srcdev-nuxt-components 9.4.0 → 9.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/components/cookie-consent-banner.md +9 -9
- package/.claude/skills/composable-cookie-consent.md +4 -4
- package/.claude/skills/composable-google-analytics.md +21 -3
- package/app/components/01.atoms/cookie-consent-banner/CookieConsentBanner.vue +24 -24
- package/app/components/01.atoms/cookie-consent-banner/stories/CookieConsentBanner.stories.ts +4 -4
- package/app/components/01.atoms/cookie-consent-banner/tests/CookieConsentBanner.spec.ts +3 -3
- package/app/composables/tests/useGoogleAnalytics.spec.ts +90 -0
- package/app/composables/useCookieConsent.ts +1 -1
- package/app/composables/useGoogleAnalytics.ts +24 -2
- package/package.json +1 -1
|
@@ -68,21 +68,21 @@ useGoogleAnalytics();
|
|
|
68
68
|
|
|
69
69
|
## CSS / styling
|
|
70
70
|
|
|
71
|
-
Public tokens (all on `.
|
|
71
|
+
Public tokens (all on `.privacy-notice-banner`, `var(--privacy-notice-banner-*, fallback)` pattern):
|
|
72
72
|
|
|
73
73
|
| Token | Default |
|
|
74
74
|
|---|---|
|
|
75
|
-
| `--
|
|
76
|
-
| `--
|
|
77
|
-
| `--
|
|
78
|
-
| `--
|
|
79
|
-
| `--
|
|
80
|
-
| `--
|
|
81
|
-
| `--
|
|
75
|
+
| `--privacy-notice-banner-z-index` | `999999` |
|
|
76
|
+
| `--privacy-notice-banner-gutter` | `1.6rem` |
|
|
77
|
+
| `--privacy-notice-banner-max-width` | `64rem` |
|
|
78
|
+
| `--privacy-notice-banner-border-radius` | `0.8rem` |
|
|
79
|
+
| `--privacy-notice-banner-border` | `0.1rem solid light-dark(var(--slate-10), var(--slate-02))` |
|
|
80
|
+
| `--privacy-notice-banner-background` | `light-dark(var(--slate-00), var(--slate-10))` |
|
|
81
|
+
| `--privacy-notice-banner-transition-duration` | `200ms` |
|
|
82
82
|
|
|
83
83
|
## Notes
|
|
84
84
|
|
|
85
|
-
- **Teleported to `<body>`** — like `DisplayToastProvider`, query it in tests via `document.querySelector(".
|
|
85
|
+
- **Teleported to `<body>`** — like `DisplayToastProvider`, query it in tests via `document.querySelector(".privacy-notice-banner")`, not `wrapper.find(...)`.
|
|
86
86
|
- **No focus trap / backdrop** — this is a dismiss-by-decision banner, not a modal. It collapses via a `grid-template-rows` transition (same mechanic as `DisplayPrompt`) once `status` leaves `"unset"`, rather than unmounting.
|
|
87
87
|
- **Only ever one instance** — `useCookieConsent()`'s underlying state is a module-scope singleton, so mounting the banner twice in one app just duplicates the UI, it doesn't create separate consent state.
|
|
88
88
|
- To let a visitor change their mind later (e.g. from a cookie-policy page), call `useCookieConsent().rejectAll()` or clear the `cookie-consent` cookie — the banner reappears since `status` returns to `"unset"` only once the cookie is gone; `rejectAll()` itself sets it to `"denied"`, which keeps the banner hidden but stops GA. Expose a dedicated "reset my choice" affordance if you want the banner itself to resurface.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Overview
|
|
4
4
|
|
|
5
|
-
`useCookieConsent` tracks the visitor's cookie-consent decision (`unset` / `granted` / `denied`), persists it in a `
|
|
5
|
+
`useCookieConsent` tracks the visitor's cookie-consent decision (`unset` / `granted` / `denied`), persists it in a `privacy-notice-consent` cookie, and wraps `@nuxt/scripts`' `useScriptTriggerConsent()` gate so any consent-dependent script (Google Analytics via [[composable-google-analytics]], or anything else added later) can be wired to it.
|
|
6
6
|
|
|
7
7
|
**This composable ships inside the `srcdev-nuxt-components` layer** (`app/composables/useCookieConsent.ts`). Consuming apps get it via Nuxt's layer auto-import — **do not create a local copy** in the consuming app.
|
|
8
8
|
|
|
@@ -28,7 +28,7 @@ Source lives at `app/composables/useCookieConsent.ts` in the layer. Shown here f
|
|
|
28
28
|
const consentTrigger = useScriptTriggerConsent(); // module-scope singleton
|
|
29
29
|
|
|
30
30
|
export function useCookieConsent() {
|
|
31
|
-
const stored = useCookie<"granted" | "denied" | null>("
|
|
31
|
+
const stored = useCookie<"granted" | "denied" | null>("privacy-notice-consent", {
|
|
32
32
|
maxAge: 60 * 60 * 24 * 365,
|
|
33
33
|
sameSite: "lax",
|
|
34
34
|
default: () => null,
|
|
@@ -70,9 +70,9 @@ if (status.value === "unset") {
|
|
|
70
70
|
}
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
-
To let a user change their mind later (e.g. a "cookie preferences" link in the footer or a cookie-policy page), call `rejectAll()` or clear the `
|
|
73
|
+
To let a user change their mind later (e.g. a "cookie preferences" link in the footer or a cookie-policy page), call `rejectAll()` or clear the `privacy-notice-consent` cookie to bring the banner back.
|
|
74
74
|
|
|
75
75
|
## Notes
|
|
76
76
|
|
|
77
|
-
- Cookie name `
|
|
77
|
+
- Cookie name `privacy-notice-consent` is fixed by the layer, not configurable per app.
|
|
78
78
|
- This composable only tracks the yes/no decision — it does not itself load any script. See [[composable-google-analytics]] for the GA4 integration that consumes `trigger`.
|
|
@@ -60,9 +60,9 @@ export const useGoogleAnalytics = () => {
|
|
|
60
60
|
return;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
const { trigger } = useCookieConsent();
|
|
63
|
+
const { status, trigger } = useCookieConsent();
|
|
64
64
|
|
|
65
|
-
useScriptGoogleAnalytics({
|
|
65
|
+
const { consent } = useScriptGoogleAnalytics({
|
|
66
66
|
id,
|
|
67
67
|
scriptOptions: { trigger },
|
|
68
68
|
defaultConsent: {
|
|
@@ -72,13 +72,31 @@ export const useGoogleAnalytics = () => {
|
|
|
72
72
|
analytics_storage: "denied",
|
|
73
73
|
},
|
|
74
74
|
});
|
|
75
|
+
|
|
76
|
+
// trigger only gates whether gtag.js *loads* — consent.update() is the separate API that
|
|
77
|
+
// actually reports the visitor's decision back to gtag.js for every hit it sends.
|
|
78
|
+
watch(
|
|
79
|
+
status,
|
|
80
|
+
(value) => {
|
|
81
|
+
if (value === "unset" || !consent) return;
|
|
82
|
+
const granted = value === "granted";
|
|
83
|
+
consent.update({
|
|
84
|
+
ad_storage: granted ? "granted" : "denied",
|
|
85
|
+
ad_user_data: granted ? "granted" : "denied",
|
|
86
|
+
ad_personalization: granted ? "granted" : "denied",
|
|
87
|
+
analytics_storage: granted ? "granted" : "denied",
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
{ immediate: true }
|
|
91
|
+
);
|
|
75
92
|
};
|
|
76
93
|
```
|
|
77
94
|
|
|
78
95
|
### Key rules
|
|
79
96
|
|
|
80
97
|
- **`useRuntimeConfig()` inside the function body**, not module scope — same reasoning as `useWhatsApp` (see [[composable-whatsapp]]).
|
|
81
|
-
- **`defaultConsent` is all `"denied"`** —
|
|
98
|
+
- **`defaultConsent` is all `"denied"`** — sets the initial Consent Mode v2 state before gtag.js has any real signal. `trigger` (passed to `scriptOptions.trigger`) only controls when the script *loads*; it does **not** itself update the Consent Mode signals. Without the `consent.update()` watcher, hits fire successfully once loaded but stay tagged as denied forever, which GA4 excludes from standard reporting — this was a real bug (hits visible in Network, "no data received" in the GA4 dashboard) until the watcher was added.
|
|
99
|
+
- The `watch(status, ..., { immediate: true })` also covers a returning visitor whose `cookie-consent` cookie already says "granted" on this page load, before any click happens.
|
|
82
100
|
- Does not return anything — it's a side-effecting setup call, not a value composable. Read GA-related state (if ever needed) via `useCookieConsent()` instead.
|
|
83
101
|
|
|
84
102
|
## Notes
|
|
@@ -1,28 +1,28 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<Teleport to="body">
|
|
3
3
|
<div
|
|
4
|
-
class="
|
|
4
|
+
class="privacy-notice-banner"
|
|
5
5
|
:class="[{ closed: status !== 'unset' }, elementClasses]"
|
|
6
6
|
:data-theme="resolved.theme"
|
|
7
|
-
data-test-id="
|
|
7
|
+
data-test-id="privacy-notice-banner"
|
|
8
8
|
>
|
|
9
|
-
<div class="
|
|
10
|
-
<div class="
|
|
9
|
+
<div class="privacy-notice-banner-inner" role="region" :aria-label="ariaLabel">
|
|
10
|
+
<div class="privacy-notice-banner-message">
|
|
11
11
|
<slot name="message">This site uses cookies to understand how it's used. You can accept or reject them.</slot>
|
|
12
12
|
</div>
|
|
13
|
-
<div class="
|
|
13
|
+
<div class="privacy-notice-banner-actions">
|
|
14
14
|
<button
|
|
15
15
|
type="button"
|
|
16
|
-
class="
|
|
17
|
-
data-test-id="
|
|
16
|
+
class="privacy-notice-banner-reject"
|
|
17
|
+
data-test-id="privacy-notice-banner-reject"
|
|
18
18
|
@click="rejectAll()"
|
|
19
19
|
>
|
|
20
20
|
<slot name="rejectLabel">Reject</slot>
|
|
21
21
|
</button>
|
|
22
22
|
<button
|
|
23
23
|
type="button"
|
|
24
|
-
class="
|
|
25
|
-
data-test-id="
|
|
24
|
+
class="privacy-notice-banner-accept"
|
|
25
|
+
data-test-id="privacy-notice-banner-accept"
|
|
26
26
|
@click="acceptAll()"
|
|
27
27
|
>
|
|
28
28
|
<slot name="acceptLabel">Accept</slot>
|
|
@@ -57,17 +57,17 @@ const ariaLabel = "Cookie consent";
|
|
|
57
57
|
|
|
58
58
|
<style lang="css">
|
|
59
59
|
@layer components {
|
|
60
|
-
.
|
|
60
|
+
.privacy-notice-banner {
|
|
61
61
|
/* Matches DisplayToastProvider/DisplayDialog's z-index convention so
|
|
62
62
|
this clears ordinary page chrome (and a consumer's sticky header) but
|
|
63
63
|
still sits below an active modal dialog if one somehow overlaps. */
|
|
64
|
-
--_z-index: var(--
|
|
65
|
-
--_gutter: var(--
|
|
66
|
-
--_max-width: var(--
|
|
67
|
-
--_border-radius: var(--
|
|
68
|
-
--_border: var(--
|
|
69
|
-
--_background: var(--
|
|
70
|
-
--_transition-duration: var(--
|
|
64
|
+
--_z-index: var(--privacy-notice-banner-z-index, 999999);
|
|
65
|
+
--_gutter: var(--privacy-notice-banner-gutter, 1.6rem);
|
|
66
|
+
--_max-width: var(--privacy-notice-banner-max-width, 64rem);
|
|
67
|
+
--_border-radius: var(--privacy-notice-banner-border-radius, 0.8rem);
|
|
68
|
+
--_border: var(--privacy-notice-banner-border, 0.1rem solid light-dark(var(--slate-10), var(--slate-02)));
|
|
69
|
+
--_background: var(--privacy-notice-banner-background, light-dark(var(--slate-00), var(--slate-10)));
|
|
70
|
+
--_transition-duration: var(--privacy-notice-banner-transition-duration, 200ms);
|
|
71
71
|
|
|
72
72
|
position: fixed;
|
|
73
73
|
inset-inline: var(--_gutter);
|
|
@@ -87,7 +87,7 @@ const ariaLabel = "Cookie consent";
|
|
|
87
87
|
pointer-events: none;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
.
|
|
90
|
+
.privacy-notice-banner-inner {
|
|
91
91
|
overflow: hidden;
|
|
92
92
|
display: flex;
|
|
93
93
|
flex-wrap: wrap;
|
|
@@ -103,18 +103,18 @@ const ariaLabel = "Cookie consent";
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
.
|
|
106
|
+
.privacy-notice-banner-message {
|
|
107
107
|
flex: 1 1 24rem;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
.
|
|
110
|
+
.privacy-notice-banner-actions {
|
|
111
111
|
display: flex;
|
|
112
112
|
gap: 0.8rem;
|
|
113
113
|
margin-inline-start: auto;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
-
.
|
|
117
|
-
.
|
|
116
|
+
.privacy-notice-banner-reject,
|
|
117
|
+
.privacy-notice-banner-accept {
|
|
118
118
|
padding: 0.8rem 1.6rem;
|
|
119
119
|
border-radius: 0.4rem;
|
|
120
120
|
border: 0.1rem solid transparent;
|
|
@@ -124,7 +124,7 @@ const ariaLabel = "Cookie consent";
|
|
|
124
124
|
background-color var(--_transition-duration);
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
.
|
|
127
|
+
.privacy-notice-banner-reject {
|
|
128
128
|
background-color: transparent;
|
|
129
129
|
border: 0.1rem solid light-dark(var(--slate-08), var(--slate-04));
|
|
130
130
|
|
|
@@ -134,7 +134,7 @@ const ariaLabel = "Cookie consent";
|
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
.
|
|
137
|
+
.privacy-notice-banner-accept {
|
|
138
138
|
background-color: var(--theme-accent);
|
|
139
139
|
color: light-dark(var(--slate-00), var(--slate-12));
|
|
140
140
|
|
package/app/components/01.atoms/cookie-consent-banner/stories/CookieConsentBanner.stories.ts
CHANGED
|
@@ -29,18 +29,18 @@ export default {
|
|
|
29
29
|
// reloads. Each story below has "Show banner again" (resets state
|
|
30
30
|
// in-place) and "Delete cookie" (clears document.cookie directly, for
|
|
31
31
|
// verifying the cookie itself is actually gone, e.g. in devtools).
|
|
32
|
-
component: "Reads/writes a real '
|
|
32
|
+
component: "Reads/writes a real 'privacy-notice-consent' cookie via useCookieConsent(). Use the story's reset controls to bring the banner back.",
|
|
33
33
|
},
|
|
34
34
|
},
|
|
35
35
|
},
|
|
36
36
|
} as Meta<typeof StorybookComponent>;
|
|
37
37
|
|
|
38
|
-
// Resets the same "
|
|
38
|
+
// Resets the same "privacy-notice-consent" cookie useCookieConsent() reads, so the
|
|
39
39
|
// banner's status goes back to "unset" and reappears without a page reload
|
|
40
40
|
// (Nuxt dedupes useCookie() by key, so this shares the ref CookieConsentBanner
|
|
41
41
|
// itself reads).
|
|
42
42
|
function useResetConsent() {
|
|
43
|
-
const consentCookie = useCookie<"granted" | "denied" | null>("
|
|
43
|
+
const consentCookie = useCookie<"granted" | "denied" | null>("privacy-notice-consent");
|
|
44
44
|
const showAgain = () => {
|
|
45
45
|
consentCookie.value = null;
|
|
46
46
|
};
|
|
@@ -48,7 +48,7 @@ function useResetConsent() {
|
|
|
48
48
|
// rather than just the in-memory ref, so devtools/Application tab reflects
|
|
49
49
|
// it too, not only the story's live re-render.
|
|
50
50
|
const deleteCookie = () => {
|
|
51
|
-
document.cookie = "
|
|
51
|
+
document.cookie = "privacy-notice-consent=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/";
|
|
52
52
|
consentCookie.value = null;
|
|
53
53
|
};
|
|
54
54
|
return { showAgain, deleteCookie };
|
|
@@ -23,15 +23,15 @@ mockNuxtImport("useCookieConsent", () => () => ({
|
|
|
23
23
|
}));
|
|
24
24
|
|
|
25
25
|
function banner() {
|
|
26
|
-
return document.querySelector(".
|
|
26
|
+
return document.querySelector(".privacy-notice-banner");
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
function acceptButton() {
|
|
30
|
-
return document.querySelector("[data-test-id='
|
|
30
|
+
return document.querySelector("[data-test-id='privacy-notice-banner-accept']") as HTMLElement;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
function rejectButton() {
|
|
34
|
-
return document.querySelector("[data-test-id='
|
|
34
|
+
return document.querySelector("[data-test-id='privacy-notice-banner-reject']") as HTMLElement;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
describe("CookieConsentBanner", () => {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import { nextTick, ref } from "vue";
|
|
3
|
+
import { mockNuxtImport } from "@nuxt/test-utils/runtime";
|
|
4
|
+
|
|
5
|
+
const { useRuntimeConfigMock, updateSpy, useScriptGoogleAnalyticsMock } = vi.hoisted(() => ({
|
|
6
|
+
useRuntimeConfigMock: vi.fn(() => ({ public: { googleAnalytics: { id: "" } }, app: { baseURL: "/" } })),
|
|
7
|
+
updateSpy: vi.fn(),
|
|
8
|
+
useScriptGoogleAnalyticsMock: vi.fn(() => ({ consent: { update: updateSpy, default: vi.fn() } })),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
const statusRef = ref<"unset" | "granted" | "denied">("unset");
|
|
12
|
+
const trigger = { consented: ref(false), accept: vi.fn(), revoke: vi.fn() };
|
|
13
|
+
|
|
14
|
+
mockNuxtImport("useRuntimeConfig", () => useRuntimeConfigMock);
|
|
15
|
+
mockNuxtImport("useScriptGoogleAnalytics", () => useScriptGoogleAnalyticsMock);
|
|
16
|
+
mockNuxtImport("useCookieConsent", () => () => ({ status: statusRef, trigger }));
|
|
17
|
+
|
|
18
|
+
const { useGoogleAnalytics } = await import("../useGoogleAnalytics");
|
|
19
|
+
|
|
20
|
+
describe("useGoogleAnalytics", () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
statusRef.value = "unset";
|
|
23
|
+
updateSpy.mockClear();
|
|
24
|
+
useScriptGoogleAnalyticsMock.mockClear();
|
|
25
|
+
useRuntimeConfigMock.mockReturnValue({ public: { googleAnalytics: { id: "" } }, app: { baseURL: "/" } });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("warns and does nothing when googleAnalytics.id is unconfigured", () => {
|
|
29
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
30
|
+
useGoogleAnalytics();
|
|
31
|
+
expect(warnSpy).toHaveBeenCalledWith("[useGoogleAnalytics] public.googleAnalytics.id is not configured");
|
|
32
|
+
expect(useScriptGoogleAnalyticsMock).not.toHaveBeenCalled();
|
|
33
|
+
warnSpy.mockRestore();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("calls useScriptGoogleAnalytics with the configured id and defaultConsent denied", () => {
|
|
37
|
+
useRuntimeConfigMock.mockReturnValue({ public: { googleAnalytics: { id: "G-TEST123" } }, app: { baseURL: "/" } });
|
|
38
|
+
useGoogleAnalytics();
|
|
39
|
+
expect(useScriptGoogleAnalyticsMock).toHaveBeenCalledWith(
|
|
40
|
+
expect.objectContaining({
|
|
41
|
+
id: "G-TEST123",
|
|
42
|
+
defaultConsent: {
|
|
43
|
+
ad_storage: "denied",
|
|
44
|
+
ad_user_data: "denied",
|
|
45
|
+
ad_personalization: "denied",
|
|
46
|
+
analytics_storage: "denied",
|
|
47
|
+
},
|
|
48
|
+
})
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("does not call consent.update() while status is 'unset'", () => {
|
|
53
|
+
useRuntimeConfigMock.mockReturnValue({ public: { googleAnalytics: { id: "G-TEST123" } }, app: { baseURL: "/" } });
|
|
54
|
+
useGoogleAnalytics();
|
|
55
|
+
expect(updateSpy).not.toHaveBeenCalled();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("calls consent.update() with all signals granted once status becomes 'granted'", async () => {
|
|
59
|
+
useRuntimeConfigMock.mockReturnValue({ public: { googleAnalytics: { id: "G-TEST123" } }, app: { baseURL: "/" } });
|
|
60
|
+
useGoogleAnalytics();
|
|
61
|
+
statusRef.value = "granted";
|
|
62
|
+
await nextTick();
|
|
63
|
+
expect(updateSpy).toHaveBeenCalledWith({
|
|
64
|
+
ad_storage: "granted",
|
|
65
|
+
ad_user_data: "granted",
|
|
66
|
+
ad_personalization: "granted",
|
|
67
|
+
analytics_storage: "granted",
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("calls consent.update() with all signals denied when status becomes 'denied'", async () => {
|
|
72
|
+
useRuntimeConfigMock.mockReturnValue({ public: { googleAnalytics: { id: "G-TEST123" } }, app: { baseURL: "/" } });
|
|
73
|
+
useGoogleAnalytics();
|
|
74
|
+
statusRef.value = "denied";
|
|
75
|
+
await nextTick();
|
|
76
|
+
expect(updateSpy).toHaveBeenCalledWith({
|
|
77
|
+
ad_storage: "denied",
|
|
78
|
+
ad_user_data: "denied",
|
|
79
|
+
ad_personalization: "denied",
|
|
80
|
+
analytics_storage: "denied",
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("immediately reports consent.update() for a returning visitor already granted", () => {
|
|
85
|
+
useRuntimeConfigMock.mockReturnValue({ public: { googleAnalytics: { id: "G-TEST123" } }, app: { baseURL: "/" } });
|
|
86
|
+
statusRef.value = "granted";
|
|
87
|
+
useGoogleAnalytics();
|
|
88
|
+
expect(updateSpy).toHaveBeenCalledWith(expect.objectContaining({ analytics_storage: "granted" }));
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -18,7 +18,7 @@ export function useCookieConsent() {
|
|
|
18
18
|
// Persists the decision across visits. Read fresh on every call so this
|
|
19
19
|
// stays request-safe during SSR (Nuxt dedupes useCookie() by key within a
|
|
20
20
|
// request, so this is cheap to call repeatedly).
|
|
21
|
-
const stored = useCookie<"granted" | "denied" | null>("
|
|
21
|
+
const stored = useCookie<"granted" | "denied" | null>("privacy-notice-consent", {
|
|
22
22
|
maxAge: 60 * 60 * 24 * 365,
|
|
23
23
|
sameSite: "lax",
|
|
24
24
|
default: () => null,
|
|
@@ -11,9 +11,9 @@ export const useGoogleAnalytics = () => {
|
|
|
11
11
|
return;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
const { trigger } = useCookieConsent();
|
|
14
|
+
const { status, trigger } = useCookieConsent();
|
|
15
15
|
|
|
16
|
-
useScriptGoogleAnalytics({
|
|
16
|
+
const { consent } = useScriptGoogleAnalytics({
|
|
17
17
|
id,
|
|
18
18
|
scriptOptions: { trigger },
|
|
19
19
|
defaultConsent: {
|
|
@@ -23,4 +23,26 @@ export const useGoogleAnalytics = () => {
|
|
|
23
23
|
analytics_storage: "denied",
|
|
24
24
|
},
|
|
25
25
|
});
|
|
26
|
+
|
|
27
|
+
// `trigger` above only gates whether gtag.js *loads* — it does not itself flip the Consent
|
|
28
|
+
// Mode v2 signals gtag.js reports alongside every hit. Without this, hits fire successfully
|
|
29
|
+
// (visible in Network) but stay tagged with defaultConsent's "denied" state forever, which
|
|
30
|
+
// GA4 excludes from standard reporting — confirmed via a real hit's gcs=G100/pscdl=denied
|
|
31
|
+
// query params still showing after accepting, 2026-09-06. `consent.update()` is the separate
|
|
32
|
+
// API that actually reports the visitor's decision back to gtag.js; `immediate: true` also
|
|
33
|
+
// covers a returning visitor whose cookie already says "granted" on this page load.
|
|
34
|
+
watch(
|
|
35
|
+
status,
|
|
36
|
+
(value) => {
|
|
37
|
+
if (value === "unset" || !consent) return;
|
|
38
|
+
const granted = value === "granted";
|
|
39
|
+
consent.update({
|
|
40
|
+
ad_storage: granted ? "granted" : "denied",
|
|
41
|
+
ad_user_data: granted ? "granted" : "denied",
|
|
42
|
+
ad_personalization: granted ? "granted" : "denied",
|
|
43
|
+
analytics_storage: granted ? "granted" : "denied",
|
|
44
|
+
});
|
|
45
|
+
},
|
|
46
|
+
{ immediate: true }
|
|
47
|
+
);
|
|
26
48
|
};
|