From 8d5d6a4927f7375b8d24b294e0659021df5e19d2 Mon Sep 17 00:00:00 2001 From: Tony Dang Date: Sat, 18 Jul 2026 16:52:59 -0700 Subject: [PATCH 1/2] feat: add OneTrust cookie consent, GTM, and privacy choices footer link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native Next.js implementation: ConsentTags renders the OneTrust tags and the GTM container as plain blocking tags at the top of — the DOM order is the legal contract (AutoBlocker before GTM so non-consented cookies are gated) — gated by resolveOneTrustEnv: production variant on production deploys, test variant on previews, nothing locally unless ONETRUST_ENV is set. The CCPA "Your Privacy Choices" link opens the OneTrust preference center from both the main and perspectives footers. Co-authored-by: Isaac --- CONTRIBUTING.md | 4 + public/img/gpc-icon.png | Bin 0 -> 1825 bytes .../(perspectives)/perspectives/layout.tsx | 2 + src/app/layout.tsx | 2 + src/components/consent-tags.tsx | 77 ++++++++++++++++++ src/components/footer.tsx | 2 + src/components/your-privacy-choices-link.tsx | 40 +++++++++ src/lib/onetrust.ts | 33 ++++++++ tests/broken-links.test.ts | 6 +- tests/e2e/navigation.spec.ts | 1 + tests/e2e/pages.spec.ts | 4 +- tests/onetrust.test.ts | 35 ++++++++ 12 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 public/img/gpc-icon.png create mode 100644 src/components/consent-tags.tsx create mode 100644 src/components/your-privacy-choices-link.tsx create mode 100644 src/lib/onetrust.ts create mode 100644 tests/onetrust.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4e04df0..eabbea7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,6 +56,10 @@ 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). + ### 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 0000000000000000000000000000000000000000..94881acdc5ad49acf2313afb4f684561cd64bb88 GIT binary patch literal 1825 zcmWkvc{CMx6#k(iB~7%9r9{h91|><0u{_J0khhQsk5|tg30a00VY2g7k||{?Q;fad z2t|=LGNDv&bHDT5?~i-VjkmEjmyuSGCWOe)ET{}o-7ks_QWBy( zyIt=fA>#HnmMl|IOSo9*1@#at@`QOTOyW@93Av+?@PKjv40zDv;Y;HkRu>e^a`=m3xp2h z4dM*3!yq&ebCCTI`x#^(h-hjH!o(afFJgAUr>`)L0x`qP90sP)F@~x(T&#mb9j45H;N?!QHSH|}-e z&vq0%LEe3wuR%mPV#^Rw2*I#Wk%8J<_AKG}NY9HGZLJR<2nusEqWs-C zY-d~3E$SPU735{-KF>~%HaFh8TXysM`3!zaOcc#x?=HQahK8!qge=%WqZ+Y7x|2JC zc(2XYNKTD6*l@XPbq4b)l!|g$tFl&XKGv?S*A}PXbNhCQ@R9J5Hn-<>cME^7fcbkQ z%38ne{kivqirOtTcApL19a>_M?=yMFK`^;*sQOx( zTxjHwNz29zt$-t*2|HNgp2|qP%9Y_%r1|dK zYLU~Z^ejMHS25|;zm$Fxt275!&H0Nt^8XD}nj+nICuz8i`xbxuVSDX{X<}DigS%8yJqe5-(9MmJzpBXJU(-wb(?J60zX^cr>KJL_;jgnZZu|SS_7AL;nGC3 zYgRc$=iSu^rxce3emP~S(dT9zpJ}w!FQVwLQmu8{5;95*mu&8qnf6nuoDQv<+&UGQ zz^R<&T3qjx$X5-!^r90$SRjViW2+51F7fV&VDFh9IZ(hJ2c`UKbIHz0ue0z~OBYEX) zQOPg9)|x*mNDoU{eELi5%0Q}(*DB{2=fHn=Eno49^G^BCI|me%tujSU!(qWolxddes#onhIf5Re~K|f_fX;U zl8?7@1$skMPEDH9_x-X~yUvsevSo&T?>}O9M7dkOpC@+E-X_WFhC#LT;V}JcMw8Wo zNi~fJDZ2d9#fzCK4>wf~9*TTN{pCWg^wmgz#dh)8&^tj&#KGhnS+>?orM9}IMeW0z zWijO;5o-=RtlKWDWs-2*z%t9Lsv@4UexjK*y2Z2NnzjFAu-!nGoWNzVro_GHX8z;! z2v)R*rqJekXXLx2poWF7de}JzIY)*yG6jqMZ^W>AN3GSwrw2D$NjXaRytz$vtRHCp znxI7WNla`f`5$VHHa6}`sdVpR(x3e%Y_0O8^!w7%zOe0%>gBS+@_SFF6&o_!zy5Hk zB=PCFjm2guZy)c<39M4QbEzwKhGy?lt+-L>@POX*q)&Z{!Rf1{tKRZj9ncb5VSRNF zhsWOg>NDsQoq?))E39=kr>FNmGAgPrIb*u!az@9z%f!j`YP}OiFIOjP_9lGu<6K>y zShQ{T$~?aClZv!KY+=L=JypMTe)e;Z_)pv!!%wIAnVEv2jw(fi&&!T2o7m+0p+4CECpq literal 0 HcmV?d00001 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..7ec9ece --- /dev/null +++ b/src/components/consent-tags.tsx @@ -0,0 +1,77 @@ +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}');`; + +/** + * 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