srcdev-nuxt-components 9.4.2 → 9.4.3
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 +3 -3
- package/.claude/skills/composable-analytics.md +160 -0
- package/.claude/skills/composable-cookie-consent.md +3 -3
- package/.claude/skills/index.md +1 -1
- package/app/composables/tests/useAnalytics.spec.ts +177 -0
- package/app/composables/tests/useCookieConsent.spec.ts +1 -1
- package/app/composables/useAnalytics.ts +101 -0
- package/app/composables/useCookieConsent.ts +2 -2
- package/nuxt.config.ts +10 -4
- package/package.json +1 -1
- package/.claude/skills/composable-google-analytics.md +0 -105
- package/app/composables/tests/useGoogleAnalytics.spec.ts +0 -90
- package/app/composables/useGoogleAnalytics.ts +0 -48
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Overview
|
|
4
4
|
|
|
5
|
-
Fixed, non-modal banner shown while cookie consent is undecided (`useCookieConsent().status === 'unset'`). Accept/Reject buttons call `acceptAll()`/`rejectAll()` on [[composable-cookie-consent]] directly — no `v-model`, no emits to wire up. Pair with [[composable-
|
|
5
|
+
Fixed, non-modal banner shown while cookie consent is undecided (`useCookieConsent().status === 'unset'`). Accept/Reject buttons call `acceptAll()`/`rejectAll()` on [[composable-cookie-consent]] directly — no `v-model`, no emits to wire up. Pair with [[composable-analytics]] (or any other consent-gated script) which reads the same consent state.
|
|
6
6
|
|
|
7
7
|
**Location**: `app/components/01.atoms/cookie-consent-banner/CookieConsentBanner.vue`
|
|
8
8
|
|
|
@@ -44,13 +44,13 @@ Per [[feedback_i18n_required]] in consuming apps, always fill these slots with `
|
|
|
44
44
|
|
|
45
45
|
## Basic usage
|
|
46
46
|
|
|
47
|
-
Register once, in the app's default layout, alongside `DisplayToastProvider` and a call to `
|
|
47
|
+
Register once, in the app's default layout, alongside `DisplayToastProvider` and a call to `useAnalytics()`:
|
|
48
48
|
|
|
49
49
|
```vue
|
|
50
50
|
<!-- layouts/default.vue -->
|
|
51
51
|
<script setup lang="ts">
|
|
52
52
|
const { t } = useI18n();
|
|
53
|
-
|
|
53
|
+
useAnalytics();
|
|
54
54
|
</script>
|
|
55
55
|
|
|
56
56
|
<template>
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# useAnalytics Composable
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
`useAnalytics` is the **only** analytics composable consuming apps ever call — it fires custom events, tracks page views on client-side route changes, and does the one-time provider setup (script loading, consent wiring), all through the same call. It's deliberately provider-agnostic: `public.analytics.provider` in runtime config picks the backend (today only `"google-analytics"` is implemented), so call sites never reference a specific provider and adding a second one later doesn't touch any consuming-app code.
|
|
6
|
+
|
|
7
|
+
Gated behind [[composable-cookie-consent]]'s consent state — events don't fire, and (for the Google Analytics provider) `gtag.js` doesn't set any cookie, until the visitor accepts via [[cookie-consent-banner]] (or your own call to `useCookieConsent().acceptAll()`).
|
|
8
|
+
|
|
9
|
+
**This composable ships inside the `srcdev-nuxt-components` layer** (`app/composables/useAnalytics.ts`). Consuming apps get it via Nuxt's layer auto-import — **do not create a local copy** in the consuming app.
|
|
10
|
+
|
|
11
|
+
## Prerequisites (for the `google-analytics` provider)
|
|
12
|
+
|
|
13
|
+
- `NUXT_PUBLIC_ANALYTICS_GOOGLE_ANALYTICS_ID` env var set to your GA4 measurement ID (`G-XXXXXXXXXX`).
|
|
14
|
+
- `@nuxt/scripts` — already a layer dependency, registered in the layer's own `modules` array. Nothing to add in the consuming app.
|
|
15
|
+
- The app's CSP (if using `nuxt-security`) must allow `https://www.googletagmanager.com` in `script-src`/`connect-src` and `https://*.google-analytics.com` (a wildcard — GA4 posts to regional subdomains like `region1.google-analytics.com`, not just the bare host) in `connect-src`, or hits will be blocked once consent is granted.
|
|
16
|
+
|
|
17
|
+
## Setup in the consuming app
|
|
18
|
+
|
|
19
|
+
### 1. Runtime config
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
// nuxt.config.ts
|
|
23
|
+
runtimeConfig: {
|
|
24
|
+
public: {
|
|
25
|
+
analytics: {
|
|
26
|
+
provider: "google-analytics", // only value implemented today
|
|
27
|
+
googleAnalytics: {
|
|
28
|
+
id: "", // NUXT_PUBLIC_ANALYTICS_GOOGLE_ANALYTICS_ID
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### 2. Call it once, near the app root
|
|
36
|
+
|
|
37
|
+
Call `useAnalytics()` once in the default layout's `<script setup>`, alongside where [[cookie-consent-banner]] is registered — this is what wires up script loading, the consent watcher, and site-wide page-view tracking on route change:
|
|
38
|
+
|
|
39
|
+
```vue
|
|
40
|
+
<script setup lang="ts">
|
|
41
|
+
useAnalytics();
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<template>
|
|
45
|
+
<div class="page-layout">
|
|
46
|
+
<slot />
|
|
47
|
+
<CookieConsentBanner />
|
|
48
|
+
</div>
|
|
49
|
+
</template>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
It no-ops (with a console warning) if the active provider's config is unset — safe to call unconditionally in every environment, including local dev without a real measurement ID.
|
|
53
|
+
|
|
54
|
+
### 3. Fire events from anywhere
|
|
55
|
+
|
|
56
|
+
`useAnalytics()` is also the API for firing custom events from any page or component — safe/cheap to call repeatedly (the underlying script instance is a registry singleton keyed by measurement ID, so this never re-runs setup or duplicates the consent watcher or the page-view route listener):
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
const { trackEvent } = useAnalytics();
|
|
60
|
+
|
|
61
|
+
const handlePlanSelect = (plan: PricingPlan) => {
|
|
62
|
+
trackEvent("select_plan", { plan_tier: plan.id, value: plan.price, currency: "GBP" });
|
|
63
|
+
// ...
|
|
64
|
+
};
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`trackEvent(name, params)` only accepts flat string/number/boolean param values (matches GA4's own event-param constraints) and silently no-ops until consent is granted — never queues events from before consent, by design.
|
|
68
|
+
|
|
69
|
+
## Composable reference
|
|
70
|
+
|
|
71
|
+
Source lives at `app/composables/useAnalytics.ts` in the layer. Shown here for reference only — do not recreate it in the consuming app.
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
export type AnalyticsProvider = "google-analytics";
|
|
75
|
+
|
|
76
|
+
interface AnalyticsProviderImpl {
|
|
77
|
+
trackEvent: (name: string, params?: Record<string, string | number | boolean>) => void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let pageViewTrackingRegistered = false;
|
|
81
|
+
|
|
82
|
+
function useGoogleAnalyticsProvider(): AnalyticsProviderImpl | null {
|
|
83
|
+
const config = useRuntimeConfig();
|
|
84
|
+
const id = config.public.analytics?.googleAnalytics?.id;
|
|
85
|
+
if (!id) {
|
|
86
|
+
console.warn("[useAnalytics] public.analytics.googleAnalytics.id is not configured");
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const { status, trigger } = useCookieConsent();
|
|
91
|
+
const { proxy, consent } = useScriptGoogleAnalytics({
|
|
92
|
+
id,
|
|
93
|
+
scriptOptions: { trigger },
|
|
94
|
+
defaultConsent: {
|
|
95
|
+
ad_storage: "denied",
|
|
96
|
+
ad_user_data: "denied",
|
|
97
|
+
ad_personalization: "denied",
|
|
98
|
+
analytics_storage: "denied",
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
watch(
|
|
103
|
+
status,
|
|
104
|
+
(value) => {
|
|
105
|
+
if (value === "unset" || !consent) return;
|
|
106
|
+
const granted = value === "granted";
|
|
107
|
+
consent.update({
|
|
108
|
+
ad_storage: granted ? "granted" : "denied",
|
|
109
|
+
ad_user_data: granted ? "granted" : "denied",
|
|
110
|
+
ad_personalization: granted ? "granted" : "denied",
|
|
111
|
+
analytics_storage: granted ? "granted" : "denied",
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
{ immediate: true }
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
if (import.meta.client && !pageViewTrackingRegistered) {
|
|
118
|
+
pageViewTrackingRegistered = true;
|
|
119
|
+
const router = useRouter();
|
|
120
|
+
let hasNavigated = false;
|
|
121
|
+
router.afterEach((to) => {
|
|
122
|
+
if (!hasNavigated) { hasNavigated = true; return }
|
|
123
|
+
if (status.value !== "granted") return;
|
|
124
|
+
proxy.gtag("event", "page_view", { page_path: to.fullPath, page_title: document.title });
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
trackEvent: (name, params) => {
|
|
130
|
+
if (status.value !== "granted") return;
|
|
131
|
+
proxy.gtag("event", name, params);
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function useAnalytics() {
|
|
137
|
+
const config = useRuntimeConfig();
|
|
138
|
+
const provider = config.public.analytics?.provider as AnalyticsProvider | undefined;
|
|
139
|
+
const impl = provider === "google-analytics" ? useGoogleAnalyticsProvider() : null;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
trackEvent: (name: string, params?: Record<string, string | number | boolean>) => {
|
|
143
|
+
impl?.trackEvent(name, params);
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Key rules
|
|
150
|
+
|
|
151
|
+
- **The public surface (`trackEvent`) never mentions a provider.** All GA-specific code lives in `useGoogleAnalyticsProvider()`, which is not exported. Adding a second provider means: add a branch in `useAnalytics()`, write a new `use<Provider>Provider()` implementing the same `{ trackEvent }` shape, extend the `AnalyticsProvider` union and `public.analytics.<provider>` config — no consuming-app call site changes.
|
|
152
|
+
- **`trigger` only gates whether the script *loads*.** It does not itself update the Consent Mode v2 signals — `consent.update()` is the separate API that reports the visitor's actual decision to `gtag.js` for every hit it sends. Skipping this was a real bug: hits fired successfully (visible in Network) but stayed tagged as denied forever, which GA4 excludes from standard reporting.
|
|
153
|
+
- **The `router.afterEach` page-view listener is registered exactly once**, guarded by a module-scope flag (`pageViewTrackingRegistered`) — not per call-site. Unlike `trackEvent`, which is safe to fire from every component that calls `useAnalytics()`, a router listener registered per call-site would fire once per component on every single route change. The very first navigation is skipped (already covered by `gtag('config', id)`'s own initial page_view).
|
|
154
|
+
- **This composable, not `@nuxt/scripts`' GA registry, is what makes SPA route changes tracked at all** — `@nuxt/scripts`' GA integration has no router hook of its own; without this, only the very first page load (before any client-side navigation) would ever produce a `page_view`.
|
|
155
|
+
|
|
156
|
+
## Notes
|
|
157
|
+
|
|
158
|
+
- Only the Google Analytics provider is implemented — no other analytics backend exists yet, despite the provider-agnostic naming.
|
|
159
|
+
- The measurement ID in `public` config is visible in the client bundle — expected, GA4 IDs are not secret.
|
|
160
|
+
- Event names/params are entirely up to the consuming app; this composable doesn't define any standard event vocabulary (e.g. GA4's own `select_item`/`begin_checkout`/`purchase` ecommerce event shapes) — follow GA4's own conventions for anything you want its ecommerce reports to recognize.
|
|
@@ -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 `privacy-notice-consent` cookie, and wraps `@nuxt/scripts`' `useScriptTriggerConsent()` gate so any consent-dependent script (Google Analytics via [[composable-
|
|
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-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
|
|
|
@@ -57,7 +57,7 @@ export function useCookieConsent() {
|
|
|
57
57
|
### Key rules
|
|
58
58
|
|
|
59
59
|
- **`useScriptTriggerConsent()` is called once at module scope**, mirroring `@nuxt/scripts`' own documented pattern — it's a single shared gate for the app's lifetime, not a fresh instance per call-site. `useCookie()` is read fresh inside the function body on every call instead, which stays SSR-request-safe (Nuxt dedupes `useCookie()` by key within a single request).
|
|
60
|
-
- **`status` is the public read API.** `trigger` is exposed only so `
|
|
60
|
+
- **`status` is the public read API.** `trigger` is exposed only so `useAnalytics` (or another consent-gated script composable) can pass it straight into `scriptOptions.trigger` — don't read/mutate `trigger` directly from app code, use `status`/`acceptAll`/`rejectAll`.
|
|
61
61
|
- A prior "granted" cookie is replayed into the trigger on init, since the in-memory trigger resets on every full page load but the cookie doesn't.
|
|
62
62
|
|
|
63
63
|
## Usage
|
|
@@ -75,4 +75,4 @@ To let a user change their mind later (e.g. a "cookie preferences" link in the f
|
|
|
75
75
|
## Notes
|
|
76
76
|
|
|
77
77
|
- Cookie name `privacy-notice-consent` is fixed by the layer, not configurable per app.
|
|
78
|
-
- This composable only tracks the yes/no decision — it does not itself load any script. See [[composable-
|
|
78
|
+
- This composable only tracks the yes/no decision — it does not itself load any script. See [[composable-analytics]] for the GA4 integration that consumes `trigger`.
|
package/.claude/skills/index.md
CHANGED
|
@@ -60,7 +60,7 @@ Each skill is a single markdown file named `<area>-<task>.md`.
|
|
|
60
60
|
├── composable-anchor-scroll.md — useAnchorScroll: smooth anchor scrolling with reduced-motion support, dynamic offset, and TabNavigation integration
|
|
61
61
|
├── composable-tooltips-guide.md — useTooltipsGuide: sequential popover guide with auto-start, dismiss-to-advance, manual controls
|
|
62
62
|
├── composable-cookie-consent.md — useCookieConsent: unset/granted/denied state, cookie persistence, wraps @nuxt/scripts' useScriptTriggerConsent
|
|
63
|
-
├── composable-
|
|
63
|
+
├── composable-analytics.md — useAnalytics: provider-agnostic trackEvent/page-view tracking (google-analytics only implemented), consent-gated, single call site for setup + firing events
|
|
64
64
|
└── components/
|
|
65
65
|
├── alert-content-inner.md — AlertContentInner: shared icon/body/dismiss molecule under AlertContent + AlertMaskedContent; app.config icon map (alertContent.icons + dismissIcon) covers all consumers
|
|
66
66
|
├── accordian-core.md — AccordianCore indexed dynamic slots (accordian-{n}-summary/icon/content), exclusive-open grouping
|
|
@@ -0,0 +1,177 @@
|
|
|
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
|
+
// useAnalytics's page-view tracking is registered once via a module-scope flag (see
|
|
6
|
+
// useAnalytics.ts) — vi.resetModules() + a fresh dynamic import per test gives each test its
|
|
7
|
+
// own clean flag state, matching a real fresh page load. useRouter() itself is NOT mocked —
|
|
8
|
+
// Nuxt's own test-app bootstrap (and other internal plugins) depend on a real router instance,
|
|
9
|
+
// so router.afterEach is spied on directly on the real router per test instead.
|
|
10
|
+
const { updateSpy, gtagSpy, useScriptGoogleAnalyticsMock, useRuntimeConfigMock } = vi.hoisted(() => ({
|
|
11
|
+
updateSpy: vi.fn(),
|
|
12
|
+
gtagSpy: vi.fn(),
|
|
13
|
+
useScriptGoogleAnalyticsMock: vi.fn(() => ({
|
|
14
|
+
proxy: { gtag: gtagSpy },
|
|
15
|
+
consent: { update: updateSpy, default: vi.fn() },
|
|
16
|
+
})),
|
|
17
|
+
useRuntimeConfigMock: vi.fn(() => ({
|
|
18
|
+
public: { analytics: { provider: "google-analytics", googleAnalytics: { id: "" } } },
|
|
19
|
+
app: { baseURL: "/" },
|
|
20
|
+
})),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const statusRef = ref<"unset" | "granted" | "denied">("unset");
|
|
24
|
+
const trigger = { consented: ref(false), accept: vi.fn(), revoke: vi.fn() };
|
|
25
|
+
|
|
26
|
+
const defaultRuntimeConfig = () => ({
|
|
27
|
+
public: { analytics: { provider: "google-analytics" as const, googleAnalytics: { id: "" } } },
|
|
28
|
+
app: { baseURL: "/" },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
mockNuxtImport("useRuntimeConfig", () => useRuntimeConfigMock);
|
|
32
|
+
mockNuxtImport("useScriptGoogleAnalytics", () => useScriptGoogleAnalyticsMock);
|
|
33
|
+
mockNuxtImport("useCookieConsent", () => () => ({ status: statusRef, trigger }));
|
|
34
|
+
|
|
35
|
+
async function importFresh() {
|
|
36
|
+
vi.resetModules();
|
|
37
|
+
return (await import("../useAnalytics")).useAnalytics;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function configuredWith(id: string) {
|
|
41
|
+
useRuntimeConfigMock.mockReturnValue({
|
|
42
|
+
public: { analytics: { provider: "google-analytics" as const, googleAnalytics: { id } } },
|
|
43
|
+
app: { baseURL: "/" },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("useAnalytics", () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
statusRef.value = "unset";
|
|
50
|
+
trigger.accept.mockClear();
|
|
51
|
+
trigger.revoke.mockClear();
|
|
52
|
+
updateSpy.mockClear();
|
|
53
|
+
gtagSpy.mockClear();
|
|
54
|
+
useScriptGoogleAnalyticsMock.mockClear();
|
|
55
|
+
useRuntimeConfigMock.mockReturnValue(defaultRuntimeConfig());
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("warns and does nothing when googleAnalytics.id is unconfigured", async () => {
|
|
59
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
60
|
+
const useAnalytics = await importFresh();
|
|
61
|
+
useAnalytics();
|
|
62
|
+
expect(warnSpy).toHaveBeenCalledWith("[useAnalytics] public.analytics.googleAnalytics.id is not configured");
|
|
63
|
+
expect(useScriptGoogleAnalyticsMock).not.toHaveBeenCalled();
|
|
64
|
+
warnSpy.mockRestore();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("does nothing when provider is not 'google-analytics'", async () => {
|
|
68
|
+
useRuntimeConfigMock.mockReturnValue({
|
|
69
|
+
public: { analytics: { provider: "other-provider", googleAnalytics: { id: "G-TEST123" } } },
|
|
70
|
+
app: { baseURL: "/" },
|
|
71
|
+
});
|
|
72
|
+
const useAnalytics = await importFresh();
|
|
73
|
+
const { trackEvent } = useAnalytics();
|
|
74
|
+
trackEvent("test_event");
|
|
75
|
+
expect(useScriptGoogleAnalyticsMock).not.toHaveBeenCalled();
|
|
76
|
+
expect(gtagSpy).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("calls useScriptGoogleAnalytics with the configured id and defaultConsent denied", async () => {
|
|
80
|
+
configuredWith("G-TEST123");
|
|
81
|
+
const useAnalytics = await importFresh();
|
|
82
|
+
useAnalytics();
|
|
83
|
+
expect(useScriptGoogleAnalyticsMock).toHaveBeenCalledWith(
|
|
84
|
+
expect.objectContaining({
|
|
85
|
+
id: "G-TEST123",
|
|
86
|
+
defaultConsent: {
|
|
87
|
+
ad_storage: "denied",
|
|
88
|
+
ad_user_data: "denied",
|
|
89
|
+
ad_personalization: "denied",
|
|
90
|
+
analytics_storage: "denied",
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("does not call consent.update() while status is 'unset'", async () => {
|
|
97
|
+
configuredWith("G-TEST123");
|
|
98
|
+
const useAnalytics = await importFresh();
|
|
99
|
+
useAnalytics();
|
|
100
|
+
expect(updateSpy).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("calls consent.update() with all signals granted once status becomes 'granted'", async () => {
|
|
104
|
+
configuredWith("G-TEST123");
|
|
105
|
+
const useAnalytics = await importFresh();
|
|
106
|
+
useAnalytics();
|
|
107
|
+
statusRef.value = "granted";
|
|
108
|
+
await nextTick();
|
|
109
|
+
expect(updateSpy).toHaveBeenCalledWith({
|
|
110
|
+
ad_storage: "granted",
|
|
111
|
+
ad_user_data: "granted",
|
|
112
|
+
ad_personalization: "granted",
|
|
113
|
+
analytics_storage: "granted",
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("trackEvent() does nothing while status is not 'granted'", async () => {
|
|
118
|
+
configuredWith("G-TEST123");
|
|
119
|
+
const useAnalytics = await importFresh();
|
|
120
|
+
const { trackEvent } = useAnalytics();
|
|
121
|
+
trackEvent("select_plan", { plan_tier: "basic" });
|
|
122
|
+
expect(gtagSpy).not.toHaveBeenCalledWith("event", "select_plan", expect.anything());
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("trackEvent() calls proxy.gtag('event', name, params) once status is 'granted'", async () => {
|
|
126
|
+
configuredWith("G-TEST123");
|
|
127
|
+
const useAnalytics = await importFresh();
|
|
128
|
+
const { trackEvent } = useAnalytics();
|
|
129
|
+
statusRef.value = "granted";
|
|
130
|
+
await nextTick();
|
|
131
|
+
trackEvent("select_plan", { plan_tier: "basic" });
|
|
132
|
+
expect(gtagSpy).toHaveBeenCalledWith("event", "select_plan", { plan_tier: "basic" });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("registers a single router.afterEach listener no matter how many times useAnalytics() is called", async () => {
|
|
136
|
+
configuredWith("G-TEST123");
|
|
137
|
+
const router = useRouter();
|
|
138
|
+
const afterEachSpy = vi.spyOn(router, "afterEach");
|
|
139
|
+
const useAnalytics = await importFresh();
|
|
140
|
+
useAnalytics();
|
|
141
|
+
useAnalytics(); // e.g. called again from a second component
|
|
142
|
+
expect(afterEachSpy).toHaveBeenCalledTimes(1);
|
|
143
|
+
afterEachSpy.mockRestore();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("skips the first navigation (already covered by gtag('config')'s own page_view) and fires page_view on subsequent navigations once granted", async () => {
|
|
147
|
+
configuredWith("G-TEST123");
|
|
148
|
+
const router = useRouter();
|
|
149
|
+
const afterEachSpy = vi.spyOn(router, "afterEach");
|
|
150
|
+
const useAnalytics = await importFresh();
|
|
151
|
+
useAnalytics();
|
|
152
|
+
statusRef.value = "granted";
|
|
153
|
+
await nextTick();
|
|
154
|
+
|
|
155
|
+
const afterEachCallback = afterEachSpy.mock.calls[0]![0] as (to: { fullPath: string }) => void;
|
|
156
|
+
afterEachCallback({ fullPath: "/first" });
|
|
157
|
+
expect(gtagSpy).not.toHaveBeenCalledWith("event", "page_view", expect.anything());
|
|
158
|
+
|
|
159
|
+
afterEachCallback({ fullPath: "/second" });
|
|
160
|
+
expect(gtagSpy).toHaveBeenCalledWith("event", "page_view", { page_path: "/second", page_title: document.title });
|
|
161
|
+
afterEachSpy.mockRestore();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("does not fire page_view on navigation while consent is not granted", async () => {
|
|
165
|
+
configuredWith("G-TEST123");
|
|
166
|
+
const router = useRouter();
|
|
167
|
+
const afterEachSpy = vi.spyOn(router, "afterEach");
|
|
168
|
+
const useAnalytics = await importFresh();
|
|
169
|
+
useAnalytics();
|
|
170
|
+
|
|
171
|
+
const afterEachCallback = afterEachSpy.mock.calls[0]![0] as (to: { fullPath: string }) => void;
|
|
172
|
+
afterEachCallback({ fullPath: "/first" }); // consumed as the "initial" navigation
|
|
173
|
+
afterEachCallback({ fullPath: "/second" });
|
|
174
|
+
expect(gtagSpy).not.toHaveBeenCalledWith("event", "page_view", expect.anything());
|
|
175
|
+
afterEachSpy.mockRestore();
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -79,7 +79,7 @@ describe("useCookieConsent", () => {
|
|
|
79
79
|
expect(revokeSpy).toHaveBeenCalled();
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
it("exposes the raw trigger for
|
|
82
|
+
it("exposes the raw trigger for useAnalytics to consume", () => {
|
|
83
83
|
const { trigger } = useCookieConsent();
|
|
84
84
|
expect(trigger).toBeTruthy();
|
|
85
85
|
expect(typeof trigger.accept).toBe("function");
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
export type AnalyticsProvider = "google-analytics";
|
|
2
|
+
|
|
3
|
+
interface AnalyticsProviderImpl {
|
|
4
|
+
trackEvent: (name: string, params?: Record<string, string | number | boolean>) => void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// Registered once regardless of how many times useAnalytics() is called across the app —
|
|
8
|
+
// unlike trackEvent, a router listener must not be set up per call-site, or every route change
|
|
9
|
+
// would fire once per component that ever called useAnalytics(). Guarded to client-only so this
|
|
10
|
+
// module-scope flag can't get "used up" by a server render before any client code runs.
|
|
11
|
+
let pageViewTrackingRegistered = false;
|
|
12
|
+
|
|
13
|
+
function useGoogleAnalyticsProvider(): AnalyticsProviderImpl | null {
|
|
14
|
+
const config = useRuntimeConfig();
|
|
15
|
+
const id = config.public.analytics?.googleAnalytics?.id;
|
|
16
|
+
|
|
17
|
+
if (!id) {
|
|
18
|
+
console.warn("[useAnalytics] public.analytics.googleAnalytics.id is not configured");
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { status, trigger } = useCookieConsent();
|
|
23
|
+
|
|
24
|
+
const { proxy, consent } = useScriptGoogleAnalytics({
|
|
25
|
+
id,
|
|
26
|
+
scriptOptions: { trigger },
|
|
27
|
+
defaultConsent: {
|
|
28
|
+
ad_storage: "denied",
|
|
29
|
+
ad_user_data: "denied",
|
|
30
|
+
ad_personalization: "denied",
|
|
31
|
+
analytics_storage: "denied",
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// `trigger` above only gates whether gtag.js *loads* — it does not itself flip the Consent
|
|
36
|
+
// Mode v2 signals gtag.js reports alongside every hit. Without this, hits fire successfully
|
|
37
|
+
// (visible in Network) but stay tagged with defaultConsent's "denied" state forever, which
|
|
38
|
+
// GA4 excludes from standard reporting — a real bug found via a live hit's gcs=G100/pscdl=denied
|
|
39
|
+
// query params still showing after accepting, 2026-09-06. `consent.update()` is the separate
|
|
40
|
+
// API that actually reports the visitor's decision back to gtag.js; `immediate: true` also
|
|
41
|
+
// covers a returning visitor whose cookie already says "granted" on this page load.
|
|
42
|
+
watch(
|
|
43
|
+
status,
|
|
44
|
+
(value) => {
|
|
45
|
+
if (value === "unset" || !consent) return;
|
|
46
|
+
const granted = value === "granted";
|
|
47
|
+
consent.update({
|
|
48
|
+
ad_storage: granted ? "granted" : "denied",
|
|
49
|
+
ad_user_data: granted ? "granted" : "denied",
|
|
50
|
+
ad_personalization: granted ? "granted" : "denied",
|
|
51
|
+
analytics_storage: granted ? "granted" : "denied",
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
{ immediate: true }
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (import.meta.client && !pageViewTrackingRegistered) {
|
|
58
|
+
pageViewTrackingRegistered = true;
|
|
59
|
+
const router = useRouter();
|
|
60
|
+
let hasNavigated = false;
|
|
61
|
+
router.afterEach((to) => {
|
|
62
|
+
if (!hasNavigated) {
|
|
63
|
+
// The initial load's page_view is already sent by gtag('config', id) inside
|
|
64
|
+
// useScriptGoogleAnalytics' own clientInit — only subsequent client-side
|
|
65
|
+
// navigations (which don't otherwise fire page_view anywhere in this app or
|
|
66
|
+
// @nuxt/scripts' GA registry) need an explicit one here.
|
|
67
|
+
hasNavigated = true;
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (status.value !== "granted") return;
|
|
71
|
+
proxy.gtag("event", "page_view", {
|
|
72
|
+
page_path: to.fullPath,
|
|
73
|
+
page_title: document.title,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
trackEvent: (name, params) => {
|
|
80
|
+
if (status.value !== "granted") return;
|
|
81
|
+
proxy.gtag("event", name, params);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Call from anywhere — the layout, for one-time setup (script load, consent wiring, page-view
|
|
87
|
+
// tracking), or any page/component, to fire an event. Safe/cheap to call repeatedly: the
|
|
88
|
+
// underlying script instance is a registry singleton keyed by measurement ID (@nuxt/scripts
|
|
89
|
+
// dedupes by id), so this never re-runs clientInit or duplicates the consent watcher.
|
|
90
|
+
export function useAnalytics() {
|
|
91
|
+
const config = useRuntimeConfig();
|
|
92
|
+
const provider = config.public.analytics?.provider as AnalyticsProvider | undefined;
|
|
93
|
+
|
|
94
|
+
const impl = provider === "google-analytics" ? useGoogleAnalyticsProvider() : null;
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
trackEvent: (name: string, params?: Record<string, string | number | boolean>) => {
|
|
98
|
+
impl?.trackEvent(name, params);
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -2,7 +2,7 @@ import type { CookieConsentStatus } from "~/types/components";
|
|
|
2
2
|
|
|
3
3
|
// Singleton, same pattern @nuxt/scripts itself documents for
|
|
4
4
|
// useScriptTriggerConsent (a single shared gate for the app's lifetime, not a
|
|
5
|
-
// fresh instance per call-site). This is what
|
|
5
|
+
// fresh instance per call-site). This is what useAnalytics()'s
|
|
6
6
|
// `trigger` option is wired to.
|
|
7
7
|
//
|
|
8
8
|
// Built lazily on first call rather than at module scope — module-scope
|
|
@@ -47,7 +47,7 @@ export function useCookieConsent() {
|
|
|
47
47
|
acceptAll,
|
|
48
48
|
rejectAll,
|
|
49
49
|
// Internal API: pass straight into useScriptGoogleAnalytics's
|
|
50
|
-
// scriptOptions.trigger (see
|
|
50
|
+
// scriptOptions.trigger (see useAnalytics()). Not for consuming
|
|
51
51
|
// app code to read/mutate directly — use status/acceptAll/rejectAll.
|
|
52
52
|
trigger,
|
|
53
53
|
};
|
package/nuxt.config.ts
CHANGED
|
@@ -26,10 +26,16 @@ export default defineNuxtConfig({
|
|
|
26
26
|
colourScheme: {
|
|
27
27
|
enabled: true,
|
|
28
28
|
},
|
|
29
|
-
// Consumer apps
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
// Consumer apps call useAnalytics() to fire events / track page views. `provider`
|
|
30
|
+
// selects the backend — only "google-analytics" is implemented today, but call sites
|
|
31
|
+
// never reference a provider directly, so adding a second one later is additive.
|
|
32
|
+
// Set NUXT_PUBLIC_ANALYTICS_GOOGLE_ANALYTICS_ID to a GA4 measurement ID (G-XXXXXXXXXX)
|
|
33
|
+
// to enable it; left empty it no-ops.
|
|
34
|
+
analytics: {
|
|
35
|
+
provider: "google-analytics",
|
|
36
|
+
googleAnalytics: {
|
|
37
|
+
id: "",
|
|
38
|
+
},
|
|
33
39
|
},
|
|
34
40
|
},
|
|
35
41
|
},
|
package/package.json
CHANGED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
# useGoogleAnalytics Composable
|
|
2
|
-
|
|
3
|
-
## Overview
|
|
4
|
-
|
|
5
|
-
`useGoogleAnalytics` loads Google Analytics 4 (`gtag.js`) via `@nuxt/scripts`' `useScriptGoogleAnalytics()`, gated behind [[composable-cookie-consent]]'s consent state and Google Consent Mode v2's `defaultConsent`. The script does not fetch, and no analytics cookie is set, until the visitor accepts via [[cookie-consent-banner]] (or your own call to `useCookieConsent().acceptAll()`).
|
|
6
|
-
|
|
7
|
-
**This composable ships inside the `srcdev-nuxt-components` layer** (`app/composables/useGoogleAnalytics.ts`). Consuming apps get it via Nuxt's layer auto-import — **do not create a local copy** in the consuming app.
|
|
8
|
-
|
|
9
|
-
## Prerequisites
|
|
10
|
-
|
|
11
|
-
- `NUXT_PUBLIC_GOOGLE_ANALYTICS_ID` env var set to your GA4 measurement ID (`G-XXXXXXXXXX`).
|
|
12
|
-
- `@nuxt/scripts` — already a layer dependency, registered in the layer's own `modules` array. Nothing to add in the consuming app.
|
|
13
|
-
- The app's CSP (if using `nuxt-security`) must allow `https://www.googletagmanager.com` in `script-src`/`connect-src` and `https://www.google-analytics.com` in `connect-src`, or the script will be blocked once consent is granted.
|
|
14
|
-
|
|
15
|
-
## Setup in the consuming app
|
|
16
|
-
|
|
17
|
-
### 1. Runtime config
|
|
18
|
-
|
|
19
|
-
```ts
|
|
20
|
-
// nuxt.config.ts
|
|
21
|
-
runtimeConfig: {
|
|
22
|
-
public: {
|
|
23
|
-
googleAnalytics: {
|
|
24
|
-
id: "", // NUXT_PUBLIC_GOOGLE_ANALYTICS_ID
|
|
25
|
-
},
|
|
26
|
-
},
|
|
27
|
-
},
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
### 2. Call it once
|
|
31
|
-
|
|
32
|
-
Call `useGoogleAnalytics()` once, near the app root — e.g. in the default layout's `<script setup>`, alongside where [[cookie-consent-banner]] is registered:
|
|
33
|
-
|
|
34
|
-
```vue
|
|
35
|
-
<script setup lang="ts">
|
|
36
|
-
useGoogleAnalytics();
|
|
37
|
-
</script>
|
|
38
|
-
|
|
39
|
-
<template>
|
|
40
|
-
<div class="page-layout">
|
|
41
|
-
<slot />
|
|
42
|
-
<CookieConsentBanner />
|
|
43
|
-
</div>
|
|
44
|
-
</template>
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
It no-ops (with a console warning) if `googleAnalytics.id` is unset — safe to call unconditionally in every environment, including local dev without a real measurement ID.
|
|
48
|
-
|
|
49
|
-
## Composable reference
|
|
50
|
-
|
|
51
|
-
Source lives at `app/composables/useGoogleAnalytics.ts` in the layer. Shown here for reference only — do not recreate it in the consuming app.
|
|
52
|
-
|
|
53
|
-
```ts
|
|
54
|
-
export const useGoogleAnalytics = () => {
|
|
55
|
-
const config = useRuntimeConfig();
|
|
56
|
-
const id = config.public.googleAnalytics?.id;
|
|
57
|
-
|
|
58
|
-
if (!id) {
|
|
59
|
-
console.warn("[useGoogleAnalytics] public.googleAnalytics.id is not configured");
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const { status, trigger } = useCookieConsent();
|
|
64
|
-
|
|
65
|
-
const { consent } = useScriptGoogleAnalytics({
|
|
66
|
-
id,
|
|
67
|
-
scriptOptions: { trigger },
|
|
68
|
-
defaultConsent: {
|
|
69
|
-
ad_storage: "denied",
|
|
70
|
-
ad_user_data: "denied",
|
|
71
|
-
ad_personalization: "denied",
|
|
72
|
-
analytics_storage: "denied",
|
|
73
|
-
},
|
|
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
|
-
);
|
|
92
|
-
};
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
### Key rules
|
|
96
|
-
|
|
97
|
-
- **`useRuntimeConfig()` inside the function body**, not module scope — same reasoning as `useWhatsApp` (see [[composable-whatsapp]]).
|
|
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.
|
|
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.
|
|
101
|
-
|
|
102
|
-
## Notes
|
|
103
|
-
|
|
104
|
-
- Only loads GA4 (`gtag.js`) — no other Google tags (Ads, Tag Manager container, etc.). Extend `defaultConsent`/add a second `useScript*` call if those are needed later.
|
|
105
|
-
- The measurement ID in `public` config is visible in the client bundle — expected, GA4 IDs are not secret.
|
|
@@ -1,90 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
// Call once from the consuming app's layout/app.vue. Loading is gated behind
|
|
2
|
-
// useCookieConsent() — the gtag.js script only actually fetches once the
|
|
3
|
-
// visitor accepts, and Google Consent Mode v2's default state starts denied
|
|
4
|
-
// so no analytics cookie is set before that.
|
|
5
|
-
export const useGoogleAnalytics = () => {
|
|
6
|
-
const config = useRuntimeConfig();
|
|
7
|
-
const id = config.public.googleAnalytics?.id;
|
|
8
|
-
|
|
9
|
-
if (!id) {
|
|
10
|
-
console.warn("[useGoogleAnalytics] public.googleAnalytics.id is not configured");
|
|
11
|
-
return;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
const { status, trigger } = useCookieConsent();
|
|
15
|
-
|
|
16
|
-
const { consent } = useScriptGoogleAnalytics({
|
|
17
|
-
id,
|
|
18
|
-
scriptOptions: { trigger },
|
|
19
|
-
defaultConsent: {
|
|
20
|
-
ad_storage: "denied",
|
|
21
|
-
ad_user_data: "denied",
|
|
22
|
-
ad_personalization: "denied",
|
|
23
|
-
analytics_storage: "denied",
|
|
24
|
-
},
|
|
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
|
-
);
|
|
48
|
-
};
|