diff --git a/README.md b/README.md index d83c1c0..f80033c 100644 --- a/README.md +++ b/README.md @@ -53,26 +53,122 @@ That's it! You can now use Nuxt UTM in your Nuxt app ✨ ## Usage -You can use `useNuxtUTM` composable to access the UTM object: +### Configuration + +You can configure the module by passing options in your `nuxt.config.ts`: + +```js +export default defineNuxtConfig({ + modules: ['nuxt-utm'], + utm: { + trackingEnabled: true, // defaults to true - initial tracking state + }, +}) +``` + +#### Options + +- `trackingEnabled`: Boolean (default: `true`) - Sets the initial state for UTM tracking. This can be changed at runtime. + +### Runtime Tracking Control + +The module provides runtime control over tracking, perfect for implementing cookie consent banners or user privacy preferences. + +#### Using the Composable ```vue ``` -> Remember: You don't need to import the composable because nuxt imports it automatically. +#### Example: Cookie Banner Integration + +```vue + -Alternatively, you can get the UTM information through the Nuxt App with the following instructions: + +``` + +#### Privacy Controls + +```vue + + + +``` + +### Accessing UTM Data + +You can use `useNuxtUTM` composable to access the UTM data: ```vue ``` -Regardless of the option you choose to use the module, the `utm' object will contain an array of UTM parameters collected for use. Each element in the array represents a set of UTM parameters collected from a URL visit, and is structured as follows +> Remember: You don't need to import the composable because Nuxt imports it automatically. + +### Data Structure + +The `data` property contains an array of UTM parameters collected. Each element in the array represents a set of UTM parameters collected from a URL visit, and is structured as follows ```json [ @@ -104,7 +200,15 @@ Regardless of the option you choose to use the module, the `utm' object will con ] ``` -In the `$utm` array, each entry provides a `timestamp` indicating when the UTM parameters were collected, the `utmParams` object containing the UTM parameters, `additionalInfo` object with more context about the visit, and a `sessionId` to differentiate visits in different sessions. +Each entry provides a `timestamp` indicating when the UTM parameters were collected, the `utmParams` object containing the UTM parameters, `additionalInfo` object with more context about the visit, and a `sessionId` to differentiate visits in different sessions. + +### Key Features + +- **Runtime Control**: Enable/disable tracking dynamically based on user consent +- **Privacy Friendly**: Respects user preferences and provides clear data management +- **Persistent Preferences**: Tracking preferences are saved and persist across sessions +- **Data Clearing**: Ability to completely remove all collected data +- **Session Management**: Automatically manages sessions to avoid duplicate tracking ## Development diff --git a/playground/app.vue b/playground/app.vue index c392b10..d4865da 100644 --- a/playground/app.vue +++ b/playground/app.vue @@ -1,6 +1,50 @@ + + diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts index 447c9a8..bf1026b 100644 --- a/playground/nuxt.config.ts +++ b/playground/nuxt.config.ts @@ -1,5 +1,12 @@ export default defineNuxtConfig({ modules: ['../src/module'], devtools: { enabled: true }, - utm: {}, + app: { + head: { + title: 'Nuxt UTM Playground', + }, + }, + utm: { + trackingEnabled: true, + }, }) diff --git a/src/module.ts b/src/module.ts index 1895e85..2264bda 100644 --- a/src/module.ts +++ b/src/module.ts @@ -1,9 +1,8 @@ import { defineNuxtModule, addPlugin, addImports, createResolver } from '@nuxt/kit' -// Module options TypeScript interface definition -/* eslint-disable @typescript-eslint/no-empty-object-type */ -export interface ModuleOptions {} -/* eslint-enable @typescript-eslint/no-empty-object-type */ +export interface ModuleOptions { + trackingEnabled?: boolean +} export default defineNuxtModule({ meta: { @@ -13,12 +12,16 @@ export default defineNuxtModule({ nuxt: '^3.0.0 || ^4.0.0', }, }, - // Default configuration options of the Nuxt module - defaults: {}, - setup() { + defaults: { + trackingEnabled: true, + }, + setup(options, nuxt) { const resolver = createResolver(import.meta.url) - // Do not add the extension since the `.ts` will be transpiled to `.mjs` after `npm run prepack` + nuxt.options.runtimeConfig.public.utm = { + trackingEnabled: options.trackingEnabled ?? true, + } + addPlugin(resolver.resolve('./runtime/plugin')) addImports({ name: 'useNuxtUTM', diff --git a/src/runtime/composables.ts b/src/runtime/composables.ts index 2dc52f1..c1838ac 100644 --- a/src/runtime/composables.ts +++ b/src/runtime/composables.ts @@ -1,6 +1,23 @@ +import type { Ref } from 'vue' +import type { DataObject } from 'nuxt-utm' import { useNuxtApp } from '#imports' -export const useNuxtUTM = () => { +export interface UseNuxtUTMReturn { + data: Readonly> + trackingEnabled: Readonly> + enableTracking: () => void + disableTracking: () => void + clearData: () => void +} + +export const useNuxtUTM = (): UseNuxtUTMReturn => { const nuxtApp = useNuxtApp() - return nuxtApp.$utm + + return { + data: nuxtApp.$utm, + trackingEnabled: nuxtApp.$utmTrackingEnabled, + enableTracking: nuxtApp.$utmEnableTracking, + disableTracking: nuxtApp.$utmDisableTracking, + clearData: nuxtApp.$utmClearData, + } } diff --git a/src/runtime/plugin.ts b/src/runtime/plugin.ts index bb38c44..59af109 100644 --- a/src/runtime/plugin.ts +++ b/src/runtime/plugin.ts @@ -1,5 +1,5 @@ import type { DataObject } from 'nuxt-utm' -import { ref } from 'vue' +import { ref, readonly } from 'vue' import { readLocalData, getSessionID, @@ -9,17 +9,32 @@ import { urlHasGCLID, getGCLID, } from './utm' -import { defineNuxtPlugin } from '#app' -// import { type HookResult } from "@unhead/schema"; +import { defineNuxtPlugin, useRuntimeConfig } from '#app' const LOCAL_STORAGE_KEY = 'nuxt-utm-data' const SESSION_ID_KEY = 'nuxt-utm-session-id' +const TRACKING_ENABLED_KEY = 'nuxt-utm-tracking-enabled' export default defineNuxtPlugin((nuxtApp) => { + const config = useRuntimeConfig() const data = ref([]) + // Initialize tracking enabled state from config or localStorage + const getInitialTrackingState = (): boolean => { + if (typeof window === 'undefined') return config.public.utm?.trackingEnabled ?? true + + const storedState = localStorage.getItem(TRACKING_ENABLED_KEY) + if (storedState !== null) { + return storedState === 'true' + } + return config.public.utm?.trackingEnabled ?? true + } + + const trackingEnabled = ref(getInitialTrackingState()) + const processUtmData = () => { if (typeof window === 'undefined') return + if (!trackingEnabled.value) return data.value = readLocalData(LOCAL_STORAGE_KEY) @@ -49,11 +64,44 @@ export default defineNuxtPlugin((nuxtApp) => { localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(data.value)) } + const enableTracking = () => { + trackingEnabled.value = true + if (typeof window !== 'undefined') { + localStorage.setItem(TRACKING_ENABLED_KEY, 'true') + // Process current page data when enabling + processUtmData() + } + } + + const disableTracking = () => { + trackingEnabled.value = false + if (typeof window !== 'undefined') { + localStorage.setItem(TRACKING_ENABLED_KEY, 'false') + } + } + + const clearTrackingData = () => { + data.value = [] + if (typeof window !== 'undefined') { + localStorage.removeItem(LOCAL_STORAGE_KEY) + sessionStorage.removeItem(SESSION_ID_KEY) + } + } + + // Load existing data on initialization + if (typeof window !== 'undefined') { + data.value = readLocalData(LOCAL_STORAGE_KEY) + } + nuxtApp.hook('app:mounted', processUtmData) return { provide: { - utm: data, + utm: readonly(data), + utmTrackingEnabled: readonly(trackingEnabled), + utmEnableTracking: enableTracking, + utmDisableTracking: disableTracking, + utmClearData: clearTrackingData, }, } }) diff --git a/test/fixtures/basic/app.vue b/test/fixtures/basic/app.vue index 55a6bc2..cc11a3a 100644 --- a/test/fixtures/basic/app.vue +++ b/test/fixtures/basic/app.vue @@ -1,12 +1,13 @@ diff --git a/test/fixtures/basic/plugins/test-helper.client.ts b/test/fixtures/basic/plugins/test-helper.client.ts new file mode 100644 index 0000000..08e5dbf --- /dev/null +++ b/test/fixtures/basic/plugins/test-helper.client.ts @@ -0,0 +1,14 @@ +import { defineNuxtPlugin } from '#app' +import { useNuxtUTM } from '#imports' + +declare global { + interface Window { + useNuxtUTM: typeof useNuxtUTM + } +} + +export default defineNuxtPlugin(() => { + if (typeof window !== 'undefined') { + window.useNuxtUTM = useNuxtUTM + } +}) diff --git a/test/fixtures/disabled/app.vue b/test/fixtures/disabled/app.vue new file mode 100644 index 0000000..cc11a3a --- /dev/null +++ b/test/fixtures/disabled/app.vue @@ -0,0 +1,13 @@ + + + diff --git a/test/fixtures/disabled/nuxt.config.ts b/test/fixtures/disabled/nuxt.config.ts new file mode 100644 index 0000000..99361f7 --- /dev/null +++ b/test/fixtures/disabled/nuxt.config.ts @@ -0,0 +1,8 @@ +import UtmModule from '../../../src/module' + +export default defineNuxtConfig({ + modules: [UtmModule], + utm: { + trackingEnabled: false, + }, +}) diff --git a/test/fixtures/disabled/plugins/test-helper.client.ts b/test/fixtures/disabled/plugins/test-helper.client.ts new file mode 100644 index 0000000..08e5dbf --- /dev/null +++ b/test/fixtures/disabled/plugins/test-helper.client.ts @@ -0,0 +1,14 @@ +import { defineNuxtPlugin } from '#app' +import { useNuxtUTM } from '#imports' + +declare global { + interface Window { + useNuxtUTM: typeof useNuxtUTM + } +} + +export default defineNuxtPlugin(() => { + if (typeof window !== 'undefined') { + window.useNuxtUTM = useNuxtUTM + } +}) diff --git a/test/integration-disabled.test.ts b/test/integration-disabled.test.ts new file mode 100644 index 0000000..66e38b3 --- /dev/null +++ b/test/integration-disabled.test.ts @@ -0,0 +1,31 @@ +import { fileURLToPath } from 'node:url' +import { describe, it, expect } from 'vitest' +import { setup, $fetch, createPage } from '@nuxt/test-utils' + +describe('Module when tracking disabled by default', async () => { + await setup({ + rootDir: fileURLToPath(new URL('./fixtures/disabled', import.meta.url)), + server: true, + browser: true, + }) + + it('should not track UTM parameters when disabled', async () => { + const page = await createPage('/?utm_source=test_source&utm_medium=test_medium') + await page.waitForTimeout(500) + const rawData = await page.evaluate(() => window.localStorage.getItem('nuxt-utm-data')) + expect(rawData).toBeNull() + await page.close() + }) + + it('should show tracking is disabled', async () => { + const page = await createPage('/') + const trackingStatus = await page.textContent('p') + expect(trackingStatus).toContain('Tracking: Disabled') + await page.close() + }) + + it('should still render the page', async () => { + const html = await $fetch('/') + expect(html).toContain('

UTM Tracker

') + }) +}) diff --git a/test/integration.test.ts b/test/integration-enabled.test.ts similarity index 97% rename from test/integration.test.ts rename to test/integration-enabled.test.ts index 20d406d..21ff3b3 100644 --- a/test/integration.test.ts +++ b/test/integration-enabled.test.ts @@ -4,9 +4,10 @@ import { describe, it, expect, beforeEach } from 'vitest' import { setup, $fetch, createPage } from '@nuxt/test-utils' import type { Page } from 'playwright-core' -describe('ssr', async () => { +describe('Module when enabled', async () => { await setup({ rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)), + server: true, browser: true, }) @@ -17,6 +18,7 @@ describe('ssr', async () => { page = await createPage( '/?utm_source=test_source&utm_medium=test_medium&utm_campaign=test_campaign&utm_term=test_term&utm_content=test_content&gad_source=1&gclid=testKey', ) + await page.waitForTimeout(500) // Wait for data to be processed const rawData = await page.evaluate(() => window.localStorage.getItem('nuxt-utm-data')) entries = await JSON.parse(rawData ?? '[]') }) diff --git a/test/integration-runtime-control.test.ts b/test/integration-runtime-control.test.ts new file mode 100644 index 0000000..bdad6b7 --- /dev/null +++ b/test/integration-runtime-control.test.ts @@ -0,0 +1,167 @@ +import { fileURLToPath } from 'node:url' +import { describe, it, expect } from 'vitest' +import { setup, createPage } from '@nuxt/test-utils' +import type { Page } from 'playwright-core' +import type { DataObject } from 'nuxt-utm' +import type { UseNuxtUTMReturn } from '../src/runtime/composables' + +declare global { + interface Window { + useNuxtUTM: () => UseNuxtUTMReturn + } +} + +describe('Runtime tracking control', async () => { + await setup({ + rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)), + server: true, + browser: true, + }) + + let page: Page + + it('should allow disabling tracking at runtime', async () => { + page = await createPage('/?utm_source=test1&utm_medium=test1') + await page.waitForTimeout(500) + + // Check initial data is collected + let rawData = await page.evaluate(() => window.localStorage.getItem('nuxt-utm-data')) + let entries = JSON.parse(rawData ?? '[]') + expect(entries.length).toBeGreaterThan(0) + + // Disable tracking + await page.evaluate(() => { + const win = window as typeof window & { useNuxtUTM?: () => UseNuxtUTMReturn } + if (win.useNuxtUTM) { + const utm = win.useNuxtUTM() + utm.disableTracking() + } + }) + + // Navigate with new UTM params + await page.evaluate(() => { + window.location.href = '/?utm_source=test2&utm_medium=test2' + }) + await page.waitForTimeout(1000) + + // Check no new data was collected + rawData = await page.evaluate(() => window.localStorage.getItem('nuxt-utm-data')) + entries = JSON.parse(rawData ?? '[]') + const hasTest2 = entries.some((e: DataObject) => e.utmParams?.utm_source === 'test2') + expect(hasTest2).toBe(false) + + await page.close() + }) + + it('should allow enabling tracking at runtime', async () => { + page = await createPage('/') + + // First disable tracking + await page.evaluate(() => { + const win = window as typeof window & { useNuxtUTM?: () => UseNuxtUTMReturn } + if (win.useNuxtUTM) { + const utm = win.useNuxtUTM() + utm.disableTracking() + } + }) + + // Navigate with UTM params while disabled + await page.evaluate(() => { + window.location.href = '/?utm_source=while_disabled&utm_medium=test' + }) + await page.waitForTimeout(1000) + + let rawData = await page.evaluate(() => window.localStorage.getItem('nuxt-utm-data')) + let entries = JSON.parse(rawData ?? '[]') + const hasDisabled = entries.some((e: DataObject) => e.utmParams?.utm_source === 'while_disabled') + expect(hasDisabled).toBe(false) + + // Enable tracking + await page.evaluate(() => { + const win = window as typeof window & { useNuxtUTM?: () => UseNuxtUTMReturn } + if (win.useNuxtUTM) { + const utm = win.useNuxtUTM() + utm.enableTracking() + } + }) + + await page.waitForTimeout(500) + + // Check if current page data was collected when enabled + rawData = await page.evaluate(() => window.localStorage.getItem('nuxt-utm-data')) + entries = JSON.parse(rawData ?? '[]') + const hasEnabled = entries.some((e: DataObject) => e.utmParams?.utm_source === 'while_disabled') + expect(hasEnabled).toBe(true) + + await page.close() + }) + + it('should clear all tracking data', async () => { + page = await createPage('/?utm_source=test&utm_medium=test') + await page.waitForTimeout(500) + + // Check data exists + let rawData = await page.evaluate(() => window.localStorage.getItem('nuxt-utm-data')) + const entries = JSON.parse(rawData ?? '[]') + expect(entries.length).toBeGreaterThan(0) + + // Clear data + await page.evaluate(() => { + const win = window as typeof window & { useNuxtUTM?: () => UseNuxtUTMReturn } + if (win.useNuxtUTM) { + const utm = win.useNuxtUTM() + utm.clearData() + } + }) + + // Check data is cleared + rawData = await page.evaluate(() => window.localStorage.getItem('nuxt-utm-data')) + expect(rawData).toBeNull() + + // Check session ID is also cleared + const sessionId = await page.evaluate(() => window.sessionStorage.getItem('nuxt-utm-session-id')) + expect(sessionId).toBeNull() + + await page.close() + }) + + it('should persist tracking preference across page reloads', async () => { + page = await createPage('/') + + // Disable tracking + await page.evaluate(() => { + const win = window as typeof window & { useNuxtUTM?: () => UseNuxtUTMReturn } + if (win.useNuxtUTM) { + const utm = win.useNuxtUTM() + utm.disableTracking() + } + }) + + // Reload page + await page.reload() + await page.waitForTimeout(500) + + // Check tracking is still disabled + const isDisabled = await page.evaluate(() => { + const win = window as typeof window & { useNuxtUTM?: () => UseNuxtUTMReturn } + if (win.useNuxtUTM) { + const utm = win.useNuxtUTM() + return !utm.trackingEnabled.value + } + return false + }) + + expect(isDisabled).toBe(true) + + // Clean up - re-enable for other tests + await page.evaluate(() => { + const win = window as typeof window & { useNuxtUTM?: () => UseNuxtUTMReturn } + if (win.useNuxtUTM) { + const utm = win.useNuxtUTM() + utm.enableTracking() + } + }) + + await page.close() + }) +})