feat: Implement cookie consent management with a new banner and introduce a language-aware root redirection.

This commit is contained in:
Van Leemput Dayron
2026-02-06 17:00:52 +01:00
parent d487f3b3a9
commit bf87b9c218
11 changed files with 549 additions and 33 deletions

View File

@@ -1,13 +1,16 @@
<!doctype html>
<html lang="en">
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/public/personnes.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>xeewy.be</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom';
import { BrowserRouter, Routes, Route, useParams } from 'react-router-dom';
import { LanguageProvider } from './contexts/LanguageContext';
import Header from './components/Header';
import Home from './components/Home';
@@ -10,19 +10,24 @@ import EraseData from './components/TravelMate/EraseData';
import Support from './components/TravelMate/Support';
import HomeSync from './components/HomeSync';
import ScrollToTop from './components/ScrollToTop';
import RootRedirect from './components/RootRedirect';
import './styles/main.scss';
import { CookieProvider, useCookie } from './contexts/CookieContext';
import CookieBanner from './components/CookieBanner/CookieBanner';
function App() {
return (
<BrowserRouter>
<CookieProvider>
<Routes>
<Route path="/" element={<Navigate to="/fr" replace />} />
<Route path="/" element={<RootRedirect />} />
<Route path="/:lang/*" element={
<LanguageProvider>
<AppContent />
</LanguageProvider>
} />
</Routes>
</CookieProvider>
</BrowserRouter>
);
}
@@ -36,6 +41,8 @@ function AppContent() {
// Optional: Check if useParamsLang.lang is valid, else redirect
}, [useParamsLang]);
const { consent } = useCookie();
useEffect(() => {
const savedTheme = localStorage.getItem('darkMode');
if (savedTheme) {
@@ -46,9 +53,12 @@ function AppContent() {
}, []);
useEffect(() => {
// Only save if preferences are allowed
if (consent.preferences || consent.essential) {
localStorage.setItem('darkMode', JSON.stringify(darkMode));
}
document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light');
}, [darkMode]);
}, [darkMode, consent]);
const toggleDarkMode = () => {
setDarkMode(!darkMode);
@@ -71,6 +81,7 @@ function AppContent() {
</Routes>
</main>
<Footer />
<CookieBanner />
</div>
</>
);

View File

@@ -81,15 +81,15 @@ const Contact = () => {
{
icon: <Mail size={24} />,
title: "Email",
content: "dayronvanleemput@gmail.com",
link: "mailto:dayronvanleemput@gmail.com",
content: "contact@xeewy.be",
link: "mailto:contact@xeewy.be",
color: "#EA4335"
},
{
icon: <Phone size={24} />,
title: "Téléphone",
content: "+32 455 19 47 62",
link: "tel:+32455194762",
content: "+32 455 19 47 63",
link: "tel:+32455194763",
color: "#34A853"
},
{

View File

@@ -0,0 +1,157 @@
import { useState } from 'react';
import { useCookie } from '../../contexts/CookieContext';
import { useLanguage } from '../../contexts/LanguageContext';
import './cookie-banner.scss';
import { X } from 'lucide-react';
const CookieBanner = () => {
const { showBanner, acceptAll, rejectAll, updateConsent, consent } = useCookie();
const [showSettings, setShowSettings] = useState(false);
const [tempConsent, setTempConsent] = useState(consent);
if (!showBanner) return null;
// Simple translations for the banner since they are specific to this component
// In a larger app, these would go into the main translation file.
const content = {
fr: {
title: 'Gestion des cookies 🍪',
text: 'Nous utilisons des cookies pour améliorer votre expérience et enregistrer vos préférences.',
accept: 'Tout accepter',
reject: 'Refuser',
settings: 'Personnaliser',
save: 'Enregistrer',
essential: 'Essentiels',
essentialDesc: 'Requis pour le fonctionnement (ex: langue)',
preferences: 'Préférences',
preferencesDesc: 'Thème (Dark/Light)',
analytics: 'Analytiques',
analyticsDesc: 'Statistiques anonymes',
selectAll: 'Tout cocher',
},
en: {
title: 'Cookie Management 🍪',
text: 'We use cookies to improve your experience and save your preferences.',
accept: 'Accept All',
reject: 'Reject',
settings: 'Customize',
save: 'Save Preferences',
essential: 'Essential',
essentialDesc: 'Required for core functionality (e.g. language)',
preferences: 'Preferences',
preferencesDesc: 'Theme (Dark/Light)',
analytics: 'Analytics',
analyticsDesc: 'Anonymous statistics',
selectAll: 'Select All',
}
};
// Detect language from context or fallback to fr
// Note: We might be inside the LanguageProvider, so we can use `useLanguage`?
// Yes, App.tsx structure shows LanguageProvider wraps AppContent.
// But wait, the banner might need to be shown BEFORE language is fully settled?
// Actually, LanguageContext logic defaults to 'fr'. So we are safe.
const { language } = useLanguage();
const txt = content[language] || content.fr;
const handleSaveSettings = () => {
updateConsent(tempConsent);
};
const togglePreference = (key: keyof typeof tempConsent) => {
setTempConsent(prev => ({ ...prev, [key]: !prev[key] }));
};
const handleSelectAll = () => {
setTempConsent(prev => ({
...prev,
preferences: true,
analytics: true
}));
};
return (
<div className="cookie-banner">
{!showSettings ? (
<div className="cookie-banner__content">
<div className="cookie-banner__text">
<h3>{txt.title}</h3>
<p>{txt.text}</p>
</div>
<div className="cookie-banner__actions">
<button className="cookie-banner__button cookie-banner__button--secondary" onClick={() => setShowSettings(true)}>
{txt.settings}
</button>
<button className="cookie-banner__button cookie-banner__button--secondary" onClick={rejectAll}>
{txt.reject}
</button>
<button className="cookie-banner__button cookie-banner__button--primary" onClick={acceptAll}>
{txt.accept}
</button>
</div>
</div>
) : (
<div className="cookie-banner__settings-view">
<div className="cookie-banner__content">
<div className="cookie-banner__text">
<h3>{txt.settings}</h3>
</div>
<button className="cookie-banner__button--text" onClick={() => setShowSettings(false)}>
<X size={20} />
</button>
</div>
<div className="cookie-banner__settings">
<div className="cookie-banner__header-actions" style={{ marginBottom: '1rem', display: 'flex', justifyContent: 'flex-end' }}>
<button className="cookie-banner__button--link" onClick={handleSelectAll} style={{ background: 'none', border: 'none', color: '#007bff', cursor: 'pointer', textDecoration: 'underline', fontSize: '0.9rem' }}>
{txt.selectAll}
</button>
</div>
<div className="cookie-banner__option">
<label>
<strong>{txt.essential}</strong>
<small>{txt.essentialDesc}</small>
</label>
<input type="checkbox" checked disabled />
</div>
<div className="cookie-banner__option">
<label htmlFor="pref-check">
<strong>{txt.preferences}</strong>
<small>{txt.preferencesDesc}</small>
</label>
<input
id="pref-check"
type="checkbox"
checked={tempConsent.preferences}
onChange={() => togglePreference('preferences')}
/>
</div>
<div className="cookie-banner__option">
<label htmlFor="analytics-check">
<strong>{txt.analytics}</strong>
<small>{txt.analyticsDesc}</small>
</label>
<input
id="analytics-check"
type="checkbox"
checked={tempConsent.analytics}
onChange={() => togglePreference('analytics')}
/>
</div>
</div>
<div className="cookie-banner__actions" style={{ marginTop: '1rem' }}>
<button className="cookie-banner__button cookie-banner__button--primary" onClick={handleSaveSettings}>
{txt.save}
</button>
</div>
</div>
)}
</div>
);
};
export default CookieBanner;

View File

@@ -0,0 +1,157 @@
@use '../../styles/variables' as *;
@use '../../styles/mixins' as *;
.cookie-banner {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
width: 90%;
max-width: 600px;
background: var(--surface-light);
backdrop-filter: blur(10px);
border: 1px solid var(--border-light);
border-radius: 1rem;
padding: 1.5rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 1000;
animation: slideUp 0.5s ease-out;
/* Theme variables handling */
:global([data-theme='dark']) & {
background: rgba(30, 30, 30, 0.9);
border-color: rgba(255, 255, 255, 0.1);
color: #f1f1f1;
}
&__content {
display: flex;
flex-direction: column;
gap: 1.5rem;
@media (min-width: 768px) {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
&__text {
flex: 1;
h3 {
font-size: 1.1rem;
margin-bottom: 0.5rem;
font-weight: 600;
}
p {
font-size: 0.9rem;
line-height: 1.4;
opacity: 0.9;
margin: 0;
}
}
&__actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
justify-content: flex-end;
@media (max-width: 768px) {
width: 100%;
justify-content: stretch;
button {
flex: 1;
}
}
}
&__button {
padding: 0.6rem 1.2rem;
border-radius: 0.5rem;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
&--primary {
background: var(--primary);
color: white;
&:hover {
opacity: 0.9;
transform: translateY(-1px);
}
}
&--secondary {
background: transparent;
border: 1px solid currentColor;
color: inherit;
&:hover {
background: rgba(128, 128, 128, 0.1);
}
}
&--text {
background: transparent;
color: inherit;
text-decoration: underline;
padding: 0.6rem 0.5rem;
&:hover {
opacity: 0.8;
}
}
}
/* Settings Modal/Panel styles could go here if managed within the same CSS,
but keeping it simple for now */
&__settings {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--border-light);
display: flex;
flex-direction: column;
gap: 0.8rem;
:global([data-theme='dark']) & {
border-color: rgba(255, 255, 255, 0.1);
}
}
&__option {
display: flex;
align-items: center;
justify-content: space-between;
label {
font-size: 0.9rem;
cursor: pointer;
display: flex;
flex-direction: column;
small {
font-size: 0.75rem;
opacity: 0.7;
}
}
}
}
@keyframes slideUp {
from {
transform: translate(-50%, 100%);
opacity: 0;
}
to {
transform: translate(-50%, 0);
opacity: 1;
}
}

View File

@@ -249,7 +249,7 @@ const Education = () => {
<div className="goals-grid">
{[
{ goal: t('education.goal1'), progress: 60 },
{ goal: t('education.goal2'), progress: 30 },
{ goal: t('education.goal2'), progress: 25 },
{ goal: t('education.goal3'), progress: 20 },
{ goal: t('education.goal4'), progress: 45 }
].map((item, index) => (

View File

@@ -0,0 +1,14 @@
import { Navigate } from 'react-router-dom';
const RootRedirect = () => {
// Check for saved language preference
const savedLang = localStorage.getItem('preferred_language');
// Default to French if no preference or invalid
const targetLang = (savedLang === 'en' || savedLang === 'fr') ? savedLang : 'fr';
// We use Navigate to redirect
return <Navigate to={`/${targetLang}`} replace />;
};
export default RootRedirect;

View File

@@ -2,7 +2,7 @@ import { useEffect } from 'react';
import { motion } from 'framer-motion';
import { useLanguage } from '../contexts/LanguageContext';
import { Link } from 'react-router-dom';
import { Shield, Smartphone, Map, DollarSign, Users, Globe, Code, ArrowLeft } from 'lucide-react';
import { Shield, Smartphone, Map, DollarSign, Users, Globe, Code, ArrowLeft, Mail } from 'lucide-react';
import appIcon from '../assets/app_icon.png';
const itemVariants = {
@@ -326,6 +326,48 @@ const TravelMate = () => {
</Link>
</div>
</motion.div>
{/* Contact Section */}
<motion.div variants={itemVariants} style={{ marginBottom: '6rem', textAlign: 'center', maxWidth: '800px', margin: '0 auto 6rem auto' }}>
<h2 style={{
fontSize: '2.5rem',
fontWeight: '700',
color: 'var(--text-color)',
marginBottom: '1.5rem'
}}>
{t('travelmate.contact.title')}
</h2>
<p style={{
fontSize: '1.2rem',
opacity: 0.8,
marginBottom: '2rem',
lineHeight: '1.6'
}}>
{t('travelmate.contact.subtitle')}
</p>
<motion.a
href="mailto:contact.travelmate@xeewy.be"
className="btn"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '12px',
background: 'var(--card-bg, rgba(255, 255, 255, 0.05))',
border: '1px solid var(--border-color, rgba(255, 255, 255, 0.1))',
padding: '1rem 2rem',
borderRadius: '50px',
textDecoration: 'none',
color: 'var(--text-color)',
fontSize: '1.1rem',
fontWeight: '500'
}}
whileHover={{ scale: 1.05, background: 'var(--primary-color)', color: '#ffffff', borderColor: 'var(--primary-color)' }}
whileTap={{ scale: 0.95 }}
>
<Mail size={20} />
<span>contact.travelmate@xeewy.be</span>
</motion.a>
</motion.div>
</motion.div>

View File

@@ -0,0 +1,101 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
export interface CookieConsent {
essential: boolean; // Always true
preferences: boolean; // Language, Theme
analytics: boolean; // Optional future use
}
interface CookieContextType {
consent: CookieConsent;
updateConsent: (newConsent: CookieConsent) => void;
acceptAll: () => void;
rejectAll: () => void;
hasInteracted: boolean; // True if user has made a choice
showBanner: boolean;
closeBanner: () => void;
}
const defaultConsent: CookieConsent = {
essential: true,
preferences: false,
analytics: false,
};
const CookieContext = createContext<CookieContextType | undefined>(undefined);
export const CookieProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [consent, setConsent] = useState<CookieConsent>(defaultConsent);
const [hasInteracted, setHasInteracted] = useState(false);
const [showBanner, setShowBanner] = useState(false);
useEffect(() => {
const savedConsent = localStorage.getItem('cookie_consent');
if (savedConsent) {
try {
const parsed = JSON.parse(savedConsent);
setConsent({ ...defaultConsent, ...parsed, essential: true });
setHasInteracted(true);
setShowBanner(false);
} catch (e) {
// Invalid json, treat as no consent
setShowBanner(true);
}
} else {
setShowBanner(true);
}
}, []);
const saveConsent = (newConsent: CookieConsent) => {
localStorage.setItem('cookie_consent', JSON.stringify(newConsent));
setConsent(newConsent);
setHasInteracted(true);
setShowBanner(false);
};
const updateConsent = (newConsent: CookieConsent) => {
saveConsent({ ...newConsent, essential: true });
};
const acceptAll = () => {
saveConsent({
essential: true,
preferences: true,
analytics: true,
});
};
const rejectAll = () => {
saveConsent({
essential: true,
preferences: false,
analytics: false,
});
};
const closeBanner = () => {
setShowBanner(false);
};
return (
<CookieContext.Provider value={{
consent,
updateConsent,
acceptAll,
rejectAll,
hasInteracted,
showBanner,
closeBanner
}}>
{children}
</CookieContext.Provider>
);
};
export const useCookie = () => {
const context = useContext(CookieContext);
if (!context) {
throw new Error('useCookie must be used within a CookieProvider');
}
return context;
};

View File

@@ -119,7 +119,7 @@ const translations = {
'education.subtitle': 'Mon parcours académique et professionnel',
'education.learningGoals2025': 'Objectifs d\'apprentissage 2025',
'education.goal1': 'Maîtriser Firebase et les services cloud',
'education.goal2': 'Approfondir Spring Boot pour le backend',
'education.goal2': 'Approfondir n8n pour l\'automatisation',
'education.goal3': 'Apprendre Docker et les conteneurs',
'education.goal4': 'Développer mes compétences en UI/UX',
'education.degree': 'Bachelier en Technologies de l\'Informatique',
@@ -251,7 +251,11 @@ const translations = {
'policies.section.5.content': 'Vous avez le droit d\'accéder, de corriger ou de supprimer vos données personnelles à tout moment. Veuillez nous contacter pour toute demande.',
'policies.section.6.title': 'Nous contacter',
'policies.section.6.content': 'Pour toute question concernant cette politique de confidentialité, veuillez nous contacter à support@travelmate.com',
'policies.section.6.content': 'Pour toute question concernant cette politique de confidentialité, veuillez nous contacter à contact.travelmate@xeewy.be',
'travelmate.contact.title': 'Nous Contacter',
'travelmate.contact.subtitle': 'Une question, une suggestion ou un problème ? N\'hésitez pas à nous écrire directement.',
'travelmate.contact.emailLabel': 'Email de support',
'policies.googleBtn': 'Politique de confidentialité Google',
@@ -400,7 +404,7 @@ const translations = {
'education.subtitle': 'My academic and professional journey',
'education.learningGoals2025': '2025 Learning Goals',
'education.goal1': 'Master Firebase and cloud services',
'education.goal2': 'Deepen Spring Boot for backend',
'education.goal2': 'Deepen n8n for automation',
'education.goal3': 'Learn Docker and containers',
'education.goal4': 'Develop my UI/UX skills',
'education.degree': 'Bachelor in Computer Technology',
@@ -533,7 +537,11 @@ const translations = {
'policies.section.5.content': 'You have the right to access, correct, or delete your personal data at any time. Please contact us for any request.',
'policies.section.6.title': 'Contact Us',
'policies.section.6.content': 'For any questions regarding this privacy policy, please contact us at support@travelmate.com',
'policies.section.6.content': 'For any questions regarding this privacy policy, please contact us at contact.travelmate@xeewy.be',
'travelmate.contact.title': 'Contact Us',
'travelmate.contact.subtitle': 'A question, a suggestion or an issue? Don\'t hesitate to write to us directly.',
'travelmate.contact.emailLabel': 'Support Email',
'policies.googleBtn': 'Google Privacy Policy',
@@ -617,6 +625,23 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
}
const newPath = segments.join('/');
// Save to local storage if user has consented to preferences
// Note: We access localStorage directly here for simplicity,
// but ideally we check cookie consent first.
// However, saving a simple language preference string is often considered essential/functional.
// For strict compliance, we will wrap this check in App.tsx or use the CookieContext here.
const consent = localStorage.getItem('cookie_consent');
if (consent) {
try {
const parsed = JSON.parse(consent);
if (parsed.preferences || parsed.essential) {
localStorage.setItem('preferred_language', newLang);
}
} catch (e) {
// ignore
}
}
navigate(newPath + location.search + location.hash);
};

View File

@@ -14,6 +14,12 @@
gap: 60px;
}
&-main {
display: flex;
flex-direction: column;
gap: 40px;
}
&-card {
display: flex;
gap: 32px;