@stackonward/cli 1.0.1 → 1.2.0

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.
@@ -65,11 +65,14 @@ function openCheckout(): void {
65
65
  customer: props.customer,
66
66
  metadata: props.metadata,
67
67
  });
68
- analytics.track("checkout_started", {
69
- attempt_id: props.attemptId,
70
- currency: currency.value,
71
- value_minor: totalAmountMinor.value,
72
- item_count: props.lineItems.length,
68
+ void analytics.track({
69
+ name: "checkout_started",
70
+ properties: {
71
+ attempt_id: props.attemptId,
72
+ currency: currency.value,
73
+ value_minor: totalAmountMinor.value,
74
+ item_count: props.lineItems.length,
75
+ },
73
76
  });
74
77
  emit("checkout", url);
75
78
  if (import.meta.client) {
@@ -15,7 +15,12 @@ const props = withDefaults(
15
15
  {
16
16
  label: "Continue with Google",
17
17
  disabledLabel: "Google sign-in is not configured",
18
- options: () => ({ theme: "outline", size: "large", text: "continue_with", shape: "rectangular" }),
18
+ options: () => ({
19
+ theme: "outline",
20
+ size: "large",
21
+ text: "continue_with",
22
+ shape: "rectangular",
23
+ }),
19
24
  autoExchange: false,
20
25
  oneTap: false,
21
26
  },
@@ -35,8 +40,9 @@ const loading = ref(false);
35
40
 
36
41
  async function handleCredential(response: GoogleCredentialResponse) {
37
42
  emit("credential", response);
38
- analytics.track("identity_google_credential", {
39
- select_by: response.select_by,
43
+ void analytics.track({
44
+ name: "identity_google_credential",
45
+ ...(response.select_by === undefined ? {} : { properties: { select_by: response.select_by } }),
40
46
  });
41
47
 
42
48
  if (!props.autoExchange || !response.credential) {
@@ -48,7 +54,8 @@ async function handleCredential(response: GoogleCredentialResponse) {
48
54
  const payload = await identity.exchangeCredential(response.credential);
49
55
  emit("authenticated", payload);
50
56
  } catch (error) {
51
- const nextError = error instanceof Error ? error : new Error("Google credential exchange failed");
57
+ const nextError =
58
+ error instanceof Error ? error : new Error("Google credential exchange failed");
52
59
  errorMessage.value = nextError.message;
53
60
  emit("error", nextError);
54
61
  } finally {
@@ -68,7 +75,8 @@ async function renderGoogleButton() {
68
75
  await identity.promptOneTap(handleCredential);
69
76
  }
70
77
  } catch (error) {
71
- const nextError = error instanceof Error ? error : new Error("Google sign-in failed to initialize");
78
+ const nextError =
79
+ error instanceof Error ? error : new Error("Google sign-in failed to initialize");
72
80
  errorMessage.value = nextError.message;
73
81
  emit("error", nextError);
74
82
  }
@@ -2,23 +2,63 @@ export type ConsentValue = "accepted" | "rejected";
2
2
 
3
3
  export function useConsent(storageKey?: string) {
4
4
  const site = useSiteShell();
5
+ const analytics = useAnalytics();
6
+ const analyticsConsent = useAnalyticsConsent();
5
7
  const resolvedStorageKey = computed(() => storageKey ?? site.value.consent.storageKey);
6
8
  const value = ref<ConsentValue | null>(null);
7
9
  const ready = ref(false);
8
10
 
9
- onMounted(() => {
10
- const stored = window.localStorage.getItem(resolvedStorageKey.value);
11
+ function applyStoredValue(stored: string | null): void {
11
12
  value.value = stored === "accepted" || stored === "rejected" ? stored : null;
13
+ if (value.value === "accepted") {
14
+ analyticsConsent.grant();
15
+ } else if (value.value === "rejected") {
16
+ analyticsConsent.deny();
17
+ } else {
18
+ analyticsConsent.reset();
19
+ }
20
+ }
21
+
22
+ function handleStorage(event: StorageEvent): void {
23
+ if (event.key === resolvedStorageKey.value) {
24
+ applyStoredValue(event.newValue);
25
+ }
26
+ }
27
+
28
+ onMounted(() => {
29
+ try {
30
+ applyStoredValue(window.localStorage.getItem(resolvedStorageKey.value));
31
+ } catch {
32
+ value.value = null;
33
+ analyticsConsent.reset();
34
+ }
35
+ window.addEventListener("storage", handleStorage);
12
36
  ready.value = true;
13
37
  });
14
38
 
15
- const visible = computed(() => site.value.consent.enabled && ready.value && value.value === null);
39
+ onBeforeUnmount(() => window.removeEventListener("storage", handleStorage));
40
+
41
+ const visible = computed(
42
+ () =>
43
+ site.value.consent.enabled &&
44
+ analytics.status.value !== "disabled" &&
45
+ ready.value &&
46
+ value.value === null,
47
+ );
16
48
 
17
49
  function setConsent(next: ConsentValue): void {
18
50
  value.value = next;
19
51
  if (import.meta.client) {
20
- window.localStorage.setItem(resolvedStorageKey.value, next);
21
- window.dispatchEvent(new CustomEvent("site:consent", { detail: { value: next } }));
52
+ try {
53
+ window.localStorage.setItem(resolvedStorageKey.value, next);
54
+ } catch {
55
+ // 浏览器拒绝持久化时,本次会话的明确选择仍然生效。
56
+ }
57
+ if (next === "accepted") {
58
+ analyticsConsent.grant();
59
+ } else {
60
+ analyticsConsent.deny();
61
+ }
22
62
  }
23
63
  }
24
64
 
@@ -12,8 +12,11 @@ export default defineNuxtPlugin((nuxtApp) => {
12
12
  identity
13
13
  .promptOneTap((response) => {
14
14
  window.dispatchEvent(new CustomEvent("site:google-credential", { detail: response }));
15
- analytics.track("identity_google_credential", {
16
- select_by: response.select_by,
15
+ void analytics.track({
16
+ name: "identity_google_credential",
17
+ ...(response.select_by === undefined
18
+ ? {}
19
+ : { properties: { select_by: response.select_by } }),
17
20
  });
18
21
  })
19
22
  .catch((error) => {
@@ -241,10 +241,6 @@
241
241
  "path": "site-source/app/components/ui/YoutubeEmbed.vue",
242
242
  "target": "app/components/ui/YoutubeEmbed.vue"
243
243
  },
244
- {
245
- "path": "site-source/app/composables/useAnalytics.ts",
246
- "target": "app/composables/useAnalytics.ts"
247
- },
248
244
  {
249
245
  "path": "site-source/app/composables/useCheckout.ts",
250
246
  "target": "app/composables/useCheckout.ts"
@@ -281,10 +277,6 @@
281
277
  "path": "site-source/app/layouts/default.vue",
282
278
  "target": "app/layouts/default.vue"
283
279
  },
284
- {
285
- "path": "site-source/app/plugins/analytics.client.ts",
286
- "target": "app/plugins/analytics.client.ts"
287
- },
288
280
  {
289
281
  "path": "site-source/app/plugins/google-identity.client.ts",
290
282
  "target": "app/plugins/google-identity.client.ts"
@@ -5,11 +5,23 @@
5
5
  "type": "npm",
6
6
  "version": "^0.0.2"
7
7
  },
8
+ "@stackonward/analytics-core": {
9
+ "name": "@stackonward/analytics-core",
10
+ "description": "Provider-neutral analytics pipeline and browser autocapture",
11
+ "type": "npm",
12
+ "version": "^0.2.0"
13
+ },
14
+ "@stackonward/analytics-nuxt": {
15
+ "name": "@stackonward/analytics-nuxt",
16
+ "description": "Provider-neutral Nuxt analytics lifecycle",
17
+ "type": "npm",
18
+ "version": "^0.1.1"
19
+ },
8
20
  "@stackonward/cli": {
9
21
  "name": "@stackonward/cli",
10
22
  "description": "StackOnward project and resource CLI",
11
23
  "type": "npm",
12
- "version": "^1.0.1"
24
+ "version": "^1.2.0"
13
25
  },
14
26
  "@stackonward/cms-client": {
15
27
  "name": "@stackonward/cms-client",
@@ -53,6 +65,18 @@
53
65
  "type": "npm",
54
66
  "version": "^0.0.1"
55
67
  },
68
+ "@stackonward/google-tag-manager-nuxt": {
69
+ "name": "@stackonward/google-tag-manager-nuxt",
70
+ "description": "Consent-aware Google Tag Manager analytics integration for Nuxt",
71
+ "type": "npm",
72
+ "version": "^0.2.0"
73
+ },
74
+ "@stackonward/google-tag-manager": {
75
+ "name": "@stackonward/google-tag-manager",
76
+ "description": "Google Tag Manager transport and declarative container generator",
77
+ "type": "npm",
78
+ "version": "^0.1.0"
79
+ },
56
80
  "@stackonward/identity-browser": {
57
81
  "name": "@stackonward/identity-browser",
58
82
  "description": "Browser client for opaque identity sessions",
@@ -87,7 +111,7 @@
87
111
  "name": "@stackonward/nuxt-site-shell",
88
112
  "description": "Brand-free Nuxt product-site shell",
89
113
  "type": "layer",
90
- "version": "^0.0.3"
114
+ "version": "^0.1.0"
91
115
  },
92
116
  "@stackonward/onex-cms-client": {
93
117
  "name": "@stackonward/onex-cms-client",
@@ -30,18 +30,18 @@ ESLint composes the Nuxt-generated flat configuration with `@stackonward/eslint-
30
30
 
31
31
  `nuxt.config.ts` owns public runtime mapping; `.env.example` documents available values. Public means readable by browser users.
32
32
 
33
- | Variable family | Consumer and meaning |
34
- | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
35
- | `NUXT_PUBLIC_API_BASE_URL`, `NUXT_PUBLIC_API_TIMEOUT` | Public API URL and timeout configuration |
36
- | `NUXT_PUBLIC_SITE_URL`, `SITE_ENV`, `SITE_NAME`, `SITE_DESCRIPTION` | Canonical site identity and environment under the same prefix |
37
- | `NUXT_PUBLIC_ANALYTICS_ENABLED`, `ANALYTICS_DEBUG` | Analytics enable/debug configuration |
38
- | `NUXT_PUBLIC_ANALYTICS_REQUIRE_CONSENT`, `ANALYTICS_PAGE_TRACKING` | Consent requirement and route tracking |
39
- | `NUXT_PUBLIC_ANALYTICS_DATA_LAYER`, `GA_ID`, `GTM_ID` | Public analytics integration IDs under the same prefix |
40
- | `NUXT_PUBLIC_GOOGLE_IDENTITY_ENABLED`, `GOOGLE_CLIENT_ID` | Google client-side identity configuration |
41
- | `NUXT_PUBLIC_GOOGLE_CREDENTIAL_ENDPOINT`, `GOOGLE_ONE_TAP`, `GOOGLE_AUTO_PROMPT` | Configured credential exchange endpoint and prompting options |
42
- | `NUXT_PUBLIC_CHECKOUT_URL`, `CHECKOUT_RESULT_PARAM` | Checkout destination and return parameter |
43
- | `NUXT_PUBLIC_YOUTUBE_PRIVACY_ENHANCED` | Video privacy configuration |
44
- | `NUXT_PUBLIC_OG_IMAGE_ENABLED` | SEO layer option; verify the installed layer consumer before changing behavior |
33
+ | Variable family | Consumer and meaning |
34
+ | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
35
+ | `NUXT_PUBLIC_API_BASE_URL`, `NUXT_PUBLIC_API_TIMEOUT` | Public API URL and timeout configuration |
36
+ | `NUXT_PUBLIC_SITE_URL`, `SITE_ENV`, `SITE_NAME`, `SITE_DESCRIPTION` | Canonical site identity and environment under the same prefix |
37
+ | `NUXT_PUBLIC_ANALYTICS_DEBUG`, `NUXT_PUBLIC_ANALYTICS_REQUIRE_CONSENT` | Analytics diagnostics and consent policy |
38
+ | `NUXT_PUBLIC_ANALYTICS_PAGE_TRACKING`, `NUXT_PUBLIC_ANALYTICS_DATA_LAYER` | SPA page capture and GTM data-layer configuration |
39
+ | `NUXT_PUBLIC_GTM_ID` | Public Google Tag Manager container ID; an empty value disables the integration |
40
+ | `NUXT_PUBLIC_GOOGLE_IDENTITY_ENABLED`, `GOOGLE_CLIENT_ID` | Google client-side identity configuration |
41
+ | `NUXT_PUBLIC_GOOGLE_CREDENTIAL_ENDPOINT`, `GOOGLE_ONE_TAP`, `GOOGLE_AUTO_PROMPT` | Configured credential exchange endpoint and prompting options |
42
+ | `NUXT_PUBLIC_CHECKOUT_URL`, `CHECKOUT_RESULT_PARAM` | Checkout destination and return parameter |
43
+ | `NUXT_PUBLIC_YOUTUBE_PRIVACY_ENHANCED` | Video privacy configuration |
44
+ | `NUXT_PUBLIC_OG_IMAGE_ENABLED` | SEO layer option; verify the installed layer consumer before changing behavior |
45
45
 
46
46
  Never place OAuth secrets, service credentials or provider API keys here. An enabled client feature does not prove backend credentials or permission are configured. Validate numeric input at its actual consumer rather than relying on `Number.parseInt` alone.
47
47
 
@@ -4,12 +4,10 @@ NUXT_PUBLIC_SITE_URL=
4
4
  NUXT_PUBLIC_SITE_ENV=development
5
5
  NUXT_PUBLIC_SITE_NAME="{{projectName}}"
6
6
  NUXT_PUBLIC_SITE_DESCRIPTION="Reusable Nuxt product-site foundation."
7
- NUXT_PUBLIC_ANALYTICS_ENABLED=false
8
7
  NUXT_PUBLIC_ANALYTICS_DEBUG=false
9
8
  NUXT_PUBLIC_ANALYTICS_REQUIRE_CONSENT=true
10
9
  NUXT_PUBLIC_ANALYTICS_PAGE_TRACKING=true
11
10
  NUXT_PUBLIC_ANALYTICS_DATA_LAYER=dataLayer
12
- NUXT_PUBLIC_GA_ID=
13
11
  NUXT_PUBLIC_GTM_ID=
14
12
  NUXT_PUBLIC_GOOGLE_IDENTITY_ENABLED=false
15
13
  NUXT_PUBLIC_GOOGLE_CLIENT_ID=
File without changes
File without changes
@@ -5,12 +5,31 @@ This template extends `@stackonward/nuxt-site-shell` for Nuxt module wiring and
5
5
  - Nuxt UI and Tailwind theme wiring.
6
6
  - Local source files for marketing, app, content and static page shells under `app/layouts` and `app/components`.
7
7
  - Editable base components such as `BaseButton`, `BaseModal`, `BaseText`, `BasePopover`, `GoogleSignInButton`, `YoutubeEmbed`, `InfiniteScroll`, `CheckoutSummary` and `CheckoutStatus`.
8
- - Editable composables and plugins for Google Identity, GA/GTM analytics, consent-gated tracking, privacy-enhanced YouTube embeds and provider-free checkout redirects.
8
+ - Editable composables and plugins for Google Identity, consent presentation, privacy-enhanced YouTube embeds and provider-free checkout redirects.
9
+ - Versioned global analytics through `@stackonward/google-tag-manager-nuxt`, including consent gating, SPA page views, element/link autocapture, manual events and product-configurable capture rules.
9
10
  - Local Nuxt UI theme overrides under `app/theme` and CSS tokens under `app/assets/css`.
10
11
  - A generated `/ui` page showing how to use the included theme and components.
11
12
 
12
13
  Product-specific copy, assets, auth, analytics, CMS providers and business workflows belong in the generated application. Source is copied into the project so product teams can edit it directly.
13
14
 
15
+ ## Analytics
16
+
17
+ Set `NUXT_PUBLIC_GTM_ID` to a published GTM container such as `GTM-ABC123`. The integration remains disabled when the ID is empty. The included consent UI restores the product-owned local decision and starts analytics only after acceptance.
18
+
19
+ Global capture defaults and product overrides live under `googleTagManager.analytics` in `nuxt.config.ts`. Products can configure selectors, internal domains, tracked link types, module naming/rules, event names, device breakpoints and privacy-sensitive field capture. Business event names remain typed in product source.
20
+
21
+ Create a product-owned `analytics.google.json` with the GA4 measurement ID and any product event parameters, then generate the GTM import artifact without granting account credentials to the application:
22
+
23
+ ```bash
24
+ pnpm dlx @stackonward/cli analytics schema --provider google-tag-manager
25
+ pnpm dlx @stackonward/cli analytics generate \
26
+ --provider google-tag-manager \
27
+ --config analytics.google.json \
28
+ --out gtm.container.json
29
+ ```
30
+
31
+ Import the generated JSON into a selected GTM web-container workspace, preview the merge, and publish the reviewed version. The generated Google Tag disables automatic page views because the Nuxt integration owns SPA page-view dispatch.
32
+
14
33
  ## Engineering workflow
15
34
 
16
35
  ```bash
@@ -42,8 +42,9 @@ const visibleInfiniteItems = computed(() => infiniteItems.slice(0, visibleItems.
42
42
  const hasMoreInfiniteItems = computed(() => visibleItems.value < infiniteItems.length);
43
43
 
44
44
  function trackPreviewEvent() {
45
- analytics.track("creative_system_action", {
46
- surface: "design_system",
45
+ void analytics.track({
46
+ name: "creative_system_action",
47
+ properties: { surface: "design_system" },
47
48
  });
48
49
  }
49
50
 
@@ -1,8 +1,11 @@
1
1
  import { defineNuxtConfig } from "nuxt/config";
2
2
 
3
+ const siteEnv = process.env.NUXT_PUBLIC_SITE_ENV || "development";
4
+ const gtmId = process.env.NUXT_PUBLIC_GTM_ID || "";
5
+
3
6
  export default defineNuxtConfig({
4
7
  extends: ["@stackonward/nuxt-site-shell"],
5
- modules: ["@nuxt/eslint"],
8
+ modules: ["@nuxt/eslint", "@stackonward/google-tag-manager-nuxt"],
6
9
  compatibilityDate: "2025-07-15",
7
10
  css: ["~/assets/css/site-shell.css"],
8
11
  components: [
@@ -14,6 +17,23 @@ export default defineNuxtConfig({
14
17
  imports: {
15
18
  dirs: ["~/composables"],
16
19
  },
20
+ googleTagManager: {
21
+ id: gtmId,
22
+ dataLayerName: process.env.NUXT_PUBLIC_ANALYTICS_DATA_LAYER || "dataLayer",
23
+ analytics: {
24
+ applicationId: "{{projectName}}",
25
+ environment: siteEnv,
26
+ debug: process.env.NUXT_PUBLIC_ANALYTICS_DEBUG === "true",
27
+ consent: {
28
+ required: process.env.NUXT_PUBLIC_ANALYTICS_REQUIRE_CONSENT !== "false",
29
+ },
30
+ autoCapture: {
31
+ pageViews: process.env.NUXT_PUBLIC_ANALYTICS_PAGE_TRACKING !== "false",
32
+ modulePrefix: "",
33
+ moduleRules: [],
34
+ },
35
+ },
36
+ },
17
37
  icon: {
18
38
  customCollections: [
19
39
  {
@@ -34,20 +54,11 @@ export default defineNuxtConfig({
34
54
  },
35
55
  site: {
36
56
  url: process.env.NUXT_PUBLIC_SITE_URL || "",
37
- env: process.env.NUXT_PUBLIC_SITE_ENV || "development",
57
+ env: siteEnv,
38
58
  name: process.env.NUXT_PUBLIC_SITE_NAME || "{{projectName}}",
39
59
  description:
40
60
  process.env.NUXT_PUBLIC_SITE_DESCRIPTION || "Premium product media production workspace.",
41
61
  },
42
- analytics: {
43
- enabled: process.env.NUXT_PUBLIC_ANALYTICS_ENABLED === "true",
44
- debug: process.env.NUXT_PUBLIC_ANALYTICS_DEBUG === "true",
45
- requireConsent: process.env.NUXT_PUBLIC_ANALYTICS_REQUIRE_CONSENT !== "false",
46
- pageTracking: process.env.NUXT_PUBLIC_ANALYTICS_PAGE_TRACKING !== "false",
47
- dataLayerName: process.env.NUXT_PUBLIC_ANALYTICS_DATA_LAYER || "dataLayer",
48
- gaId: process.env.NUXT_PUBLIC_GA_ID || "",
49
- gtmId: process.env.NUXT_PUBLIC_GTM_ID || "",
50
- },
51
62
  identity: {
52
63
  google: {
53
64
  enabled: process.env.NUXT_PUBLIC_GOOGLE_IDENTITY_ENABLED === "true",
@@ -29,8 +29,9 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@stackonward/api-client": "^0.0.2",
32
+ "@stackonward/google-tag-manager-nuxt": "^0.2.0",
32
33
  "@stackonward/nuxt-layer-base": "^0.0.3",
33
- "@stackonward/nuxt-site-shell": "^0.0.3"
34
+ "@stackonward/nuxt-site-shell": "^0.1.0"
34
35
  },
35
36
  "devDependencies": {
36
37
  "@commitlint/cli": "^21.2.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackonward/cli",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "StackOnward CLI for creating projects and installing reusable components and snippets",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -10,6 +10,15 @@
10
10
  "files": [
11
11
  "dist"
12
12
  ],
13
+ "scripts": {
14
+ "dev": "tsup --watch",
15
+ "build": "tsup",
16
+ "prepack": "pnpm build",
17
+ "clean": "rm -rf dist",
18
+ "typecheck": "tsc --noEmit",
19
+ "test": "vitest run",
20
+ "check:package": "node scripts/check-package.mjs"
21
+ },
13
22
  "keywords": [
14
23
  "cli",
15
24
  "stackonward",
@@ -28,6 +37,8 @@
28
37
  "directory": "packages/cli"
29
38
  },
30
39
  "dependencies": {
40
+ "@stackonward/api-client": "workspace:*",
41
+ "@stackonward/google-tag-manager": "workspace:*",
31
42
  "commander": "^12.0.0",
32
43
  "execa": "^9.0.0",
33
44
  "fs-extra": "^11.2.0",
@@ -35,8 +46,7 @@
35
46
  "kolorist": "^1.8.0",
36
47
  "ora": "^8.0.0",
37
48
  "prompts": "^2.4.2",
38
- "zod": "4",
39
- "@stackonward/api-client": "0.0.2"
49
+ "zod": "4"
40
50
  },
41
51
  "devDependencies": {
42
52
  "@types/fs-extra": "^11.0.4",
@@ -47,13 +57,5 @@
47
57
  },
48
58
  "engines": {
49
59
  "node": ">=20.0.0"
50
- },
51
- "scripts": {
52
- "dev": "tsup --watch",
53
- "build": "tsup",
54
- "clean": "rm -rf dist",
55
- "typecheck": "tsc --noEmit",
56
- "test": "vitest run",
57
- "check:package": "node scripts/check-package.mjs"
58
60
  }
59
- }
61
+ }
@@ -1,24 +0,0 @@
1
- export interface AnalyticsEvent {
2
- name: string;
3
- properties?: Record<string, unknown>;
4
- }
5
-
6
- export function useAnalytics() {
7
- function track(event: AnalyticsEvent | string, properties?: Record<string, unknown>): void {
8
- if (!import.meta.client) return;
9
- const detail = typeof event === "string" ? { name: event, properties } : event;
10
- window.dispatchEvent(new CustomEvent("site:analytics", { detail }));
11
- }
12
-
13
- function pageView(path: string, title?: string): void {
14
- track({
15
- name: "page_view",
16
- properties: {
17
- page_path: path,
18
- page_title: title,
19
- },
20
- });
21
- }
22
-
23
- return { track, pageView };
24
- }
@@ -1,121 +0,0 @@
1
- type DataLayerEvent = Record<string, unknown> & {
2
- event?: string;
3
- };
4
-
5
- declare global {
6
- interface Window {
7
- dataLayer?: DataLayerEvent[];
8
- gtag?: (...args: unknown[]) => void;
9
- __siteAnalyticsInstalled?: boolean;
10
- }
11
- }
12
-
13
- function appendScript(id: string, src: string): void {
14
- if (document.getElementById(id)) {
15
- return;
16
- }
17
-
18
- const script = document.createElement("script");
19
- script.id = id;
20
- script.async = true;
21
- script.src = src;
22
- document.head.appendChild(script);
23
- }
24
-
25
- export default defineNuxtPlugin((nuxtApp) => {
26
- const config = useRuntimeConfig();
27
- const site = useSiteShell();
28
- const analytics = config.public.analytics;
29
-
30
- if (!analytics?.enabled || window.__siteAnalyticsInstalled) {
31
- return;
32
- }
33
-
34
- const dataLayerName = analytics.dataLayerName || "dataLayer";
35
- const win = window as unknown as Window & Record<string, unknown>;
36
-
37
- function dataLayer(): DataLayerEvent[] {
38
- const existing = win[dataLayerName];
39
- if (Array.isArray(existing)) {
40
- return existing as DataLayerEvent[];
41
- }
42
-
43
- const next: DataLayerEvent[] = [];
44
- win[dataLayerName] = next;
45
- if (dataLayerName === "dataLayer") {
46
- window.dataLayer = next;
47
- }
48
- return next;
49
- }
50
-
51
- function push(event: DataLayerEvent): void {
52
- dataLayer().push(event);
53
- if (analytics.debug) {
54
- window.dispatchEvent(new CustomEvent("site:analytics-debug", { detail: event }));
55
- }
56
- }
57
-
58
- function hasConsent(): boolean {
59
- if (!analytics.requireConsent) {
60
- return true;
61
- }
62
- return window.localStorage.getItem(site.value.consent.storageKey) === "accepted";
63
- }
64
-
65
- function install(): void {
66
- if (window.__siteAnalyticsInstalled || !hasConsent()) {
67
- return;
68
- }
69
-
70
- window.__siteAnalyticsInstalled = true;
71
-
72
- if (analytics.gtmId) {
73
- push({ "gtm.start": Date.now(), event: "gtm.js" });
74
- appendScript(
75
- "site-gtm",
76
- `https://www.googletagmanager.com/gtm.js?id=${encodeURIComponent(analytics.gtmId)}`,
77
- );
78
- }
79
-
80
- if (analytics.gaId) {
81
- window.gtag = (...args: unknown[]) => {
82
- dataLayer().push(args as unknown as DataLayerEvent);
83
- };
84
- appendScript(
85
- "site-gtag",
86
- `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(analytics.gaId)}`,
87
- );
88
- window.gtag("js", new Date());
89
- window.gtag("config", analytics.gaId, { send_page_view: false });
90
- }
91
- }
92
-
93
- window.addEventListener("site:consent", (event) => {
94
- const detail = (event as CustomEvent<{ value?: string }>).detail;
95
- if (detail?.value === "accepted") {
96
- install();
97
- }
98
- });
99
-
100
- window.addEventListener("site:analytics", (event) => {
101
- const detail = (event as CustomEvent<{ name?: string; properties?: Record<string, unknown> }>)
102
- .detail;
103
- if (!detail?.name) {
104
- return;
105
- }
106
- push({ event: detail.name, ...(detail.properties || {}) });
107
- });
108
-
109
- if (analytics.pageTracking) {
110
- const router = useRouter();
111
- router.afterEach((to) => {
112
- push({
113
- event: "page_view",
114
- page_path: to.fullPath,
115
- page_title: document.title,
116
- });
117
- });
118
- }
119
-
120
- nuxtApp.hook("app:mounted", install);
121
- });