Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-owls-count.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'landing-page': minor
---

add GA internal traffic and debug switches, and site language and color scheme user properties
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

- [About](#about)
- [What's inside](#whats-inside)
- [Analytics](#analytics)
- [Architecture](#architecture)
- [Running locally](#running-locally)
- [Quality gates](#quality-gates)
Expand All @@ -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
Expand Down
25 changes: 24 additions & 1 deletion src/app/_layout/analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand All @@ -15,6 +22,7 @@ const useConsentedTags = () => {
startAnalytics()
}

applyAnalyticsFlags()
if (hasConsent()) start()

return onConsentChange((value) => {
Expand Down Expand Up @@ -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()

Expand Down
6 changes: 4 additions & 2 deletions src/config/privacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand All @@ -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: [
Expand Down Expand Up @@ -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.',
Expand All @@ -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: [
Expand Down
74 changes: 72 additions & 2 deletions src/lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Flag>

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<string, unknown>) => {
if (!hasConsent()) return
window.gtag?.('event', event, params)
Expand Down Expand Up @@ -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)
}
Expand All @@ -82,5 +144,13 @@ const clearAnalyticsCookies = () => {
})
}

export { clearAnalyticsCookies, send, startAnalytics, track, trackable }
export {
applyAnalyticsFlags,
clearAnalyticsCookies,
send,
startAnalytics,
track,
trackable,
updateUserProperties,
}
export type { AnalyticsEvent, AnalyticsEvents }
132 changes: 132 additions & 0 deletions tests/analytics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
Loading