From 987e5494a20147b3b4e59c52f6b23b8c5cd79898 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Sat, 11 Jul 2026 11:05:37 -0400 Subject: [PATCH] Add Plausible and consent-gated GA4 to badseo.dev (#380) * Add consent-gated GA4 to badseo.dev * Refine analytics consent banner * Add Plausible analytics to badseo.dev * Tighten analytics consent prompt * Update analytics consent copy --- .agents/PAPERCUTS.md | 4 + badseo/README.md | 16 +++- badseo/public/analytics.js | 116 +++++++++++++++++++++++++ badseo/public/styles.css | 109 +++++++++++++++++++++++ badseo/scripts/run-audit.ts | 3 +- badseo/src/components/site-layout.tsx | 8 ++ badseo/src/lib.ts | 11 ++- badseo/src/plausible.ts | 5 ++ badseo/src/routeTree.gen.ts | 32 ++++++- badseo/src/routes/__root.tsx | 6 ++ badseo/src/routes/privacy.tsx | 120 ++++++++++++++++++++++++++ badseo/src/server/badseo.ts | 2 +- 12 files changed, 424 insertions(+), 8 deletions(-) create mode 100644 badseo/public/analytics.js create mode 100644 badseo/src/plausible.ts create mode 100644 badseo/src/routes/privacy.tsx diff --git a/.agents/PAPERCUTS.md b/.agents/PAPERCUTS.md index ec16739..2f9c5d9 100644 --- a/.agents/PAPERCUTS.md +++ b/.agents/PAPERCUTS.md @@ -6,6 +6,10 @@ This is not a completed-work log or a bug tracker. Never include secrets, creden ## Open +- [ ] `2026-07-10T22:32:40Z` — `codex` — While running the badseo audit from the workspace, sandboxed TSX failed with `listen EPERM` when creating its IPC socket under the temporary directory. Rerunning the same audit with local IPC permission succeeded; provide a sandbox-compatible TSX invocation for validation scripts. +- [ ] `2026-07-10T22:23:50Z` — `codex` — While pushing the consent-banner refinement to PR #380, the SSH push hung without output and a separate remote verification hung as well; retrying the same push succeeded immediately. Surface an actionable SSH timeout or connection error instead of waiting indefinitely. +- [ ] `2026-07-10T21:36:27Z` — `codex` — While using the repository's `webapp-testing` skill, its required Python Playwright import was unavailable in both system and bundled Python. The bundled Node Playwright runtime completed the check; document or provide that fallback, including Cloudflare Vite's default port 8787 rather than Vite's usual 5173. +- [ ] `2026-07-10T21:32:10Z` — `codex` — While formatting the standalone `badseo` workspace, `pnpm exec prettier` failed because Prettier is only available from the repository root. Document the root-only formatter command or expose a workspace-local formatting script. - [ ] `2026-07-10T21:09:27Z` — `codex` — While building the TanStack/Cloudflare badseo app, Wrangler reported an EPERM writing its debug log under the user preferences directory even though the build succeeded. Set `WRANGLER_LOG_PATH` to a writable temporary path in sandboxed build commands or make the logging failure non-fatal and quiet. - [ ] `2026-07-10T17:53:20Z` — `codex` — While validating `.greptile/`, both `pnpm exec prettier --check` and the existing `pnpm format:check` attempted to reconcile `node_modules` and aborted because no TTY was available. Calling `node_modules/.bin/prettier` performed the non-installing check successfully; the agent/CI path needs a stable way to run package scripts without an interactive modules purge. - [ ] `2026-07-10T18:12:35Z` — `codex` — While validating referenced files in zsh, using `path` as a loop variable overwrote zsh's special `path` array and made commands such as `git`, `jq`, and `sed` appear missing later in the same shell. Use a neutral name such as `file_path` in shell loops. diff --git a/badseo/README.md b/badseo/README.md index a405988..70e136a 100644 --- a/badseo/README.md +++ b/badseo/README.md @@ -39,7 +39,7 @@ Browse them all at `/catalog`. badseo.dev is a TanStack Start app deployed to a Cloudflare Worker, following the same Vite and Cloudflare setup as the repository's `web/` app. -TanStack React routes render the healthy homepage and catalog. +TanStack React routes render the healthy homepage, catalog, and privacy policy. A TanStack catch-all server route keeps the deliberate fixtures as raw responses with byte-level control over status codes, redirects, headers (`X-Robots-Tag`, `Link: …; rel=canonical`), timing, and the malformed `` @@ -52,6 +52,18 @@ states the audit needs to observe. **SEO-neutral**: it emits no `

`–`

` and no ``. - `src/fixtures/*.ts` — the fixtures, one file per category. +## Analytics + +Plausible Analytics loads on every page using the site-specific script supplied +for badseo.dev. It provides the cookieless aggregate baseline without changing +the Google Analytics consent choice. + +Google Analytics uses measurement ID `G-7MXV9FH7SS`. The small consent script in +`public/analytics.js` is shared by TanStack pages and raw fixture documents. It +does not request Google's tag or set analytics cookies until a visitor accepts. +The visitor can reject analytics or revisit the choice from **Cookie settings** +in the footer. The choice is stored only in the visitor's browser. + ## Run it locally ```bash @@ -64,7 +76,7 @@ npm run dev # serves on http://localhost:8787 The harness drives the **real** OpenSEO crawl + issue-detection functions (imported straight from `../src`) against a running badseo.dev, then asserts every fixture triggers exactly the issues it declares — and that the homepage, -catalog, and support pages come back clean. +catalog, privacy policy, and support pages come back clean. ```bash # with `npm run dev` running in another terminal: diff --git a/badseo/public/analytics.js b/badseo/public/analytics.js new file mode 100644 index 0000000..5b24154 --- /dev/null +++ b/badseo/public/analytics.js @@ -0,0 +1,116 @@ +/* oxlint-disable typescript-eslint/no-unsafe-argument, typescript-eslint/no-unsafe-assignment, typescript-eslint/no-unsafe-call, typescript-eslint/no-unsafe-member-access -- This standalone browser script defines Google's dynamic dataLayer globals. */ +(function () { + "use strict"; + + var measurementId = "G-7MXV9FH7SS"; + var storageKey = "badseo.analyticsConsent"; + var granted = "granted"; + var denied = "denied"; + + function readConsent() { + try { + return window.localStorage.getItem(storageKey); + } catch { + return null; + } + } + + function writeConsent(value) { + try { + window.localStorage.setItem(storageKey, value); + } catch { + // The choice still applies to this page when storage is unavailable. + } + } + + function loadAnalytics() { + if (document.getElementById("ga4-script")) return; + + window["ga-disable-" + measurementId] = false; + window.dataLayer = window.dataLayer || []; + window.gtag = function () { + window.dataLayer.push(arguments); + }; + window.gtag("consent", "default", { + analytics_storage: "granted", + ad_storage: "denied", + ad_user_data: "denied", + ad_personalization: "denied", + }); + window.gtag("js", new Date()); + window.gtag("config", measurementId); + + var script = document.createElement("script"); + script.id = "ga4-script"; + script.async = true; + script.src = + "https://www.googletagmanager.com/gtag/js?id=" + + encodeURIComponent(measurementId); + document.head.appendChild(script); + } + + function deleteAnalyticsCookies() { + document.cookie.split(";").forEach(function (cookie) { + var name = cookie.split("=")[0].trim(); + if (name !== "_ga" && !name.startsWith("_ga_")) return; + + document.cookie = name + "=; Max-Age=0; path=/; SameSite=Lax"; + if (window.location.hostname.endsWith("badseo.dev")) { + document.cookie = + name + "=; Max-Age=0; path=/; domain=.badseo.dev; SameSite=Lax"; + } + }); + } + + function createBanner() { + var banner = document.createElement("div"); + banner.className = "consent-banner"; + banner.setAttribute("role", "dialog"); + banner.setAttribute("aria-modal", "true"); + banner.setAttribute("aria-labelledby", "consent-title"); + banner.hidden = true; + banner.innerHTML = + '" + + '"; + document.body.appendChild(banner); + + banner.addEventListener("click", function (event) { + var button = event.target.closest("[data-consent]"); + if (!button) return; + + var previous = readConsent(); + var choice = button.getAttribute("data-consent"); + writeConsent(choice); + banner.hidden = true; + + if (choice === granted) { + loadAnalytics(); + return; + } + + window["ga-disable-" + measurementId] = true; + deleteAnalyticsCookies(); + if (previous === granted) window.location.reload(); + }); + + return banner; + } + + var banner = createBanner(); + var consent = readConsent(); + if (consent === granted) loadAnalytics(); + else if (consent !== denied) banner.hidden = false; + + document.addEventListener("click", function (event) { + if (!event.target.closest("[data-cookie-settings]")) return; + banner.hidden = false; + banner.querySelector("button").focus(); + }); +})(); diff --git a/badseo/public/styles.css b/badseo/public/styles.css index 02dc498..393b114 100644 --- a/badseo/public/styles.css +++ b/badseo/public/styles.css @@ -393,6 +393,115 @@ a:hover { .foot-links { display: flex; gap: 18px; + align-items: center; + flex-wrap: wrap; +} +.footer-button { + appearance: none; + border: 0; + padding: 0; + background: transparent; + color: var(--ink-muted); + font: inherit; + cursor: pointer; +} +.footer-button:hover { + color: var(--ink); + text-decoration: underline; + text-underline-offset: 3px; +} + +/* ── analytics consent ── */ +.consent-banner { + position: fixed; + z-index: 100; + bottom: 24px; + left: 24px; + width: min(350px, calc(100vw - 48px)); + padding: 16px; + background: var(--surface); + border: 1px solid var(--hairline); + border-radius: 14px; + box-shadow: + 0 20px 50px rgb(17 17 17 / 14%), + 0 2px 8px rgb(17 17 17 / 7%); + animation: consent-in 180ms ease-out; +} +.consent-banner[hidden] { + display: none; +} +.consent-copy strong { + display: block; + margin-bottom: 4px; + font-size: 15px; + font-weight: 600; + letter-spacing: -0.01em; +} +.consent-copy p { + margin: 0 0 7px; + color: var(--ink-muted); + font-size: 13.5px; + line-height: 1.45; +} +.consent-copy a { + color: var(--ink-muted); + font-size: 12.5px; + text-decoration: underline; + text-decoration-color: var(--hairline); + text-underline-offset: 3px; +} +.consent-copy a:hover { + color: var(--ink); + text-decoration-color: var(--ink); +} +.consent-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-top: 12px; +} +.consent-actions button { + border: 1px solid var(--hairline); + border-radius: 8px; + min-height: 36px; + padding: 7px 12px; + background: var(--surface); + color: var(--ink); + font: inherit; + font-size: 14px; + font-weight: 500; + cursor: pointer; +} +.consent-actions button:hover { + background: var(--surface-2); +} +.consent-actions .consent-accept { + border-color: var(--ink); + background: var(--ink); + color: #ffffff; +} +.consent-actions .consent-accept:hover { + background: #000000; +} +@keyframes consent-in { + from { + opacity: 0; + transform: translateY(8px); + } +} +@media (prefers-reduced-motion: reduce) { + .consent-banner { + animation: none; + } +} +@media (max-width: 640px) { + .consent-banner { + right: 12px; + bottom: 12px; + left: 12px; + width: auto; + padding: 15px; + } } .openseo-badge { diff --git a/badseo/scripts/run-audit.ts b/badseo/scripts/run-audit.ts index 733ef49..40f74ba 100644 --- a/badseo/scripts/run-audit.ts +++ b/badseo/scripts/run-audit.ts @@ -46,7 +46,7 @@ interface CrawlLink { async function warmup(): Promise { // Prime the dev server so healthy pages don't read as slow on a cold start. - const paths = new Set(["/", "/catalog"]); + const paths = new Set(["/", "/catalog", "/privacy"]); for (const f of allFixtures) for (const p of fixturePaths(f)) paths.add(p); await Promise.all( [...paths].map((p) => @@ -335,6 +335,7 @@ async function main() { // Non-fixture content pages must be clean. check("Homepage", "/", [], false); check("Catalog", "/catalog", [], false); + check("Privacy policy", "/privacy", [], false); const byCategory = new Map(); for (const f of allFixtures) { diff --git a/badseo/src/components/site-layout.tsx b/badseo/src/components/site-layout.tsx index 6c94273..e3aa583 100644 --- a/badseo/src/components/site-layout.tsx +++ b/badseo/src/components/site-layout.tsx @@ -22,6 +22,14 @@ export function SiteLayout({ children }: { children: ReactNode }) { Catalog GitHub OpenSEO + Privacy + diff --git a/badseo/src/lib.ts b/badseo/src/lib.ts index 0e6cb1b..eae8980 100644 --- a/badseo/src/lib.ts +++ b/badseo/src/lib.ts @@ -7,6 +7,7 @@ // — not accidental noise from the layout. import { AUDIT_ISSUE_TYPES } from "../../src/shared/audit-issues"; import type { Fixture, IssueId } from "./fixtures/types"; +import { PLAUSIBLE_INIT_SCRIPT, PLAUSIBLE_SCRIPT_SRC } from "./plausible"; export function escapeHtml(input: string): string { return input @@ -44,7 +45,13 @@ interface DocumentOptions { /** Build a complete HTML document string with exact control. */ export function renderDocument(opts: DocumentOptions): string { - const head: string[] = ['']; + const head: string[] = [ + "", + ``, + ``, + '', + '', + ]; head.push( '', ); @@ -102,6 +109,8 @@ function footerHtml(): string { Catalog GitHub OpenSEO + Privacy + `; } diff --git a/badseo/src/plausible.ts b/badseo/src/plausible.ts new file mode 100644 index 0000000..850c8b8 --- /dev/null +++ b/badseo/src/plausible.ts @@ -0,0 +1,5 @@ +export const PLAUSIBLE_SCRIPT_SRC = + "https://plausible.io/js/pa-Cr442m-9lp6sEF3_GhWNS.js"; + +export const PLAUSIBLE_INIT_SCRIPT = + "window.plausible=window.plausible||function(){(plausible.q=plausible.q||[]).push(arguments)},plausible.init=plausible.init||function(i){plausible.o=i||{}};plausible.init();"; diff --git a/badseo/src/routeTree.gen.ts b/badseo/src/routeTree.gen.ts index 481b785..cf879fc 100644 --- a/badseo/src/routeTree.gen.ts +++ b/badseo/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SitemapDotxmlRouteImport } from './routes/sitemap[.]xml' import { Route as RobotsDottxtRouteImport } from './routes/robots[.]txt' +import { Route as PrivacyRouteImport } from './routes/privacy' import { Route as CatalogRouteImport } from './routes/catalog' import { Route as SplatRouteImport } from './routes/$' import { Route as IndexRouteImport } from './routes/index' @@ -25,6 +26,11 @@ const RobotsDottxtRoute = RobotsDottxtRouteImport.update({ path: '/robots.txt', getParentRoute: () => rootRouteImport, } as any) +const PrivacyRoute = PrivacyRouteImport.update({ + id: '/privacy', + path: '/privacy', + getParentRoute: () => rootRouteImport, +} as any) const CatalogRoute = CatalogRouteImport.update({ id: '/catalog', path: '/catalog', @@ -45,6 +51,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/$': typeof SplatRoute '/catalog': typeof CatalogRoute + '/privacy': typeof PrivacyRoute '/robots.txt': typeof RobotsDottxtRoute '/sitemap.xml': typeof SitemapDotxmlRoute } @@ -52,6 +59,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/$': typeof SplatRoute '/catalog': typeof CatalogRoute + '/privacy': typeof PrivacyRoute '/robots.txt': typeof RobotsDottxtRoute '/sitemap.xml': typeof SitemapDotxmlRoute } @@ -60,21 +68,31 @@ export interface FileRoutesById { '/': typeof IndexRoute '/$': typeof SplatRoute '/catalog': typeof CatalogRoute + '/privacy': typeof PrivacyRoute '/robots.txt': typeof RobotsDottxtRoute '/sitemap.xml': typeof SitemapDotxmlRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/$' | '/catalog' | '/robots.txt' | '/sitemap.xml' + fullPaths: + '/' | '/$' | '/catalog' | '/privacy' | '/robots.txt' | '/sitemap.xml' fileRoutesByTo: FileRoutesByTo - to: '/' | '/$' | '/catalog' | '/robots.txt' | '/sitemap.xml' - id: '__root__' | '/' | '/$' | '/catalog' | '/robots.txt' | '/sitemap.xml' + to: '/' | '/$' | '/catalog' | '/privacy' | '/robots.txt' | '/sitemap.xml' + id: + | '__root__' + | '/' + | '/$' + | '/catalog' + | '/privacy' + | '/robots.txt' + | '/sitemap.xml' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute SplatRoute: typeof SplatRoute CatalogRoute: typeof CatalogRoute + PrivacyRoute: typeof PrivacyRoute RobotsDottxtRoute: typeof RobotsDottxtRoute SitemapDotxmlRoute: typeof SitemapDotxmlRoute } @@ -95,6 +113,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RobotsDottxtRouteImport parentRoute: typeof rootRouteImport } + '/privacy': { + id: '/privacy' + path: '/privacy' + fullPath: '/privacy' + preLoaderRoute: typeof PrivacyRouteImport + parentRoute: typeof rootRouteImport + } '/catalog': { id: '/catalog' path: '/catalog' @@ -123,6 +148,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, SplatRoute: SplatRoute, CatalogRoute: CatalogRoute, + PrivacyRoute: PrivacyRoute, RobotsDottxtRoute: RobotsDottxtRoute, SitemapDotxmlRoute: SitemapDotxmlRoute, } diff --git a/badseo/src/routes/__root.tsx b/badseo/src/routes/__root.tsx index 79a2493..c02b36a 100644 --- a/badseo/src/routes/__root.tsx +++ b/badseo/src/routes/__root.tsx @@ -4,6 +4,7 @@ import { Outlet, Scripts, } from "@tanstack/react-router"; +import { PLAUSIBLE_INIT_SCRIPT, PLAUSIBLE_SCRIPT_SRC } from "../plausible"; export const Route = createRootRoute({ head: () => ({ @@ -27,6 +28,10 @@ export const Route = createRootRoute({ }, { rel: "stylesheet", href: "/styles.css" }, ], + scripts: [ + { async: true, src: PLAUSIBLE_SCRIPT_SRC }, + { defer: true, src: "/analytics.js" }, + ], }), component: RootComponent, }); @@ -36,6 +41,7 @@ function RootComponent() { +