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>
|
||||
<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>
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
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">
|
||||
{[
|
||||
{ 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) => (
|
||||
|
||||
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 { 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>
|
||||
|
||||
|
||||
|
||||
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.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);
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
gap: 60px;
|
||||
}
|
||||
|
||||
&-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
&-card {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
|
||||
Reference in New Issue
Block a user