diff --git a/.changeset/bright-owls-count.md b/.changeset/bright-owls-count.md new file mode 100644 index 0000000..697f203 --- /dev/null +++ b/.changeset/bright-owls-count.md @@ -0,0 +1,5 @@ +--- +'landing-page': minor +--- + +add GA internal traffic and debug switches, and site language and color scheme user properties diff --git a/README.md b/README.md index 69a8f02..5f3598d 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ - [About](#about) - [What's inside](#whats-inside) +- [Analytics](#analytics) - [Architecture](#architecture) - [Running locally](#running-locally) - [Quality gates](#quality-gates) @@ -45,6 +46,17 @@ The site is also a working example of the rest of the delivery chain: a static b - **Accessible.** axe-core runs on every page, in both languages, on desktop and mobile. - **Documented components.** Storybook for the UI building blocks, published at [storybook.rafer.dev](https://storybook.rafer.dev). +## Analytics + +Only after a visitor accepts the banner, Google Analytics 4 receives page views, `section_view`, `select_content`, `contact_click`, `social_click`, `nav_click`, `language_change` and `theme_change`, plus the user properties `site_language` and `color_scheme`. + +Two switches for the site owner, set by visiting a URL once and stripped from the address bar right away: + +| URL | Effect | +| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `/?internal=1` | Tags this browser as internal traffic (`traffic_type=internal`) so GA's Internal Traffic filter can exclude it. `/?internal=0` undoes it. | +| `/?ga_debug=1` | Sends this tab's visit to GA DebugView (`debug_mode`). It ends with the tab; `/?ga_debug=0` turns it off earlier. | + ## Architecture ```mermaid diff --git a/src/app/_layout/analytics.tsx b/src/app/_layout/analytics.tsx index 2d70187..a32bc15 100644 --- a/src/app/_layout/analytics.tsx +++ b/src/app/_layout/analytics.tsx @@ -2,7 +2,14 @@ import { useEffect } from 'react' -import { clearAnalyticsCookies, send, startAnalytics } from '@/lib/analytics' +import { DARK_QUERY, THEME_EVENT } from '@/config/theme' +import { + applyAnalyticsFlags, + clearAnalyticsCookies, + send, + startAnalytics, + updateUserProperties, +} from '@/lib/analytics' import { hasConsent, onConsentChange } from '@/lib/consent' const useConsentedTags = () => { @@ -15,6 +22,7 @@ const useConsentedTags = () => { startAnalytics() } + applyAnalyticsFlags() if (hasConsent()) start() return onConsentChange((value) => { @@ -87,8 +95,23 @@ const useSectionViews = () => { }, []) } +const useThemeProperty = () => { + useEffect(() => { + const media = window.matchMedia(DARK_QUERY) + + window.addEventListener(THEME_EVENT, updateUserProperties) + media.addEventListener('change', updateUserProperties) + + return () => { + window.removeEventListener(THEME_EVENT, updateUserProperties) + media.removeEventListener('change', updateUserProperties) + } + }, []) +} + const Analytics = () => { useConsentedTags() + useThemeProperty() useClickTracking() useSectionViews() diff --git a/src/config/privacy.ts b/src/config/privacy.ts index 4046841..7849844 100644 --- a/src/config/privacy.ts +++ b/src/config/privacy.ts @@ -32,7 +32,7 @@ const en: Policy = { title: 'Privacy policy', description: 'What data rafer.dev collects, why, and what you can do about it.', - updated: 'Last updated: September 23, 2026', + updated: 'Last updated: September 29, 2026', path: paths.en.privacy, intro: [ 'This policy explains what data rafer.dev collects, why, and what you can do about it.', @@ -51,6 +51,7 @@ const en: Policy = { 'links clicked, such as a project or a contact channel;', 'approximate location (country and city, derived from your IP address);', 'device type, operating system and browser;', + 'the language version you read and whether the site is shown in light or dark mode;', 'the site that referred you.', ], after: [ @@ -120,7 +121,7 @@ const pt: Policy = { title: 'Política de privacidade', description: 'Quais dados o rafer.dev coleta, por quê, e o que você pode fazer a respeito.', - updated: 'Última atualização: 23 de setembro de 2026', + updated: 'Última atualização: 29 de setembro de 2026', path: paths.pt.privacy, intro: [ 'Esta política explica quais dados o rafer.dev coleta, por quê, e o que você pode fazer a respeito.', @@ -139,6 +140,7 @@ const pt: Policy = { 'links clicados, como um projeto ou um canal de contato;', 'localização aproximada (país e cidade, a partir do seu endereço IP);', 'tipo de dispositivo, sistema operacional e navegador;', + 'a versão de idioma que você lê e se o site aparece no modo claro ou escuro;', 'o site que trouxe você até aqui.', ], after: [ diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index 474ec5b..da65e3e 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -19,6 +19,67 @@ type AnalyticsEvent = keyof AnalyticsEvents const isProduction = process.env.NODE_ENV === 'production' +const INTERNAL_KEY = 'rafer.internal' +const DEBUG_KEY = 'rafer.ga-debug' + +type Flag = { key: string; param: string; storage: () => Storage } + +const flags = { + internal: { + key: INTERNAL_KEY, + param: 'internal', + storage: () => window.localStorage, + }, + debug: { + key: DEBUG_KEY, + param: 'ga_debug', + storage: () => window.sessionStorage, + }, +} satisfies Record + +const readFlag = ({ key, storage }: Flag) => { + try { + return storage().getItem(key) === '1' + } catch { + return false + } +} + +const applyAnalyticsFlags = () => { + const url = new URL(window.location.href) + let changed = false + + Object.values(flags).forEach(({ key, param, storage }) => { + const value = url.searchParams.get(param) + if (value === null) return + + try { + if (value === '1') storage().setItem(key, '1') + if (value === '0') storage().removeItem(key) + } catch {} + + url.searchParams.delete(param) + changed = true + }) + + if (changed) window.history.replaceState(window.history.state, '', url) +} + +const userProperties = () => ({ + site_language: document.documentElement.lang, + color_scheme: document.documentElement.getAttribute('data-theme') ?? 'light', +}) + +const updateUserProperties = () => { + if (!hasConsent()) return + window.gtag?.('set', 'user_properties', userProperties()) +} + +const configParams = () => ({ + ...(readFlag(flags.internal) && { traffic_type: 'internal' }), + ...(readFlag(flags.debug) && { debug_mode: true }), +}) + const send = (event: string, params: Record) => { if (!hasConsent()) return window.gtag?.('event', event, params) @@ -59,8 +120,9 @@ const startAnalytics = () => { ad_personalization: 'denied', analytics_storage: 'granted', }) + window.gtag('set', 'user_properties', userProperties()) window.gtag('js', new Date()) - window.gtag('config', __env.NEXT_PUBLIC_GA_TAG_ID) + window.gtag('config', __env.NEXT_PUBLIC_GA_TAG_ID, configParams()) if (isProduction) loadScript(__env.NEXT_PUBLIC_GA_SRC) } @@ -82,5 +144,13 @@ const clearAnalyticsCookies = () => { }) } -export { clearAnalyticsCookies, send, startAnalytics, track, trackable } +export { + applyAnalyticsFlags, + clearAnalyticsCookies, + send, + startAnalytics, + track, + trackable, + updateUserProperties, +} export type { AnalyticsEvent, AnalyticsEvents } diff --git a/tests/analytics.spec.ts b/tests/analytics.spec.ts index acc832e..7bc6801 100644 --- a/tests/analytics.spec.ts +++ b/tests/analytics.spec.ts @@ -136,3 +136,135 @@ test.describe('Analytics - Consent and tracking', () => { await expect(page.getByRole('button', { name: 'Accept' })).toBeFocused() }) }) + +test.describe('Analytics - Insights', () => { + const config = async (page: Page) => + (await dataLayer(page)).find(([command]) => command === 'config') + + const userProperties = async (page: Page) => + (await dataLayer(page)) + .filter( + ([command, target]) => command === 'set' && target === 'user_properties' + ) + .map(([, , value]) => value) + + test.beforeEach(async ({ page, context }) => { + await context.route( + /googletagmanager\.com|google-analytics\.com/, + (route) => route.abort() + ) + await page.addInitScript( + (key) => localStorage.setItem(key, 'granted'), + CONSENT_KEY + ) + }) + + test('Expected - Language and color scheme sent as user properties', async ({ + page, + }) => { + await page.emulateMedia({ colorScheme: 'light' }) + await page.goto(HOME) + + await expect + .poll(() => userProperties(page)) + .toEqual([{ site_language: 'en', color_scheme: 'light' }]) + + const commands = (await dataLayer(page)).map(([command]) => command) + expect(commands.indexOf('set')).toBeLessThan(commands.indexOf('config')) + }) + + test('Expected - Portuguese in dark mode reported as such', async ({ + page, + }) => { + await page.emulateMedia({ colorScheme: 'dark' }) + await page.goto('/pt') + + await expect + .poll(() => userProperties(page)) + .toEqual([{ site_language: 'pt-BR', color_scheme: 'dark' }]) + }) + + test('Expected - Switching theme updates the color scheme', async ({ + page, + }) => { + await page.emulateMedia({ colorScheme: 'light' }) + await page.goto(HOME) + + await page + .getByRole('group', { name: 'Theme' }) + .getByRole('button', { name: 'Dark', exact: true }) + .click() + + await expect + .poll(async () => (await userProperties(page)).at(-1)) + .toEqual({ site_language: 'en', color_scheme: 'dark' }) + }) + + test('Expected - Regular visits carry no internal or debug flags', async ({ + page, + }) => { + await page.goto(HOME) + + await expect.poll(async () => (await config(page))?.[2]).toEqual({}) + }) + + test('Expected - ?internal=1 tags this browser until ?internal=0', async ({ + page, + }) => { + await page.goto('/?internal=1#work') + + await expect(page).toHaveURL(/\/#work$/) + await expect + .poll(async () => (await config(page))?.[2]) + .toEqual({ traffic_type: 'internal' }) + + await page.goto('/pt') + await expect + .poll(async () => (await config(page))?.[2]) + .toEqual({ traffic_type: 'internal' }) + + await page.goto('/?internal=0') + await expect(page).toHaveURL(/\/$/) + await expect.poll(async () => (await config(page))?.[2]).toEqual({}) + expect( + await page.evaluate(() => localStorage.getItem('rafer.internal')) + ).toBeNull() + }) + + test('Expected - ?ga_debug=1 enables DebugView for the tab only', async ({ + page, + context, + }) => { + await page.goto('/?ga_debug=1') + + await expect(page).toHaveURL(/\/$/) + await expect + .poll(async () => (await config(page))?.[2]) + .toEqual({ debug_mode: true }) + + const other = await context.newPage() + await other.goto(HOME) + await expect.poll(async () => (await config(other))?.[2]).toEqual({}) + }) + + test('Expected - Flags are stored even before consent is given', async ({ + browser, + }) => { + const context = await browser.newContext() + await context.route( + /googletagmanager\.com|google-analytics\.com/, + (route) => route.abort() + ) + const page = await context.newPage() + + await page.goto('/?internal=1') + + await expect(page).toHaveURL(/\/$/) + expect( + await page.evaluate(() => localStorage.getItem('rafer.internal')) + ).toBe('1') + expect(await page.evaluate(() => typeof window.gtag)).toBe('undefined') + + await context.close() + }) +})