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/calm-foxes-notice.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<html lang>`. 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:

Expand Down
5 changes: 5 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
21 changes: 4 additions & 17 deletions src/app/_layout/analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
}, [])
}
Expand Down
6 changes: 3 additions & 3 deletions src/config/content/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 :)'],
},
},
Expand Down
6 changes: 3 additions & 3 deletions src/config/content/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 :)'],
},
},
Expand Down
32 changes: 16 additions & 16 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 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.',
Expand All @@ -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;',
Expand All @@ -55,22 +55,22 @@ 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.',
],
},
{
id: 'cookies',
title: 'Cookies',
paragraphs: [
'“_ga” and “_ga_<ID>” 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_<ID>” 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.',
],
},
{
Expand All @@ -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).`,
],
},
Expand All @@ -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.',
Expand All @@ -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;',
Expand All @@ -144,22 +144,22 @@ 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.',
],
},
{
id: 'cookies',
title: 'Cookies',
paragraphs: [
'“_ga” e “_ga_<ID>” 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_<ID>” 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.',
],
},
{
Expand All @@ -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).`,
],
},
Expand Down
22 changes: 21 additions & 1 deletion src/lib/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>)[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() {
Expand All @@ -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('.')
Expand All @@ -149,6 +168,7 @@ export {
clearAnalyticsCookies,
send,
startAnalytics,
stopAnalytics,
track,
trackable,
updateUserProperties,
Expand Down
13 changes: 12 additions & 1 deletion src/lib/consent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down
Loading
Loading