diff --git a/.env.example b/.env.example index c15a6df4..b3017ece 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,25 @@ CMS_API_TOKEN=your_strapi_api_token # Generate with: openssl rand -hex 32 ALTCHA_HMAC_SECRET=your_altcha_hmac_secret +################################## +# ******* Consent / c15t ******** # +################################## + +# Operating mode for the c15t consent manager. +# - "offline" (default) stores consent locally only and uses bundled offline policies +# - "hosted" persists consent records to a hosted c15t backend (consent.io) +# - "c15t" alias of "hosted" for legacy installations +NEXT_PUBLIC_C15T_MODE=offline + +# Only relevant in "hosted" mode. URL of your c15t backend (e.g. via a Next.js +# rewrite like `/api/c15t` or a direct consent.io endpoint). +NEXT_PUBLIC_C15T_BACKEND_URL=/api/c15t + +# Google Analytics 4 / gtag.js measurement ID (starts with G-, AW- or DC-). +# When unset, the gtag script is not loaded at all. When set, it is loaded +# only after the user consents to the `measurement` category. +NEXT_PUBLIC_GA_ID= + ################################## # ** Strapi CMS .env (reference) # ################################## diff --git a/jest.config.js b/jest.config.js index ade681ff..cb432c99 100644 --- a/jest.config.js +++ b/jest.config.js @@ -47,6 +47,8 @@ const customJestConfig = { '^@/(.*)$': '/src/$1', '^~/(.*)$': '/public/$1', '^.+\\.(svg)$': '/src/__mocks__/svg.tsx', + '^@c15t/nextjs$': '/src/__mocks__/c15t-nextjs.tsx', + '^@c15t/scripts/.*$': '/src/__mocks__/c15t-scripts.ts', }, }; diff --git a/package.json b/package.json index d5e3e63c..051da2a2 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,8 @@ ] }, "dependencies": { + "@c15t/nextjs": "2.0.0", + "@c15t/scripts": "2.0.0", "@hookform/resolvers": "5.2.2", "@react-email/components": "1.0.12", "@strapi/blocks-react-renderer": "1.0.2", @@ -90,6 +92,7 @@ "jest-environment-jsdom": "30.3.0", "lint-staged": "16.4.0", "postcss": "8.5.10", + "postcss-import": "16.1.1", "prettier": "3.8.3", "prettier-plugin-tailwindcss": "0.7.2", "tailwindcss": "3.4.19", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 644a017f..8b6274bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,12 @@ importers: .: dependencies: + '@c15t/nextjs': + specifier: 2.0.0 + version: 2.0.0(@types/react@19.2.14)(next@16.2.4(@babel/core@7.27.4)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3) + '@c15t/scripts': + specifier: 2.0.0 + version: 2.0.0 '@hookform/resolvers': specifier: 5.2.2 version: 5.2.2(react-hook-form@7.72.1(react@19.2.5)) @@ -148,6 +154,9 @@ importers: postcss: specifier: 8.5.10 version: 8.5.10 + postcss-import: + specifier: 16.1.1 + version: 16.1.1(postcss@8.5.10) prettier: specifier: 3.8.3 version: 3.8.3 @@ -345,6 +354,31 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@c15t/nextjs@2.0.0': + resolution: {integrity: sha512-WIhf9tDdmTqCmNFtHALo/dn6c2rbWANlROjnKeET45gcuACJOeaCpJhs9f0zv2meX6LV8N+85qcgVB/HW5SCGQ==} + peerDependencies: + next: ^16.0.0 || ^15.0.0 || ^14.0.0 || ^13.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + '@c15t/react@2.0.0': + resolution: {integrity: sha512-MCWp8mhAyjJJo64x5E37sQLe6n3eZEhwCNnSunAL8IJuB3lQeLIbE62NFiLZfIS21NpLJaSUg+fkCwL5P70WWw==} + peerDependencies: + react: ^19.0.0 || ^19.0.0-rc || ^18.0.0 || ^17.0.0 || ^16.8.0 + react-dom: ^19.0.0 || ^19.0.0-rc || ^18.0.0 || ^17.0.0 || ^16.8.0 + + '@c15t/schema@2.0.0': + resolution: {integrity: sha512-NmNH+pkK/yq7Sknrpdi4zGAjDkwd/bu4q3rtzjlJdl0zdxUfNhJnlOOwQ6gHSwchYI4jiphL0IsEBEYVwqjLxw==} + + '@c15t/scripts@2.0.0': + resolution: {integrity: sha512-b5GJaqSh22gRxAswl4kv2DGXocKqvayAPZ5L2OWSRc+d/H1Q1EdB4+xyDZ/bL33rB+CetUc3UNVbIUlw+jdLcg==} + + '@c15t/translations@2.0.0': + resolution: {integrity: sha512-FLMx8QEvZdkBGdFk+yiPuLUry4NVZjarNbBjISnNQsPzfmtjWOmtY/k5+J1jxGCyWPP8GN7KtqxJw+lU5vcOjw==} + + '@c15t/ui@2.0.0': + resolution: {integrity: sha512-4Ym5QL9aa4ccfQv7f2qpVO6AwXTtnNCQkqqxTFxLpSPKEvPFFpWDkVXCBYz75qApn2Y0mvIjdw/VJQmj4OsPaQ==} + '@commitlint/cli@20.5.0': resolution: {integrity: sha512-yNkyN/tuKTJS3wdVfsZ2tXDM4G4Gi7z+jW54Cki8N8tZqwKBltbIvUUrSbT4hz1bhW/h0CdR+5sCSpXD+wMKaQ==} engines: {node: '>=v18'} @@ -1618,6 +1652,9 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + c15t@2.0.0: + resolution: {integrity: sha512-ApF6BO7BQ5nsn2pq43Rjg///iph5hv2fVPcAjSJYhLC18bMYigs8Pg++jTfP4vZmmqK68zexFGO5ScgeoLxxhg==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -2536,10 +2573,6 @@ packages: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} - is-core-module@2.15.1: - resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} - engines: {node: '>= 0.4'} - is-core-module@2.16.1: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} @@ -3327,6 +3360,12 @@ packages: peerDependencies: postcss: ^8.0.0 + postcss-import@16.1.1: + resolution: {integrity: sha512-2xVS1NCZAfjtVdvXiyegxzJ447GyqCeEI5V7ApgQVOWnros1p5lGNovJNapwPpMombyFBfqDwt7AD3n2l0KOfQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + postcss: ^8.0.0 + postcss-js@4.0.1: resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} engines: {node: ^12 || ^14 || >= 16} @@ -3968,6 +4007,14 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + valibot@1.3.1: + resolution: {integrity: sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -4099,6 +4146,24 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zustand@5.0.12: + resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': 19.2.14 + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@adobe/css-tools@4.4.3': {} @@ -4309,6 +4374,53 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@c15t/nextjs@2.0.0(@types/react@19.2.14)(next@16.2.4(@babel/core@7.27.4)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3)': + dependencies: + '@c15t/react': 2.0.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3) + '@c15t/translations': 2.0.0 + c15t: 2.0.0(@types/react@19.2.14)(react@19.2.5)(typescript@5.9.3) + next: 16.2.4(@babel/core@7.27.4)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + transitivePeerDependencies: + - '@types/react' + - immer + - typescript + - use-sync-external-store + + '@c15t/react@2.0.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3)': + dependencies: + '@c15t/ui': 2.0.0(@types/react@19.2.14)(react@19.2.5)(typescript@5.9.3) + c15t: 2.0.0(@types/react@19.2.14)(react@19.2.5)(typescript@5.9.3) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + transitivePeerDependencies: + - '@types/react' + - immer + - typescript + - use-sync-external-store + + '@c15t/schema@2.0.0(typescript@5.9.3)': + dependencies: + valibot: 1.3.1(typescript@5.9.3) + transitivePeerDependencies: + - typescript + + '@c15t/scripts@2.0.0': {} + + '@c15t/translations@2.0.0': {} + + '@c15t/ui@2.0.0(@types/react@19.2.14)(react@19.2.5)(typescript@5.9.3)': + dependencies: + '@c15t/translations': 2.0.0 + c15t: 2.0.0(@types/react@19.2.14)(react@19.2.5)(typescript@5.9.3) + transitivePeerDependencies: + - '@types/react' + - immer + - react + - typescript + - use-sync-external-store + '@commitlint/cli@20.5.0(@types/node@24.12.2)(conventional-commits-parser@6.3.0)(typescript@5.9.3)': dependencies: '@commitlint/format': 20.5.0 @@ -5651,6 +5763,18 @@ snapshots: buffer-from@1.1.2: {} + c15t@2.0.0(@types/react@19.2.14)(react@19.2.5)(typescript@5.9.3): + dependencies: + '@c15t/schema': 2.0.0(typescript@5.9.3) + '@c15t/translations': 2.0.0 + zustand: 5.0.12(@types/react@19.2.14)(react@19.2.5) + transitivePeerDependencies: + - '@types/react' + - immer + - react + - typescript + - use-sync-external-store + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -6761,10 +6885,6 @@ snapshots: is-callable@1.2.7: {} - is-core-module@2.15.1: - dependencies: - hasown: 2.0.2 - is-core-module@2.16.1: dependencies: hasown: 2.0.2 @@ -7749,6 +7869,13 @@ snapshots: read-cache: 1.0.0 resolve: 1.22.8 + postcss-import@16.1.1(postcss@8.5.10): + dependencies: + postcss: 8.5.10 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.8 + postcss-js@4.0.1(postcss@8.5.10): dependencies: camelcase-css: 2.0.1 @@ -7918,7 +8045,7 @@ snapshots: resolve@1.22.8: dependencies: - is-core-module: 2.15.1 + is-core-module: 2.16.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 @@ -8485,6 +8612,10 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + valibot@1.3.1(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 @@ -8640,3 +8771,8 @@ snapshots: zod: 4.3.6 zod@4.3.6: {} + + zustand@5.0.12(@types/react@19.2.14)(react@19.2.5): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.5 diff --git a/postcss.config.js b/postcss.config.js index 12a703d9..fa35cd61 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -1,5 +1,6 @@ module.exports = { plugins: { + 'postcss-import': {}, tailwindcss: {}, autoprefixer: {}, }, diff --git a/src/__mocks__/c15t-nextjs.tsx b/src/__mocks__/c15t-nextjs.tsx new file mode 100644 index 00000000..651fd445 --- /dev/null +++ b/src/__mocks__/c15t-nextjs.tsx @@ -0,0 +1,27 @@ +/** + * Jest mock for `@c15t/nextjs`. + * + * The real package pulls in Next.js server-only modules (via + * `next/cache`) which are not available inside `jest-environment-jsdom`. + * This mock provides the minimal surface used by our own components. + */ +import type { ReactNode } from 'react'; + +export const ConsentManagerProvider = ({ + children, +}: { + children: ReactNode; +}) => <>{children}; + +export const ConsentBanner = () => null; +export const ConsentDialog = () => null; +export const ConsentWidget = () => null; + +export const useConsentManager = () => ({ + setActiveUI: () => undefined, + saveConsents: () => undefined, + resetConsents: () => undefined, + gdprConsents: {}, +}); + +export type Theme = Record; diff --git a/src/__mocks__/c15t-scripts.ts b/src/__mocks__/c15t-scripts.ts new file mode 100644 index 00000000..99e43672 --- /dev/null +++ b/src/__mocks__/c15t-scripts.ts @@ -0,0 +1,25 @@ +/** + * Jest mock for `@c15t/scripts/*` helper packages (e.g. `google-tag`). + * + * Returns a plain `Script`-shaped object so that any consumer calling + * `gtag(...)` or similar factory keeps working in the test environment. + */ +export const gtag = (options: { id: string; category: string }) => ({ + id: `gtag-${options.id}`, + category: options.category, + src: `https://www.googletagmanager.com/gtag/js?id=${options.id}`, +}); + +const scriptFactory = (name: string) => () => ({ + id: name, + category: 'measurement', +}); + +export const metaPixel = scriptFactory('meta-pixel'); +export const googleTagManager = scriptFactory('google-tag-manager'); +export const linkedinInsights = scriptFactory('linkedin-insights'); +export const microsoftUet = scriptFactory('microsoft-uet'); +export const tiktokPixel = scriptFactory('tiktok-pixel'); +export const xPixel = scriptFactory('x-pixel'); +export const posthog = scriptFactory('posthog'); +export const databuddy = scriptFactory('databuddy'); diff --git a/src/app/cookies/page.tsx b/src/app/cookies/page.tsx new file mode 100644 index 00000000..7984b5c8 --- /dev/null +++ b/src/app/cookies/page.tsx @@ -0,0 +1,13 @@ +import { Metadata } from 'next'; + +import CookiePolicy from '@/components/templates/CookiePolicy'; + +export const metadata: Metadata = { + title: 'Cookie Policy, Project Sentiment (dot) org', + description: + 'Cookie policy and transparent consent control center for the SENTIMENT research project. Part of the German government\'s research framework program on IT security "Digital. Secure. Sovereign".', +}; + +export default function CookiesPage() { + return ; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 067ae86b..f56d15dc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import '../styles/globals.css'; import { CircularStd } from '@/lib/fonts'; +import { ConsentManager } from '@/components/helpers/ConsentManager'; import { ThemeProvider } from '@/components/helpers/ThemeProvider'; import Footer from '@/components/layout/Footer'; import Header from '@/components/layout/Header'; @@ -19,17 +20,19 @@ export default function RootLayout({ - - Skip to main content - -
-
{children}
- -