diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4e04df0..f187a16 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,6 +56,12 @@ Flipping the banner on Vercel is "edit env var → redeploy", the same model as To stand up a new event: copy an existing file in `src/legacy-pages/hackathon/` to a new slug, add an App Router wrapper in `src/app/(website)/hackathon//page.tsx`, edit its data (or write a custom layout), then set `HACKATHON_EVENT_SLUG=` and `HACKATHON_BANNER_ENABLED=true` on Vercel. +### Cookie consent & analytics (OneTrust + GTM) + +[`ConsentTags`](./src/components/consent-tags.tsx) renders the OneTrust consent banner and the Google Tag Manager container at the top of ``, gated by [`resolveOneTrustEnv`](./src/lib/onetrust.ts): production deploys get the production OneTrust variant, previews get the test variant (works on any domain), and local dev gets none. Set `ONETRUST_ENV=test pnpm dev` to see the banner locally. Tag order is load-bearing — the OneTrust AutoBlocker must run before GTM so non-consented cookies are gated — so the tags render as plain blocking scripts, not `next/script` (React hoists only the banner stylesheet `` into ``; the scripts keep their `` source order). The "Your Privacy Choices" footer link ([`YourPrivacyChoicesLink`](./src/components/your-privacy-choices-link.tsx)) opens the OneTrust preference center and must remain in both footers (main and perspectives). + +Because the site is a SPA there is no next page load to pick up a consent change, so `ConsentTags` also renders a consent-change handler that reloads the page when the user actually changes their consent (guarded by comparing `OnetrustActiveGroups` against its initial snapshot). It chains — never replaces — `OptanonWrapper`, which the shared databricks `onetrust.js` defines to delete opted-out cookies, so that script's logic keeps running first and the handler must stay ordered after it. + ### Site URL Resolution Anywhere we need an absolute URL — `llms.txt`, `sitemap.xml`, `robots.txt`, JSON-LD, `/api/markdown`, `/api/bootstrap-prompt`, `/api/mcp`, the `Copy prompt` / `Copy Markdown` buttons — we resolve the site origin in this order (see `src/lib/site-url.ts`): diff --git a/public/img/gpc-icon.png b/public/img/gpc-icon.png new file mode 100644 index 0000000..94881ac Binary files /dev/null and b/public/img/gpc-icon.png differ diff --git a/src/app/(perspectives)/perspectives/layout.tsx b/src/app/(perspectives)/perspectives/layout.tsx index 2fab56c..b6e8180 100644 --- a/src/app/(perspectives)/perspectives/layout.tsx +++ b/src/app/(perspectives)/perspectives/layout.tsx @@ -3,6 +3,7 @@ import Image from "next/image"; import Link from "next/link"; import { COPYRIGHT_LINE, LEGAL_LINKS } from "@/lib/legal-links"; +import { YourPrivacyChoicesLink } from "@/components/your-privacy-choices-link"; export default function PerspectivesLayout({ children, @@ -63,6 +64,7 @@ export default function PerspectivesLayout({ {link.label} ))} + diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 062165b..178662f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,7 @@ import type { Metadata, Viewport } from "next"; import { Analytics } from "@vercel/analytics/react"; import { resolveSiteUrl } from "@/lib/site-url"; +import { ConsentTags } from "@/components/consent-tags"; export const metadata: Metadata = { metadataBase: new URL(resolveSiteUrl()), @@ -55,6 +56,7 @@ export default function RootLayout({ children }: { children: ReactNode }) { return ( + {renderVercelAnalytics ? : null} {children} diff --git a/src/components/consent-tags.tsx b/src/components/consent-tags.tsx new file mode 100644 index 0000000..8197907 --- /dev/null +++ b/src/components/consent-tags.tsx @@ -0,0 +1,109 @@ +import type { ReactNode } from "react"; + +import { + GTM_CONTAINER_ID, + ONETRUST_DOMAIN_SCRIPT_ID, + resolveOneTrustEnv, +} from "@/lib/onetrust"; + +const GTM_HEAD_SNIPPET = + "(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':" + + "new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0]," + + "j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=" + + "'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);" + + `})(window,document,'script','dataLayer','${GTM_CONTAINER_ID}');`; + +// On www.databricks.com a consent change takes effect on the next page load; +// this SPA has none, so reload when consent actually changes. The databricks +// onetrust.js defines OptanonWrapper (OneTrust's init + consent-change +// callback) and deletes opted-out cookies inside it, so chain it — never +// replace it — and let it run first. Reloading is safe in both directions: +// the post-reload OptanonWrapper init re-runs the cookie deletion. The +// OnetrustActiveGroups snapshot guard keeps SDK-internal re-fires (geo +// resolution, implied-consent recording) from reloading without a real +// user-driven change. +const CONSENT_CHANGE_SNIPPET = + "(function(){" + + "var previous=window.OptanonWrapper;" + + "var initialGroups=null;" + + "var registered=false;" + + "window.OptanonWrapper=function(){" + + "if(typeof previous==='function')previous();" + + "if(initialGroups===null)initialGroups=window.OnetrustActiveGroups||'';" + + "if(registered||!window.OneTrust||typeof window.OneTrust.OnConsentChanged!=='function')return;" + + "registered=true;" + + "window.OneTrust.OnConsentChanged(function(){" + + "if((window.OnetrustActiveGroups||'')!==initialGroups)window.location.reload();" + + "});" + + "};" + + "})();"; + +/** + * OneTrust cookie consent + Google Tag Manager, copied from the + * www.databricks.com install. Rendered as the first children of as + * plain blocking tags — not next/script — because the DOM order is the legal + * contract: the OneTrust AutoBlocker must execute before the GTM snippet so + * it can gate the cookies GTM drops, and the