feat: add GDPR cookie consent banner and upgrade-to-Pro link
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 3m8s
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 3m8s
- Cookie consent banner with accept all / essential only buttons - Uses existing i18n messages (en/fr) and localStorage for persistence - Animated with Framer Motion, respects dark/light theme - Free tier users see "Passer Pro →" link next to their badge in sidebar Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -73,15 +73,22 @@ export function DashboardSidebar() {
|
|||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
<span className="truncate text-sm font-medium leading-none text-foreground">{user.name}</span>
|
<span className="truncate text-sm font-medium leading-none text-foreground">{user.name}</span>
|
||||||
<span className="truncate text-xs leading-none text-muted-foreground">{user.email}</span>
|
<span className="truncate text-xs leading-none text-muted-foreground">{user.email}</span>
|
||||||
<Badge
|
<div className="flex items-center gap-1.5">
|
||||||
variant="secondary"
|
<Badge
|
||||||
className={cn(
|
variant="secondary"
|
||||||
'mt-0.5 w-fit text-xs',
|
className={cn(
|
||||||
user.tier !== 'free' && user.tier && 'border border-primary/20 bg-primary/10 text-primary'
|
'text-xs',
|
||||||
|
user.tier !== 'free' && user.tier && 'border border-primary/20 bg-primary/10 text-primary'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{translateTier(t, user.tier)}
|
||||||
|
</Badge>
|
||||||
|
{(!user.tier || user.tier === 'free') && (
|
||||||
|
<Link href="/pricing" className="text-xs font-medium text-primary hover:underline">
|
||||||
|
{t('dashboard.sidebar.upgradeToPro', { defaultValue: 'Passer Pro →' })}
|
||||||
|
</Link>
|
||||||
)}
|
)}
|
||||||
>
|
</div>
|
||||||
{translateTier(t, user.tier)}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { NotificationProvider } from "@/components/ui/notification";
|
|||||||
import { I18nProvider } from "@/lib/i18n";
|
import { I18nProvider } from "@/lib/i18n";
|
||||||
import { Agentation } from "agentation";
|
import { Agentation } from "agentation";
|
||||||
import { GoogleOAuthProvider } from "@react-oauth/google";
|
import { GoogleOAuthProvider } from "@react-oauth/google";
|
||||||
|
import { CookieConsent } from "@/components/ui/cookie-consent";
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ export default function RootLayout({
|
|||||||
<GoogleOAuthProvider clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ""}>
|
<GoogleOAuthProvider clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ""}>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
{children}
|
{children}
|
||||||
|
<CookieConsent />
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</GoogleOAuthProvider>
|
</GoogleOAuthProvider>
|
||||||
</QueryProvider>
|
</QueryProvider>
|
||||||
|
|||||||
65
frontend/src/components/ui/cookie-consent.tsx
Normal file
65
frontend/src/components/ui/cookie-consent.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "cookie-consent";
|
||||||
|
|
||||||
|
export function CookieConsent() {
|
||||||
|
const t = useTranslations("cookieConsent");
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisible(!localStorage.getItem(STORAGE_KEY));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function acceptAll() {
|
||||||
|
localStorage.setItem(STORAGE_KEY, "all");
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function essentialOnly() {
|
||||||
|
localStorage.setItem(STORAGE_KEY, "essential");
|
||||||
|
setVisible(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{visible && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ y: 100, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: 100, opacity: 0 }}
|
||||||
|
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||||
|
className="fixed bottom-0 inset-x-0 z-50 p-4"
|
||||||
|
>
|
||||||
|
<div className="mx-auto max-w-3xl rounded-t-2xl border border-border bg-background/95 backdrop-blur-md shadow-2xl p-6">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className="text-2xl shrink-0" aria-hidden="true">
|
||||||
|
🍪
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-foreground mb-1">
|
||||||
|
{t("title")}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
{t("description")}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mt-4">
|
||||||
|
<Button variant="outline" size="sm" onClick={essentialOnly}>
|
||||||
|
{t("essentialOnly")}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={acceptAll}>
|
||||||
|
{t("acceptAll")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user