diff --git a/frontend/index.html b/frontend/index.html
index b0abe1d..a4ae7b8 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1,13 +1,16 @@
-
-
-
-
-
- xeewy.be
-
-
-
-
-
-
+
+
+
+
+
+
+ xeewy.be
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index a63649b..9550e25 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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 (
-
- } />
-
-
-
- } />
-
+
+
+ } />
+
+
+
+ } />
+
+
);
}
@@ -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(() => {
- 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');
- }, [darkMode]);
+ }, [darkMode, consent]);
const toggleDarkMode = () => {
setDarkMode(!darkMode);
@@ -71,6 +81,7 @@ function AppContent() {
+
>
);
diff --git a/frontend/src/components/Contact.tsx b/frontend/src/components/Contact.tsx
index fb525ec..864359e 100644
--- a/frontend/src/components/Contact.tsx
+++ b/frontend/src/components/Contact.tsx
@@ -81,15 +81,15 @@ const Contact = () => {
{
icon: ,
title: "Email",
- content: "dayronvanleemput@gmail.com",
- link: "mailto:dayronvanleemput@gmail.com",
+ content: "contact@xeewy.be",
+ link: "mailto:contact@xeewy.be",
color: "#EA4335"
},
{
icon: ,
title: "Téléphone",
- content: "+32 455 19 47 62",
- link: "tel:+32455194762",
+ content: "+32 455 19 47 63",
+ link: "tel:+32455194763",
color: "#34A853"
},
{
diff --git a/frontend/src/components/CookieBanner/CookieBanner.tsx b/frontend/src/components/CookieBanner/CookieBanner.tsx
new file mode 100644
index 0000000..0b3955e
--- /dev/null
+++ b/frontend/src/components/CookieBanner/CookieBanner.tsx
@@ -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 (
+
+ {!showSettings ? (
+
+
+
{txt.title}
+
{txt.text}
+
+
+
+
+
+
+
+ ) : (
+
+
+
+
{txt.settings}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ togglePreference('preferences')}
+ />
+
+
+
+
+ togglePreference('analytics')}
+ />
+
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default CookieBanner;
diff --git a/frontend/src/components/CookieBanner/cookie-banner.scss b/frontend/src/components/CookieBanner/cookie-banner.scss
new file mode 100644
index 0000000..c484f5f
--- /dev/null
+++ b/frontend/src/components/CookieBanner/cookie-banner.scss
@@ -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;
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/components/Education.tsx b/frontend/src/components/Education.tsx
index d72584f..b1b2142 100644
--- a/frontend/src/components/Education.tsx
+++ b/frontend/src/components/Education.tsx
@@ -249,7 +249,7 @@ const Education = () => {
{[
{ 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) => (
diff --git a/frontend/src/components/RootRedirect.tsx b/frontend/src/components/RootRedirect.tsx
new file mode 100644
index 0000000..a6883fe
--- /dev/null
+++ b/frontend/src/components/RootRedirect.tsx
@@ -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 ;
+};
+
+export default RootRedirect;
diff --git a/frontend/src/components/TravelMate.tsx b/frontend/src/components/TravelMate.tsx
index 3a83fe7..852b85f 100644
--- a/frontend/src/components/TravelMate.tsx
+++ b/frontend/src/components/TravelMate.tsx
@@ -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 = () => {
+ {/* Contact Section */}
+
+
+ {t('travelmate.contact.title')}
+
+
+ {t('travelmate.contact.subtitle')}
+
+
+
+
+ contact.travelmate@xeewy.be
+
+
diff --git a/frontend/src/contexts/CookieContext.tsx b/frontend/src/contexts/CookieContext.tsx
new file mode 100644
index 0000000..6d67c00
--- /dev/null
+++ b/frontend/src/contexts/CookieContext.tsx
@@ -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(undefined);
+
+export const CookieProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
+ const [consent, setConsent] = useState(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 (
+
+ {children}
+
+ );
+};
+
+export const useCookie = () => {
+ const context = useContext(CookieContext);
+ if (!context) {
+ throw new Error('useCookie must be used within a CookieProvider');
+ }
+ return context;
+};
diff --git a/frontend/src/contexts/LanguageContext.tsx b/frontend/src/contexts/LanguageContext.tsx
index 086144c..c62f4ab 100644
--- a/frontend/src/contexts/LanguageContext.tsx
+++ b/frontend/src/contexts/LanguageContext.tsx
@@ -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 = ({ 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);
};
diff --git a/frontend/src/styles/components/_education.scss b/frontend/src/styles/components/_education.scss
index 8190302..b134b99 100644
--- a/frontend/src/styles/components/_education.scss
+++ b/frontend/src/styles/components/_education.scss
@@ -14,6 +14,12 @@
gap: 60px;
}
+ &-main {
+ display: flex;
+ flex-direction: column;
+ gap: 40px;
+ }
+
&-card {
display: flex;
gap: 32px;