diff --git a/apps/web/app/_components/analytics-gate.tsx b/apps/web/app/_components/analytics-gate.tsx index 4dde522..4d50f12 100644 --- a/apps/web/app/_components/analytics-gate.tsx +++ b/apps/web/app/_components/analytics-gate.tsx @@ -14,7 +14,7 @@ const SCRIPT_ID = "ga-gtag-js" * without an explanation. */ const FALLBACK_TEXT = - "We use cookies to measure how this site is used, via Google Analytics. Analytics only runs if you accept." + "Can we count your visit? It shows us which pages actually help people — we only ever look at totals, and never use it for ads. It runs on Google Analytics cookies, and only if you accept." type Consent = "granted" | "denied" diff --git a/docs/notes/analytics.md b/docs/notes/analytics.md index 743dba9..36317a0 100644 --- a/docs/notes/analytics.md +++ b/docs/notes/analytics.md @@ -7,6 +7,7 @@ - `packages/db/src/schema.ts` — `analyticsConfig` table (`analytics_config`), single row per `kind` (`google_analytics` today), same shape-pattern as `emailConfig`. `measurementId` plaintext (public id, not a secret). Satisfies `L2-ANALYTICS-01`. - `packages/db/migrations/0005_clear_mattie_franklin.sql` — drizzle-kit generated `CREATE TABLE analytics_config`. - `packages/db/migrations/0006_seed_analytics_config.sql` — hand-written custom migration (`drizzle-kit generate --custom`), seeds the singleton row + default banner copy, `ON CONFLICT ("kind") DO NOTHING`. Fixed literal uuid for the seed id (the pk has no DB-level default — `$defaultFn` is app-side only). Satisfies `L2-ANALYTICS-01`, `L2-ANALYTICS-08`. +- `packages/db/migrations/0008_reword_cookie_banner.sql` — custom migration; rewrites the banner copy seeded by 0006. Guarded `WHERE "cookieBannerText" = `, so an operator-edited banner is never clobbered (`L2-ANALYTICS-08`) and a re-run matches nothing. 0006 is left untouched — applied migrations are immutable; a fresh DB runs 0006 then 0008 and lands on the new copy. - `settings/_actions.ts` → `saveAnalyticsConfig` — auth + `canAccessSettings` gate → uppercase/validate id → upsert on `kind` → `revalidatePath("/backflip/settings")`. Mirrors `saveEmailConfig` minus encryption. Satisfies `L2-ANALYTICS-02`, `L2-ANALYTICS-12`, `L2-ANALYTICS-14/15`. - `settings/_components/analytics-integration.tsx` — client detail pane; `useActionState(saveAnalyticsConfig)`; exports the `AnalyticsConfig` view-model type (page.tsx imports it type-only). Banner switch is controlled state so the copy field and the explainer react live. Satisfies `L2-ANALYTICS-05`. - `settings/_components/integrations-view.tsx` — `Selection` widened to `"ai" | "email" | "analytics"`; third `ListRow` (GA tile, subtitle = the measurement id when set, else "Not configured"); detail switch became a chain. @@ -17,6 +18,14 @@ - `apps/web/app/_components/cookie-banner.tsx` — client; presentational only (`role="region"`, `aria-label="Cookie consent"`, fixed bottom, `z-50`, Card-ish bar, `Button` outline/primary). No storage or GA knowledge. - `apps/web/app/_components/site-footer.tsx` — now renders ``. +## Banner copy (why it reads the way it does) +Default is a conversion problem, not a legal one — the consent gate is strict (`L2-ANALYTICS-06`), so *undecided* visitors are never measured either, and most people never click anything. Measured traffic ≈ accept rate. +- Old seed led with the cost ("We use cookies … via Google Analytics") and gave no reason to say yes. +- Current default asks for the visit, states the benefit, bounds the use ("we only ever look at totals, and never use it for ads"), then names Google Analytics + cookies. Same facts, cost last. +- Claims are about *our* use only. Don't add promises about what Google does with it — an operator can enable Google Signals/ads features and make them false. +- Layout stays symmetric: Decline and Accept, one click each, same bar. EDPB requires reject to be equally easy; it does not require identical styling, so primary Accept + outline Decline stays compliant. Never remove Decline, never bury it behind a second layer. +- Copy is operator-editable at `/backflip/settings`, so retuning needs no deploy. `FALLBACK_TEXT` in `analytics-gate.tsx` mirrors the seed and must be updated with it. + ## Why the config is fetched, not server-rendered Public pages (`/`, `/getting-started`, both guides) build as `○` static. Reading `analytics_config` in those pages — or in the root layout — would flip them to `ƒ`, and the root layout is shared with `/backflip`, so it would drag the whole app dynamic. diff --git a/packages/db/migrations/0008_reword_cookie_banner.sql b/packages/db/migrations/0008_reword_cookie_banner.sql new file mode 100644 index 0000000..9461e59 --- /dev/null +++ b/packages/db/migrations/0008_reword_cookie_banner.sql @@ -0,0 +1,12 @@ +-- Custom SQL migration file, put your code below! -- +-- Reword the default cookie-banner copy. The 0006 seed led with the cost +-- ("cookies", "Google Analytics") and gave no reason to accept, so most +-- visitors declined or ignored it and nothing was ever measured. +-- +-- Only rewrites rows still carrying the untouched 0006 text: the copy is +-- operator-editable (`L2-ANALYTICS-08`), and a migration must never clobber +-- an operator's edit. Idempotent — a second run matches nothing. +UPDATE "analytics_config" +SET "cookieBannerText" = 'Can we count your visit? It shows us which pages actually help people — we only ever look at totals, and never use it for ads. It runs on Google Analytics cookies, and only if you accept.' +WHERE "kind" = 'google_analytics' + AND "cookieBannerText" = 'We use cookies to measure how this site is used, via Google Analytics. Analytics only runs if you accept.'; diff --git a/packages/db/migrations/meta/0008_snapshot.json b/packages/db/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..27284b4 --- /dev/null +++ b/packages/db/migrations/meta/0008_snapshot.json @@ -0,0 +1,673 @@ +{ + "id": "c7a05304-8dff-402f-86e2-fae12800f105", + "prevId": "361a7173-5a46-4e99-af10-f5bd05d5ee4d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "columnsFrom": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "name": "account_provider_providerAccountId_pk", + "columns": [ + "provider", + "providerAccountId" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_config": { + "name": "ai_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "ai_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "apiKeyEnc": { + "name": "apiKeyEnc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "baseUrl": { + "name": "baseUrl", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0.7 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "isDefault": { + "name": "isDefault", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ai_config_provider_unique": { + "name": "ai_config_provider_unique", + "columns": [ + "provider" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.analytics_config": { + "name": "analytics_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'google_analytics'" + }, + "measurementId": { + "name": "measurementId", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cookieBannerEnabled": { + "name": "cookieBannerEnabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cookieBannerText": { + "name": "cookieBannerText", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "analytics_config_kind_unique": { + "name": "analytics_config_kind_unique", + "columns": [ + "kind" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_config": { + "name": "email_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'resend'" + }, + "apiKeyEnc": { + "name": "apiKeyEnc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fromEmail": { + "name": "fromEmail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fromName": { + "name": "fromName", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "replyTo": { + "name": "replyTo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "email_config_provider_unique": { + "name": "email_config_provider_unique", + "columns": [ + "provider" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "columnsFrom": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.speech_config": { + "name": "speech_config", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'deepgram'" + }, + "apiKeyEnc": { + "name": "apiKeyEnc", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sttModel": { + "name": "sttModel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ttsModel": { + "name": "ttsModel", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "speech_config_provider_unique": { + "name": "speech_config_provider_unique", + "columns": [ + "provider" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_token": { + "name": "user_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "user_token_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "tokenHash": { + "name": "tokenHash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "newEmail": { + "name": "newEmail", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expiresAt": { + "name": "expiresAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "consumedAt": { + "name": "consumedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_token_userId_user_id_fk": { + "name": "user_token_userId_user_id_fk", + "tableFrom": "user_token", + "columnsFrom": [ + "userId" + ], + "tableTo": "user", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_token_tokenHash_unique": { + "name": "user_token_tokenHash_unique", + "columns": [ + "tokenHash" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "emailVerified": { + "name": "emailVerified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "passwordHash": { + "name": "passwordHash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'teammate'" + }, + "tokenVersion": { + "name": "tokenVersion", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "nullsNotDistinct": false + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verificationToken": { + "name": "verificationToken", + "schema": "", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires": { + "name": "expires", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "name": "verificationToken_identifier_token_pk", + "columns": [ + "identifier", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.ai_provider": { + "name": "ai_provider", + "schema": "public", + "values": [ + "anthropic", + "openai", + "google" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "owner", + "admin", + "teammate" + ] + }, + "public.user_token_type": { + "name": "user_token_type", + "schema": "public", + "values": [ + "password_reset", + "email_change" + ] + } + }, + "schemas": {}, + "views": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 10756db..6418f98 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1785368142332, "tag": "0007_unknown_professor_monster", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1786079140545, + "tag": "0008_reword_cookie_banner", + "breakpoints": true } ] } \ No newline at end of file