feat: add cookie consent banner for GDPR compliance
All checks were successful
Build & Push / build-and-push (push) Successful in 2m56s

Slides up on first visit, remembers choice in localStorage.
Accept → grants analytics_storage, Decline → keeps denied.
Returning visitors get their previous choice restored silently.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 21:11:47 -04:00
parent 5710d27663
commit 1b09059467
3 changed files with 86 additions and 0 deletions

View File

@@ -7,6 +7,7 @@ import { routing } from '@/i18n/routing'
import Nav from '@/components/layout/Nav' import Nav from '@/components/layout/Nav'
import Footer from '@/components/layout/Footer' import Footer from '@/components/layout/Footer'
import GoogleAnalytics from '@/components/analytics/GoogleAnalytics' import GoogleAnalytics from '@/components/analytics/GoogleAnalytics'
import CookieConsent from '@/components/analytics/CookieConsent'
import '@/styles/globals.css' import '@/styles/globals.css'
const BASE_URL = 'https://letsbe.biz' const BASE_URL = 'https://letsbe.biz'
@@ -107,6 +108,7 @@ export default async function LocaleLayout({ children, params }: Props) {
<Nav /> <Nav />
{children} {children}
<Footer /> <Footer />
<CookieConsent />
</NextIntlClientProvider> </NextIntlClientProvider>
</body> </body>
</html> </html>

View File

@@ -0,0 +1,79 @@
'use client'
import { useState, useEffect } from 'react'
const CONSENT_KEY = 'cookie_consent'
type ConsentState = 'granted' | 'denied'
function updateConsent(state: ConsentState) {
if (typeof window !== 'undefined' && typeof window.gtag === 'function') {
window.gtag('consent', 'update', {
analytics_storage: state,
})
}
}
export default function CookieConsent() {
const [visible, setVisible] = useState(false)
useEffect(() => {
const stored = localStorage.getItem(CONSENT_KEY)
if (stored === 'granted' || stored === 'denied') {
// Restore previous choice
updateConsent(stored)
} else {
// No choice yet — show banner
setVisible(true)
}
}, [])
const handleAccept = () => {
localStorage.setItem(CONSENT_KEY, 'granted')
updateConsent('granted')
setVisible(false)
}
const handleDecline = () => {
localStorage.setItem(CONSENT_KEY, 'denied')
updateConsent('denied')
setVisible(false)
}
if (!visible) return null
return (
<div
role="dialog"
aria-label="Cookie consent"
style={{ animation: 'cookie-slide-up 0.5s ease-out' }}
className="fixed bottom-0 left-0 right-0 z-50 p-4 sm:p-6"
>
<div className="max-w-xl mx-auto rounded-2xl bg-surface-high border border-outline-variant/20 shadow-[0_-4px_32px_rgba(25,28,29,0.12)] p-5 sm:p-6">
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<p className="text-sm text-outline leading-relaxed flex-1">
We use cookies to understand how visitors use our site.
No personal data is shared with third parties.
</p>
<div className="flex items-center gap-2.5 flex-shrink-0">
<button
type="button"
onClick={handleDecline}
className="px-4 py-2 rounded-xl text-sm font-medium text-outline transition-colors hover:bg-on-surface/5 cursor-pointer"
>
Decline
</button>
<button
type="button"
onClick={handleAccept}
className="px-5 py-2 rounded-xl text-sm font-medium text-white transition-all hover:-translate-y-px active:translate-y-0 cursor-pointer shadow-[0_4px_16px_rgba(0,100,148,0.25)]"
style={{ background: 'linear-gradient(135deg, #006494, #5BA4D9)' }}
>
Accept
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -91,6 +91,11 @@
87.5% { transform: translate(-120px, 120px); } 87.5% { transform: translate(-120px, 120px); }
100% { transform: translate(0px, 170px); } 100% { transform: translate(0px, 170px); }
} }
@keyframes cookie-slide-up {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes hero-orbit-b { @keyframes hero-orbit-b {
0% { transform: translate(0px, -130px); } 0% { transform: translate(0px, -130px); }
12.5% { transform: translate(-90px, -90px); } 12.5% { transform: translate(-90px, -90px); }