diff --git a/.changeset/calm-foxes-notice.md b/.changeset/calm-foxes-notice.md new file mode 100644 index 0000000..e48111b --- /dev/null +++ b/.changeset/calm-foxes-notice.md @@ -0,0 +1,5 @@ +--- +'landing-page': minor +--- + +run analytics by default with an opt-out notice that honors Global Privacy Control and Do Not Track diff --git a/README.md b/README.md index 5f3598d..84f2357 100644 --- a/README.md +++ b/README.md @@ -42,13 +42,13 @@ The site is also a working example of the rest of the delivery chain: a static b - **Bilingual, statically.** English at `/` and Brazilian Portuguese at `/pt`, each with its own root layout and correct ``. First-time visitors are sent to their browser's language by a tiny inline script. After that, the language toggle's choice wins. - **Content as data.** All copy lives in typed modules under `src/config/content`, one per locale, behind a single `SiteContent` type. A missing translation is a type error. -- **Privacy first.** Google Analytics runs in consent mode and stays denied until the visitor accepts. Click tracking is declarative through `data-*` attributes. Bilingual privacy policy (LGPD) at `/privacy` and `/pt/privacidade`. +- **Privacy minded.** Google Analytics runs by default with ads features off, behind a notice where visitors can opt out at any time. Opting out stops tracking at once and deletes its cookies, and Global Privacy Control or Do Not Track signals keep it off. Click tracking is declarative through `data-*` attributes. Bilingual privacy policy (LGPD) at `/privacy` and `/pt/privacidade`. - **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`. +Unless a visitor opts out or their browser sends a privacy signal, 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: diff --git a/playwright.config.ts b/playwright.config.ts index 131fa3f..bddf73d 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -11,6 +11,11 @@ export default defineConfig({ baseURL: process.env.WEB_SERVER_URL || 'http://localhost:3000', trace: 'on-first-retry', + launchOptions: { + args: [ + '--host-resolver-rules=MAP *.googletagmanager.com ~NOTFOUND, MAP *.google-analytics.com ~NOTFOUND', + ], + }, }, projects: [ diff --git a/src/app/_layout/analytics.tsx b/src/app/_layout/analytics.tsx index a32bc15..ba72095 100644 --- a/src/app/_layout/analytics.tsx +++ b/src/app/_layout/analytics.tsx @@ -5,34 +5,21 @@ import { useEffect } from 'react' import { DARK_QUERY, THEME_EVENT } from '@/config/theme' import { applyAnalyticsFlags, - clearAnalyticsCookies, send, startAnalytics, + stopAnalytics, updateUserProperties, } from '@/lib/analytics' import { hasConsent, onConsentChange } from '@/lib/consent' const useConsentedTags = () => { useEffect(() => { - let started = false - - const start = () => { - if (started) return - started = true - startAnalytics() - } - applyAnalyticsFlags() - if (hasConsent()) start() + if (hasConsent()) startAnalytics() return onConsentChange((value) => { - if (value === 'granted') { - start() - return - } - - clearAnalyticsCookies() - if (started) window.location.reload() + if (value === 'granted') startAnalytics() + else stopAnalytics() }) }, []) } diff --git a/src/config/content/en.ts b/src/config/content/en.ts index 087025c..5f81ec2 100644 --- a/src/config/content/en.ts +++ b/src/config/content/en.ts @@ -27,10 +27,10 @@ const en: SiteContent = { columns: { contact: 'Contact', social: 'Elsewhere', site: 'Site' }, privacyChoices: 'Privacy choices', consent: { - text: 'I use Google Analytics to see which projects people read. No ads, nothing is sold, and nothing loads unless you say yes.', + text: 'I use Google Analytics to see which projects people read. No ads, nothing is sold, and you can turn it off any time.', policy: 'Privacy policy', - accept: 'Accept', - decline: 'Decline', + accept: 'OK', + decline: 'No thanks', note: ['your call :)'], }, }, diff --git a/src/config/content/pt.ts b/src/config/content/pt.ts index 3b32edf..e2217ef 100644 --- a/src/config/content/pt.ts +++ b/src/config/content/pt.ts @@ -27,10 +27,10 @@ const pt: SiteContent = { columns: { contact: 'Contato', social: 'Em outros lugares', site: 'Site' }, privacyChoices: 'Preferências de privacidade', consent: { - text: 'Uso o Google Analytics para ver quais projetos as pessoas leem. Sem anúncios, nada é vendido, e nada carrega sem o seu sim.', + text: 'Uso o Google Analytics para ver quais projetos as pessoas leem. Sem anúncios, nada é vendido, e você pode desativar quando quiser.', policy: 'Política de privacidade', - accept: 'Aceitar', - decline: 'Recusar', + accept: 'OK', + decline: 'Não, obrigado', note: ['você decide :)'], }, }, diff --git a/src/config/privacy.ts b/src/config/privacy.ts index 7849844..5688a82 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 29, 2026', + updated: 'Last updated: October 17, 2026', path: paths.en.privacy, intro: [ 'This policy explains what data rafer.dev collects, why, and what you can do about it.', @@ -43,8 +43,8 @@ const en: Policy = { id: 'collected', title: 'What is collected', paragraphs: [ - 'Nothing, unless you accept analytics. Until you do, no analytics script is loaded and no tracking cookie is set.', - 'If you accept, Google Analytics 4 records how the site is used:', + 'Google Analytics 4 runs by default to show how the site is used. You can turn it off at any time, and it stays off when your browser sends a Global Privacy Control or Do Not Track signal.', + 'While it is on, it records:', ], list: [ 'pages and sections viewed;', @@ -55,14 +55,14 @@ const en: Policy = { 'the site that referred you.', ], after: [ - 'Your answer to the consent banner is kept in your browser’s local storage, under the key “rafer.consent”, so you aren’t asked again. If you pick a language or a light or dark theme, those choices are kept the same way, under “rafer.lang” and “rafer.theme”. None of them ever leaves your device.', + 'Your answer to the analytics notice is kept in your browser’s local storage, under the key “rafer.consent”, so the notice isn’t shown again. If you pick a language or a light or dark theme, those choices are kept the same way, under “rafer.lang” and “rafer.theme”. None of them ever leaves your device.', ], }, { id: 'purpose', title: 'Why, and on what legal basis', paragraphs: [ - 'To understand which content is useful and to improve the site. The legal basis is your consent (LGPD, article 7, I).', + 'To understand which content is useful and to improve the site. The legal basis is legitimate interest (LGPD, article 7, IX): the data is limited to how the site is used, it is never used to identify you, and you can opt out at any time.', 'The data is not used for advertising, is not sold, and is not shared for marketing. Google’s advertising features are turned off.', ], }, @@ -70,7 +70,7 @@ const en: Policy = { id: 'cookies', title: 'Cookies', paragraphs: [ - '“_ga” and “_ga_” are set by Google Analytics, only after you accept, to tell visits apart. They last up to 2 years, or until you decline or clear them.', + '“_ga” and “_ga_” are set by Google Analytics to tell visits apart. They are never set if you opt out or your browser sends a privacy signal, and opting out deletes them. Otherwise they last up to 2 years, or until you clear them.', ], }, { @@ -93,8 +93,8 @@ const en: Policy = { id: 'rights', title: 'Your rights', paragraphs: [ - 'Under the LGPD you can ask whether data about you is processed, and to access, correct or delete it. You can also withdraw your consent at any time.', - 'To withdraw consent, use “Privacy choices” below or at the bottom of any page. Declining stops analytics and deletes its cookies.', + 'Under the LGPD you can ask whether data about you is processed, and to access, correct or delete it. You can also object to this processing at any time.', + 'To opt out, use “Privacy choices” below or at the bottom of any page and choose “No thanks”. Analytics stops right away and its cookies are deleted.', `For any other request, email ${email}. You can also file a complaint with Brazil’s data protection authority (ANPD).`, ], }, @@ -121,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: 29 de setembro de 2026', + updated: 'Última atualização: 17 de outubro 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.', @@ -132,8 +132,8 @@ const pt: Policy = { id: 'collected', title: 'O que é coletado', paragraphs: [ - 'Nada, a menos que você aceite a análise de uso. Até lá, nenhum script de análise é carregado e nenhum cookie de rastreamento é criado.', - 'Se você aceitar, o Google Analytics 4 registra como o site é usado:', + 'O Google Analytics 4 funciona por padrão para mostrar como o site é usado. Você pode desativá-lo a qualquer momento, e ele fica desligado quando o seu navegador envia um sinal de Global Privacy Control ou Do Not Track.', + 'Enquanto está ativo, ele registra:', ], list: [ 'páginas e seções visualizadas;', @@ -144,14 +144,14 @@ const pt: Policy = { 'o site que trouxe você até aqui.', ], after: [ - 'Sua resposta ao aviso de consentimento fica guardada no armazenamento local do seu navegador, na chave “rafer.consent”, para que a pergunta não se repita. Se você escolher um idioma ou um tema claro ou escuro, essas escolhas ficam guardadas do mesmo jeito, nas chaves “rafer.lang” e “rafer.theme”. Nenhuma delas sai do seu dispositivo.', + 'Sua resposta ao aviso de análise de uso fica guardada no armazenamento local do seu navegador, na chave “rafer.consent”, para que o aviso não apareça de novo. Se você escolher um idioma ou um tema claro ou escuro, essas escolhas ficam guardadas do mesmo jeito, nas chaves “rafer.lang” e “rafer.theme”. Nenhuma delas sai do seu dispositivo.', ], }, { id: 'purpose', title: 'Finalidade e base legal', paragraphs: [ - 'Entender quais conteúdos são úteis e melhorar o site. A base legal é o seu consentimento (LGPD, art. 7º, I).', + 'Entender quais conteúdos são úteis e melhorar o site. A base legal é o legítimo interesse (LGPD, art. 7º, IX): os dados se limitam a como o site é usado, nunca servem para identificar você, e você pode desativar a qualquer momento.', 'Os dados não são usados para publicidade, não são vendidos e não são compartilhados para marketing. Os recursos de publicidade do Google estão desativados.', ], }, @@ -159,7 +159,7 @@ const pt: Policy = { id: 'cookies', title: 'Cookies', paragraphs: [ - '“_ga” e “_ga_” são criados pelo Google Analytics, somente depois que você aceita, para diferenciar visitas. Duram até 2 anos, ou até você recusar ou apagá-los.', + '“_ga” e “_ga_” são criados pelo Google Analytics para diferenciar visitas. Nunca são criados se você desativar ou se o seu navegador enviar um sinal de privacidade, e desativar os apaga. Fora isso, duram até 2 anos, ou até você apagá-los.', ], }, { @@ -182,8 +182,8 @@ const pt: Policy = { id: 'rights', title: 'Seus direitos', paragraphs: [ - 'Pela LGPD, você pode confirmar se dados seus são tratados, e pedir acesso, correção ou exclusão. Também pode retirar o consentimento a qualquer momento.', - 'Para retirar o consentimento, use “Preferências de privacidade” abaixo ou no rodapé de qualquer página. Recusar interrompe a análise de uso e apaga os cookies dela.', + 'Pela LGPD, você pode confirmar se dados seus são tratados, e pedir acesso, correção ou exclusão. Também pode se opor a esse tratamento a qualquer momento.', + 'Para desativar, use “Preferências de privacidade” abaixo ou no rodapé de qualquer página e escolha “Não, obrigado”. A análise de uso para na hora e os cookies dela são apagados.', `Para qualquer outro pedido, escreva para ${email}. Você também pode registrar uma reclamação na Autoridade Nacional de Proteção de Dados (ANPD).`, ], }, diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts index da65e3e..adf5c2a 100644 --- a/src/lib/analytics.ts +++ b/src/lib/analytics.ts @@ -105,8 +105,19 @@ const loadScript = (src: string) => { document.head.appendChild(script) } +const disableKey = () => `ga-disable-${__env.NEXT_PUBLIC_GA_TAG_ID}` + +const setDisabled = (disabled: boolean) => { + ;(window as unknown as Record)[disableKey()] = disabled +} + const startAnalytics = () => { - if (window.gtag) return + if (window.gtag) { + setDisabled(false) + window.gtag('consent', 'update', { analytics_storage: 'granted' }) + window.gtag('set', 'user_properties', userProperties()) + return + } window.dataLayer = window.dataLayer || [] window.gtag = function gtag() { @@ -127,6 +138,14 @@ const startAnalytics = () => { if (isProduction) loadScript(__env.NEXT_PUBLIC_GA_SRC) } +const stopAnalytics = () => { + if (window.gtag) { + setDisabled(true) + window.gtag('consent', 'update', { analytics_storage: 'denied' }) + } + clearAnalyticsCookies() +} + const clearAnalyticsCookies = () => { const host = window.location.hostname const apex = host.split('.').slice(-2).join('.') @@ -149,6 +168,7 @@ export { clearAnalyticsCookies, send, startAnalytics, + stopAnalytics, track, trackable, updateUserProperties, diff --git a/src/lib/consent.ts b/src/lib/consent.ts index b951b1e..6739517 100644 --- a/src/lib/consent.ts +++ b/src/lib/consent.ts @@ -22,7 +22,18 @@ const setConsent = (value: Consent) => { ) } -const hasConsent = () => getConsent() === 'granted' +const hasPrivacySignal = () => { + const { globalPrivacyControl, doNotTrack } = window.navigator as Navigator & { + globalPrivacyControl?: boolean + } + return globalPrivacyControl === true || doNotTrack === '1' +} + +const hasConsent = () => { + const consent = getConsent() + if (consent !== null) return consent === 'granted' + return !hasPrivacySignal() +} const onConsentChange = (listener: (value: Consent) => void) => { const handler = (event: Event) => diff --git a/tests/analytics.spec.ts b/tests/analytics.spec.ts index 7bc6801..9af4c33 100644 --- a/tests/analytics.spec.ts +++ b/tests/analytics.spec.ts @@ -23,20 +23,54 @@ test.describe('Analytics - Consent and tracking', () => { page.on('popup', (popup) => popup.close()) }) - test('Expected - Nothing loads before the visitor answers', async ({ + test('Expected - Analytics runs by default while the notice is shown', async ({ page, }) => { await page.goto(HOME) - const banner = page.getByRole('region', { name: 'Privacy choices' }) + await expect( + page.getByRole('region', { name: 'Privacy choices' }) + ).toBeVisible() + await expect + .poll(async () => (await dataLayer(page)).map(([command]) => command)) + .toContain('config') + }) - await expect(banner).toBeVisible() - expect(await page.evaluate(() => typeof window.gtag)).toBe('undefined') + test('Expected - OK keeps tracking and hides the notice', async ({ + page, + }) => { + await page.goto(HOME) + await page.getByRole('button', { name: 'OK', exact: true }).click() + + await expect( + page.getByRole('region', { name: 'Privacy choices' }) + ).toBeHidden() + expect( + await page.evaluate((key) => localStorage.getItem(key), CONSENT_KEY) + ).toBe('granted') + + await page + .locator('#projects') + .getByRole('link', { name: /^Home MyWorldx/ }) + .click() + + expect(await events(page)).toContainEqual([ + 'event', + 'select_content', + { content_type: 'project', content_id: 'myworldx-home' }, + ]) }) - test('Expected - Declining keeps analytics off', async ({ page }) => { + test('Expected - No thanks stops tracking at once, without a reload', async ({ + page, + }) => { await page.goto(HOME) - await page.getByRole('button', { name: 'Decline' }).click() + await page.evaluate(() => { + ;(window as unknown as { marker: boolean }).marker = true + document.cookie = '_ga=GA1.1.test; path=/' + }) + + await page.getByRole('button', { name: 'No thanks' }).click() await expect( page.getByRole('region', { name: 'Privacy choices' }) @@ -44,19 +78,74 @@ test.describe('Analytics - Consent and tracking', () => { expect( await page.evaluate((key) => localStorage.getItem(key), CONSENT_KEY) ).toBe('denied') + expect( + await page.evaluate( + () => (window as unknown as { marker?: boolean }).marker + ) + ).toBe(true) + expect(await page.evaluate(() => document.cookie)).not.toContain('_ga=') + expect(await dataLayer(page)).toContainEqual([ + 'consent', + 'update', + { analytics_storage: 'denied' }, + ]) await page .locator('#projects') .getByRole('link', { name: /^Home MyWorldx/ }) .click() + expect( + (await events(page)).filter(([, name]) => name === 'select_content') + ).toEqual([]) + }) + + test('Expected - A remembered opt-out keeps analytics off', async ({ + page, + }) => { + await page.addInitScript( + (key) => localStorage.setItem(key, 'denied'), + CONSENT_KEY + ) + await page.goto(HOME) + expect(await page.evaluate(() => typeof window.gtag)).toBe('undefined') expect(await dataLayer(page)).toEqual([]) }) - test('Expected - Accepting tracks project clicks', async ({ page }) => { + for (const signal of ['globalPrivacyControl', 'doNotTrack'] as const) { + test(`Expected - A ${signal} signal keeps analytics off until OK`, async ({ + page, + }) => { + await page.addInitScript((name) => { + Object.defineProperty(Navigator.prototype, name, { + get: () => (name === 'doNotTrack' ? '1' : true), + }) + }, signal) + await page.goto(HOME) + + await expect( + page.getByRole('region', { name: 'Privacy choices' }) + ).toBeVisible() + expect(await page.evaluate(() => typeof window.gtag)).toBe('undefined') + + await page.getByRole('button', { name: 'OK', exact: true }).click() + + expect(await page.evaluate(() => typeof window.gtag)).toBe('function') + }) + } + + test('Expected - Opting back in resumes tracking', async ({ page }) => { await page.goto(HOME) - await page.getByRole('button', { name: 'Accept' }).click() + await page.getByRole('button', { name: 'No thanks' }).click() + await page.getByRole('button', { name: 'Privacy choices' }).click() + await page.getByRole('button', { name: 'OK', exact: true }).click() + + expect(await dataLayer(page)).toContainEqual([ + 'consent', + 'update', + { analytics_storage: 'granted' }, + ]) await page .locator('#projects') @@ -133,7 +222,9 @@ test.describe('Analytics - Consent and tracking', () => { await expect( page.getByRole('region', { name: 'Privacy choices' }) ).toBeVisible() - await expect(page.getByRole('button', { name: 'Accept' })).toBeFocused() + await expect( + page.getByRole('button', { name: 'OK', exact: true }) + ).toBeFocused() }) }) @@ -247,7 +338,7 @@ test.describe('Analytics - Insights', () => { await expect.poll(async () => (await config(other))?.[2]).toEqual({}) }) - test('Expected - Flags are stored even before consent is given', async ({ + test('Expected - Flags are stored even when analytics is off', async ({ browser, }) => { const context = await browser.newContext() @@ -256,6 +347,10 @@ test.describe('Analytics - Insights', () => { (route) => route.abort() ) const page = await context.newPage() + await page.addInitScript( + (key) => localStorage.setItem(key, 'denied'), + CONSENT_KEY + ) await page.goto('/?internal=1')