diff --git a/package.json b/package.json index c4a6aa5a7..6ac14ccde 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "prepare": "husky" }, "dependencies": { + "@c15t/react": "^2.1.0", + "@c15t/scripts": "^2.1.0", "@fingerprintjs/fingerprintjs-pro-react": "^2.7.1", "@floating-ui/react": "^0.27.19", "@kapaai/react-sdk": "^0.9.10", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 95f562c0b..e3548481e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -21,6 +21,12 @@ importers: .: dependencies: + '@c15t/react': + specifier: ^2.1.0 + version: 2.1.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@6.0.2)(use-sync-external-store@1.6.0(react@19.2.3)) + '@c15t/scripts': + specifier: ^2.1.0 + version: 2.1.0 '@fingerprintjs/fingerprintjs-pro-react': specifier: ^2.7.1 version: 2.7.1 @@ -513,6 +519,24 @@ packages: '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@c15t/react@2.1.0': + resolution: {integrity: sha512-tlHPjLN8R/M2xXkjoVfoiFxAy/NALVfO+DtDVUO/KL+/fqV9j1ycL489lG7s1p2jAgYeurHEnNmeb8lgjvoIDg==} + 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.1.0': + resolution: {integrity: sha512-/Kxe4qv6E5cR6wgzxx1CTZuhMFfJQHi3GGFzcRCx4smqN8IrgsVtXv800O7Nz9RqoDu/8DFiHGpJ25aupwMsRQ==} + + '@c15t/scripts@2.1.0': + resolution: {integrity: sha512-+4yU0LSeazBaEGGpx9uwVkfoQmFscnjRpx+5Y1E9b34TmoodIGrxX6klmroy71PHt+V+CnuLPU8oCD42m45jPQ==} + + '@c15t/translations@2.1.0': + resolution: {integrity: sha512-pB6A5nMKHaAwFOUetcIkyIscMBr5iVm/lxBqzoSk3MU0KMGn4F/OTpcLZOT7k5P87RMkuuKkXN+gyf6jCuvBAw==} + + '@c15t/ui@2.1.0': + resolution: {integrity: sha512-tl9mQnQdPbtWQQpXNFRzqrQDk4crjRUX+lIJmd7Le+uZVIuEiNbDeEIjQLj0XH0Cp3Xv3NJn0C7XF5H9CRVHKA==} + '@chevrotain/cst-dts-gen@11.1.2': resolution: {integrity: sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==} @@ -4110,6 +4134,9 @@ packages: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} + c15t@2.1.0: + resolution: {integrity: sha512-ukWy6y/yNGPrJv4bAr2DeMNkxtcsdehe++AeqzEC6ITDCVVaqCGeePIKo/3qMZzhyyCPtcKsQJKNIfqszyzpvQ==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -7361,6 +7388,39 @@ snapshots: '@braintree/sanitize-url@7.1.2': {} + '@c15t/react@2.1.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@6.0.2)(use-sync-external-store@1.6.0(react@19.2.3))': + dependencies: + '@c15t/ui': 2.1.0(@types/react@19.2.14)(react@19.2.3)(typescript@6.0.2)(use-sync-external-store@1.6.0(react@19.2.3)) + c15t: 2.1.0(@types/react@19.2.14)(react@19.2.3)(typescript@6.0.2)(use-sync-external-store@1.6.0(react@19.2.3)) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - immer + - typescript + - use-sync-external-store + + '@c15t/schema@2.1.0(typescript@6.0.2)': + dependencies: + valibot: 1.3.1(typescript@6.0.2) + transitivePeerDependencies: + - typescript + + '@c15t/scripts@2.1.0': {} + + '@c15t/translations@2.1.0': {} + + '@c15t/ui@2.1.0(@types/react@19.2.14)(react@19.2.3)(typescript@6.0.2)(use-sync-external-store@1.6.0(react@19.2.3))': + dependencies: + '@c15t/translations': 2.1.0 + c15t: 2.1.0(@types/react@19.2.14)(react@19.2.3)(typescript@6.0.2)(use-sync-external-store@1.6.0(react@19.2.3)) + transitivePeerDependencies: + - '@types/react' + - immer + - react + - typescript + - use-sync-external-store + '@chevrotain/cst-dts-gen@11.1.2': dependencies: '@chevrotain/gast': 11.1.2 @@ -10533,6 +10593,18 @@ snapshots: bytes@3.1.2: {} + c15t@2.1.0(@types/react@19.2.14)(react@19.2.3)(typescript@6.0.2)(use-sync-external-store@1.6.0(react@19.2.3)): + dependencies: + '@c15t/schema': 2.1.0(typescript@6.0.2) + '@c15t/translations': 2.1.0 + zustand: 5.0.12(@types/react@19.2.14)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) + transitivePeerDependencies: + - '@types/react' + - immer + - react + - typescript + - use-sync-external-store + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 diff --git a/src/components/ConsentManager.tsx b/src/components/ConsentManager.tsx new file mode 100644 index 000000000..2e3d9d98d --- /dev/null +++ b/src/components/ConsentManager.tsx @@ -0,0 +1,71 @@ +import * as React from 'react' +import type { ReactNode } from 'react' +import { + ConsentBanner, + ConsentDialog, + ConsentManagerProvider, + type ConsentManagerOptions, +} from '@c15t/react' +import { gtag } from '@c15t/scripts/google-tag' + +const C15T_BACKEND_URL = 'https://eager-kayak-phobos-tanstack-com.inth.app' +const GOOGLE_ANALYTICS_ID = 'G-JMT1Z50SPS' +const LEGAL_LINKS: Array<'privacyPolicy' | 'termsOfService'> = [ + 'privacyPolicy', + 'termsOfService', +] + +const consentManagerOptions = { + mode: 'hosted', + backendURL: C15T_BACKEND_URL, + consentCategories: ['necessary', 'measurement'], + overrides: import.meta.env.DEV ? { country: 'DE' } : undefined, + legalLinks: { + privacyPolicy: { + href: '/privacy', + target: '_self', + }, + termsOfService: { + href: '/terms', + target: '_self', + }, + }, + scripts: [ + gtag({ + id: GOOGLE_ANALYTICS_ID, + category: 'measurement', + }), + ], +} satisfies ConsentManagerOptions + +export function ConsentManager({ + children, + showControls = true, +}: { + children: ReactNode + showControls?: boolean +}) { + const [hasMounted, setHasMounted] = React.useState(false) + + React.useEffect(() => { + setHasMounted(true) + }, []) + + // c15t injects runtime theme styles; mount after hydration so Start's SSR + // markup stays byte-stable. + if (!hasMounted) { + return <>{children} + } + + return ( + + {showControls ? ( + <> + + + + ) : null} + {children} + + ) +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 0e56901e2..c315ac5d2 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,3 +1,5 @@ +import * as React from 'react' +import { ConsentDialogLink } from '@c15t/react' import { Link } from '@tanstack/react-router' import { Card } from './Card' @@ -53,6 +55,7 @@ export function Footer() { )} ))} +
© {new Date().getFullYear()} TanStack LLC @@ -60,3 +63,23 @@ export function Footer() { ) } + +function FooterConsentSettingsLink() { + const [hasMounted, setHasMounted] = React.useState(false) + + React.useEffect(() => { + setHasMounted(true) + }, []) + + if (!hasMounted) { + return null + } + + return ( +
+ + Privacy Settings + +
+ ) +} diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 348f538f5..8d080a841 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -32,6 +32,7 @@ import { DefaultCatchBoundary } from '~/components/DefaultCatchBoundary' import { SearchProvider } from '~/contexts/SearchContext' import { ToastProvider } from '~/components/ToastProvider' import { LoginModalProvider } from '~/contexts/LoginModalContext' +import { ConsentManager } from '~/components/ConsentManager' import { Spinner } from '~/components/Spinner' import { ThemeProvider, useHtmlClass } from '~/components/ThemeProvider' @@ -41,10 +42,6 @@ import { trackPageView } from '~/utils/analytics' import { createPartnerPlacementSessionSeed } from '~/utils/partner-placement' import { twMerge } from 'tailwind-merge' -const GOOGLE_ANALYTICS_ID = 'G-JMT1Z50SPS' -const GOOGLE_ANALYTICS_PROXY_PREFIX = '/_a' -const GOOGLE_ANALYTICS_SCRIPT_SRC = `${GOOGLE_ANALYTICS_PROXY_PREFIX}/gtag.js` -const GOOGLE_ANALYTICS_BOOTSTRAP = `(function(){var id='${GOOGLE_ANALYTICS_ID}';var src='${GOOGLE_ANALYTICS_SCRIPT_SRC}';window.dataLayer=window.dataLayer||[];window.gtag=window.gtag||function(){window.dataLayer.push(arguments)};window.gtag('js',new Date());window.gtag('config',id,{transport_url:window.location.origin+'${GOOGLE_ANALYTICS_PROXY_PREFIX}'});var loaded=false;var load=function(){if(loaded)return;loaded=true;var script=document.createElement('script');script.async=true;script.src=src;script.setAttribute('data-ga-loader','true');document.head.appendChild(script)};if(typeof window.requestIdleCallback==='function'){window.requestIdleCallback(load,{timeout:3000});return}if(document.readyState==='complete'){window.setTimeout(load,1500);return}window.addEventListener('load',function(){window.setTimeout(load,1500)},{once:true})})();` const DOCUMENT_CACHE_HEADERS = { 'Cache-Control': 'public, max-age=0, must-revalidate', 'Cloudflare-CDN-Cache-Control': 'no-store', @@ -223,9 +220,6 @@ export const Route = createRootRouteWithContext<{ { children: `(function(){try{var t=localStorage.getItem('theme')||'auto';var v=['light','dark','auto'].includes(t)?t:'auto';if(v==='auto'){var a=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';document.documentElement.classList.add(a,'auto')}else{document.documentElement.classList.add(v)}}catch(e){var a=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';document.documentElement.classList.add(a,'auto')}})()`, }, - { - children: GOOGLE_ANALYTICS_BOOTSTRAP, - }, ], } }, @@ -305,44 +299,46 @@ function ShellComponent({ children }: { children: React.ReactNode }) { {hasBaseParent ? : null} - - - - {hideNavbar ? children : {children}} - {showDevtools && LazyAppDevtools ? ( - - - - - - ) : null} -