srcdev-nuxt-components 9.4.1 → 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.
@@ -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-google-analytics]] (or any other consent-gated script) which reads the same consent state.
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 `useGoogleAnalytics()`:
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
- useGoogleAnalytics();
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-google-analytics]], or anything else added later) can be wired to it.
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 `useGoogleAnalytics` (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`.
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-google-analytics]] for the GA4 integration that consumes `trigger`.
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`.
@@ -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-google-analytics.md — useGoogleAnalytics: GA4 via @nuxt/scripts' useScriptGoogleAnalytics, Consent Mode v2 defaultConsent, gated on useCookieConsent
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 useGoogleAnalytics to consume", () => {
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 useGoogleAnalytics()'s
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 useGoogleAnalytics()). Not for consuming
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 set NUXT_PUBLIC_GOOGLE_ANALYTICS_ID to their GA4 measurement ID
30
- // (G-XXXXXXXXXX) to enable useGoogleAnalytics(); left empty it no-ops.
31
- googleAnalytics: {
32
- id: "",
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,7 +1,7 @@
1
1
  {
2
2
  "name": "srcdev-nuxt-components",
3
3
  "type": "module",
4
- "version": "9.4.1",
4
+ "version": "9.4.3",
5
5
  "main": "nuxt.config.ts",
6
6
  "types": "types.d.ts",
7
7
  "license": "MIT",
@@ -1,87 +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 { trigger } = useCookieConsent();
64
-
65
- 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
- ```
77
-
78
- ### Key rules
79
-
80
- - **`useRuntimeConfig()` inside the function body**, not module scope — same reasoning as `useWhatsApp` (see [[composable-whatsapp]]).
81
- - **`defaultConsent` is all `"denied"`** — this is what makes the script Consent Mode v2 compliant: gtag.js can load (if triggered) but won't set cookies or send identifiable pings until `useCookieConsent().acceptAll()` calls `trigger.accept()`, which flips consent to granted via `@nuxt/scripts`' own consent-update wiring.
82
- - 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
-
84
- ## Notes
85
-
86
- - 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.
87
- - The measurement ID in `public` config is visible in the client bundle — expected, GA4 IDs are not secret.
@@ -1,26 +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 { trigger } = useCookieConsent();
15
-
16
- 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
- };