feat: Implement cookie consent management with a new banner and introduce a language-aware root redirection.
This commit is contained in:
@@ -1,13 +1,16 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="fr">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
<head>
|
||||||
<link rel="icon" type="image/svg+xml" href="/public/personnes.png" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<link rel="icon" type="image/svg+xml" href="/public/personnes.png" />
|
||||||
<title>xeewy.be</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
</head>
|
<title>xeewy.be</title>
|
||||||
<body>
|
</head>
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<body>
|
||||||
</body>
|
<div id="root"></div>
|
||||||
</html>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
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 { LanguageProvider } from './contexts/LanguageContext';
|
||||||
import Header from './components/Header';
|
import Header from './components/Header';
|
||||||
import Home from './components/Home';
|
import Home from './components/Home';
|
||||||
@@ -10,19 +10,24 @@ import EraseData from './components/TravelMate/EraseData';
|
|||||||
import Support from './components/TravelMate/Support';
|
import Support from './components/TravelMate/Support';
|
||||||
import HomeSync from './components/HomeSync';
|
import HomeSync from './components/HomeSync';
|
||||||
import ScrollToTop from './components/ScrollToTop';
|
import ScrollToTop from './components/ScrollToTop';
|
||||||
|
import RootRedirect from './components/RootRedirect';
|
||||||
import './styles/main.scss';
|
import './styles/main.scss';
|
||||||
|
import { CookieProvider, useCookie } from './contexts/CookieContext';
|
||||||
|
import CookieBanner from './components/CookieBanner/CookieBanner';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<CookieProvider>
|
||||||
<Route path="/" element={<Navigate to="/fr" replace />} />
|
<Routes>
|
||||||
<Route path="/:lang/*" element={
|
<Route path="/" element={<RootRedirect />} />
|
||||||
<LanguageProvider>
|
<Route path="/:lang/*" element={
|
||||||
<AppContent />
|
<LanguageProvider>
|
||||||
</LanguageProvider>
|
<AppContent />
|
||||||
} />
|
</LanguageProvider>
|
||||||
</Routes>
|
} />
|
||||||
|
</Routes>
|
||||||
|
</CookieProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -36,6 +41,8 @@ function AppContent() {
|
|||||||
// Optional: Check if useParamsLang.lang is valid, else redirect
|
// Optional: Check if useParamsLang.lang is valid, else redirect
|
||||||
}, [useParamsLang]);
|
}, [useParamsLang]);
|
||||||
|
|
||||||
|
const { consent } = useCookie();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedTheme = localStorage.getItem('darkMode');
|
const savedTheme = localStorage.getItem('darkMode');
|
||||||
if (savedTheme) {
|
if (savedTheme) {
|
||||||
@@ -46,9 +53,12 @@ function AppContent() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
// 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');
|
document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light');
|
||||||
}, [darkMode]);
|
}, [darkMode, consent]);
|
||||||
|
|
||||||
const toggleDarkMode = () => {
|
const toggleDarkMode = () => {
|
||||||
setDarkMode(!darkMode);
|
setDarkMode(!darkMode);
|
||||||
@@ -71,6 +81,7 @@ function AppContent() {
|
|||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
<CookieBanner />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -81,15 +81,15 @@ const Contact = () => {
|
|||||||
{
|
{
|
||||||
icon: <Mail size={24} />,
|
icon: <Mail size={24} />,
|
||||||
title: "Email",
|
title: "Email",
|
||||||
content: "dayronvanleemput@gmail.com",
|
content: "contact@xeewy.be",
|
||||||
link: "mailto:dayronvanleemput@gmail.com",
|
link: "mailto:contact@xeewy.be",
|
||||||
color: "#EA4335"
|
color: "#EA4335"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Phone size={24} />,
|
icon: <Phone size={24} />,
|
||||||
title: "Téléphone",
|
title: "Téléphone",
|
||||||
content: "+32 455 19 47 62",
|
content: "+32 455 19 47 63",
|
||||||
link: "tel:+32455194762",
|
link: "tel:+32455194763",
|
||||||
color: "#34A853"
|
color: "#34A853"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
157
frontend/src/components/CookieBanner/CookieBanner.tsx
Normal file
157
frontend/src/components/CookieBanner/CookieBanner.tsx
Normal 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;
|
||||||
157
frontend/src/components/CookieBanner/cookie-banner.scss
Normal file
157
frontend/src/components/CookieBanner/cookie-banner.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -249,7 +249,7 @@ const Education = () => {
|
|||||||
<div className="goals-grid">
|
<div className="goals-grid">
|
||||||
{[
|
{[
|
||||||
{ goal: t('education.goal1'), progress: 60 },
|
{ 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.goal3'), progress: 20 },
|
||||||
{ goal: t('education.goal4'), progress: 45 }
|
{ goal: t('education.goal4'), progress: 45 }
|
||||||
].map((item, index) => (
|
].map((item, index) => (
|
||||||
|
|||||||
14
frontend/src/components/RootRedirect.tsx
Normal file
14
frontend/src/components/RootRedirect.tsx
Normal 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;
|
||||||
@@ -2,7 +2,7 @@ import { useEffect } from 'react';
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import { Link } from 'react-router-dom';
|
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';
|
import appIcon from '../assets/app_icon.png';
|
||||||
|
|
||||||
const itemVariants = {
|
const itemVariants = {
|
||||||
@@ -326,6 +326,48 @@ const TravelMate = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</motion.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>
|
</motion.div>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
101
frontend/src/contexts/CookieContext.tsx
Normal file
101
frontend/src/contexts/CookieContext.tsx
Normal 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;
|
||||||
|
};
|
||||||
@@ -119,7 +119,7 @@ const translations = {
|
|||||||
'education.subtitle': 'Mon parcours académique et professionnel',
|
'education.subtitle': 'Mon parcours académique et professionnel',
|
||||||
'education.learningGoals2025': 'Objectifs d\'apprentissage 2025',
|
'education.learningGoals2025': 'Objectifs d\'apprentissage 2025',
|
||||||
'education.goal1': 'Maîtriser Firebase et les services cloud',
|
'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.goal3': 'Apprendre Docker et les conteneurs',
|
||||||
'education.goal4': 'Développer mes compétences en UI/UX',
|
'education.goal4': 'Développer mes compétences en UI/UX',
|
||||||
'education.degree': 'Bachelier en Technologies de l\'Informatique',
|
'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.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.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',
|
'policies.googleBtn': 'Politique de confidentialité Google',
|
||||||
|
|
||||||
@@ -400,7 +404,7 @@ const translations = {
|
|||||||
'education.subtitle': 'My academic and professional journey',
|
'education.subtitle': 'My academic and professional journey',
|
||||||
'education.learningGoals2025': '2025 Learning Goals',
|
'education.learningGoals2025': '2025 Learning Goals',
|
||||||
'education.goal1': 'Master Firebase and cloud services',
|
'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.goal3': 'Learn Docker and containers',
|
||||||
'education.goal4': 'Develop my UI/UX skills',
|
'education.goal4': 'Develop my UI/UX skills',
|
||||||
'education.degree': 'Bachelor in Computer Technology',
|
'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.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.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',
|
'policies.googleBtn': 'Google Privacy Policy',
|
||||||
|
|
||||||
@@ -617,6 +625,23 @@ export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children })
|
|||||||
}
|
}
|
||||||
const newPath = segments.join('/');
|
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);
|
navigate(newPath + location.search + location.hash);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,12 @@
|
|||||||
gap: 60px;
|
gap: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
&-card {
|
&-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 32px;
|
gap: 32px;
|
||||||
|
|||||||
Reference in New Issue
Block a user