Compare commits
9 Commits
59c1b8c8ad
...
6c11cf5213
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c11cf5213 | ||
|
|
56897a0c2d | ||
|
|
a01c6c4356 | ||
|
|
8f86da9925 | ||
|
|
00999578f0 | ||
|
|
b148b4d90e | ||
|
|
28a8dcd170 | ||
|
|
404e493aa8 | ||
|
|
0518588121 |
8
.vite/deps/_metadata.json
Normal file
8
.vite/deps/_metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hash": "a5aca759",
|
||||
"configHash": "b6b52121",
|
||||
"lockfileHash": "e3b0c442",
|
||||
"browserHash": "5526a657",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
3
.vite/deps/package.json
Normal file
3
.vite/deps/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
105
EMAILJS_SETUP.md
105
EMAILJS_SETUP.md
@@ -1,105 +0,0 @@
|
||||
# Configuration EmailJS pour le Portfolio
|
||||
|
||||
Ce guide vous explique comment configurer EmailJS pour que le formulaire de contact de votre portfolio fonctionne réellement.
|
||||
|
||||
## 1. Créer un compte EmailJS
|
||||
|
||||
1. Allez sur [EmailJS.com](https://www.emailjs.com/)
|
||||
2. Créez un compte gratuit (jusqu'à 200 emails/mois)
|
||||
3. Connectez-vous à votre dashboard
|
||||
|
||||
## 2. Configurer le service email
|
||||
|
||||
1. Dans votre dashboard EmailJS, allez dans **Email Services**
|
||||
2. Cliquez sur **Add New Service**
|
||||
3. Choisissez votre fournisseur email (Gmail, Outlook, etc.)
|
||||
4. Suivez les instructions pour connecter votre compte email
|
||||
5. Notez votre **Service ID** (ex: `service_xyz123`)
|
||||
|
||||
## 3. Créer un template d'email
|
||||
|
||||
1. Allez dans **Email Templates**
|
||||
2. Cliquez sur **Create New Template**
|
||||
3. Configurez votre template avec ces variables :
|
||||
- `{{from_name}}` - Nom de l'expéditeur
|
||||
- `{{from_email}}` - Email de l'expéditeur
|
||||
- `{{subject}}` - Sujet du message
|
||||
- `{{message}}` - Corps du message
|
||||
|
||||
Exemple de template :
|
||||
```
|
||||
Nouveau message depuis votre portfolio
|
||||
|
||||
De: {{from_name}} ({{from_email}})
|
||||
Sujet: {{subject}}
|
||||
|
||||
Message:
|
||||
{{message}}
|
||||
|
||||
---
|
||||
Ce message a été envoyé depuis votre formulaire de contact.
|
||||
```
|
||||
|
||||
4. Notez votre **Template ID** (ex: `template_abc456`)
|
||||
|
||||
## 4. Obtenir votre clé publique
|
||||
|
||||
1. Allez dans **Account** > **General**
|
||||
2. Trouvez votre **Public Key** (ex: `user_def789`)
|
||||
|
||||
## 5. Configurer les variables d'environnement
|
||||
|
||||
Créez un fichier `.env` à la racine de votre projet :
|
||||
|
||||
```env
|
||||
VITE_EMAILJS_SERVICE_ID=your_service_id_here
|
||||
VITE_EMAILJS_TEMPLATE_ID=your_template_id_here
|
||||
VITE_EMAILJS_PUBLIC_KEY=your_public_key_here
|
||||
```
|
||||
|
||||
## 6. Sécurité et limitations
|
||||
|
||||
### Version gratuite d'EmailJS :
|
||||
- 200 emails par mois maximum
|
||||
- Pas de limitation de domaine
|
||||
- Support communautaire
|
||||
|
||||
### Pour la production :
|
||||
- Considérez un plan payant pour plus d'emails
|
||||
- Ajoutez une validation côté serveur si nécessaire
|
||||
- Implémentez un captcha pour éviter le spam
|
||||
|
||||
## 7. Test du formulaire
|
||||
|
||||
1. Remplissez les variables d'environnement
|
||||
2. Redémarrez votre serveur de développement
|
||||
3. Testez le formulaire de contact
|
||||
4. Vérifiez la réception de l'email
|
||||
|
||||
## 8. Dépannage
|
||||
|
||||
### Erreurs communes :
|
||||
|
||||
**"Invalid template ID"**
|
||||
- Vérifiez que le Template ID est correct
|
||||
- Assurez-vous que le template est publié
|
||||
|
||||
**"Forbidden"**
|
||||
- Vérifiez votre Public Key
|
||||
- Assurez-vous que le service est actif
|
||||
|
||||
**Email non reçu**
|
||||
- Vérifiez vos spams
|
||||
- Vérifiez la configuration du service email
|
||||
- Testez depuis le dashboard EmailJS
|
||||
|
||||
### Fallback automatique
|
||||
Si EmailJS ne fonctionne pas, le système bascule automatiquement vers `mailto:` pour ouvrir le client email par défaut de l'utilisateur.
|
||||
|
||||
## 9. Fichiers modifiés
|
||||
|
||||
- `src/services/emailService.ts` - Service d'envoi d'emails
|
||||
- `src/components/Contact.tsx` - Intégration du service
|
||||
- `src/styles/components/_contact.scss` - Styles des messages d'erreur
|
||||
|
||||
Le formulaire de contact est maintenant prêt à fonctionner avec une vraie logique d'envoi d'emails !
|
||||
4
backend/.gitignore
vendored
Normal file
4
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.env
|
||||
.env.example
|
||||
.env.local
|
||||
1769
backend/package-lock.json
generated
Normal file
1769
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
backend/package.json
Normal file
28
backend/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "xeewy-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend API for xeewy.be",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"dev": "nodemon src/index.ts",
|
||||
"build": "tsc"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.1",
|
||||
"mysql2": "^3.11.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.8.6",
|
||||
"nodemon": "^3.1.7",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
17
backend/src/config/db.ts
Normal file
17
backend/src/config/db.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0
|
||||
});
|
||||
|
||||
export default pool;
|
||||
32
backend/src/index.ts
Normal file
32
backend/src/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import pool from './config/db';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Basic health check
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date() });
|
||||
});
|
||||
|
||||
// Database connection test
|
||||
app.get('/api/test-db', async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT 1 + 1 AS solution');
|
||||
res.json({ status: 'connected', result: rows });
|
||||
} catch (error: any) {
|
||||
console.error('Database connection error:', error);
|
||||
res.status(500).json({ status: 'error', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server running on port ${port}`);
|
||||
});
|
||||
18
backend/tsconfig.json
Normal file
18
backend/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2016",
|
||||
"module": "commonjs",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
0
.gitignore → frontend/.gitignore
vendored
0
.gitignore → frontend/.gitignore
vendored
60
package-lock.json → frontend/package-lock.json
generated
60
package-lock.json → frontend/package-lock.json
generated
@@ -12,7 +12,8 @@
|
||||
"framer-motion": "^12.23.24",
|
||||
"lucide-react": "^0.553.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
@@ -2308,6 +2309,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -3362,6 +3376,44 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.10.1.tgz",
|
||||
"integrity": "sha512-gHL89dRa3kwlUYtRQ+m8NmxGI6CgqN+k4XyGjwcFoQwwCWF6xXpOCUlDovkXClS0d0XJN/5q7kc5W3kiFEd0Yw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.10.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.10.1.tgz",
|
||||
"integrity": "sha512-JNBANI6ChGVjA5bwsUIwJk7LHKmqB4JYnYfzFwyp2t12Izva11elds2jx7Yfoup2zssedntwU0oZ5DEmk5Sdaw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.10.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
@@ -3501,6 +3553,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -14,7 +14,8 @@
|
||||
"framer-motion": "^12.23.24",
|
||||
"lucide-react": "^0.553.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
72
frontend/src/App.tsx
Normal file
72
frontend/src/App.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom';
|
||||
import { LanguageProvider } from './contexts/LanguageContext';
|
||||
import Header from './components/Header';
|
||||
import Footer from './components/Footer';
|
||||
import Home from './components/Home';
|
||||
import TravelMate from './components/TravelMate';
|
||||
import Policies from './components/Policies';
|
||||
import ScrollToTop from './components/ScrollToTop';
|
||||
import './styles/main.scss';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/fr" replace />} />
|
||||
<Route path="/:lang/*" element={
|
||||
<LanguageProvider>
|
||||
<AppContent />
|
||||
</LanguageProvider>
|
||||
} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const useParamsLang = useParams();
|
||||
|
||||
// Sync URL language with context if needed, or validate it
|
||||
useEffect(() => {
|
||||
// Optional: Check if useParamsLang.lang is valid, else redirect
|
||||
}, [useParamsLang]);
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('darkMode');
|
||||
if (savedTheme) {
|
||||
setDarkMode(JSON.parse(savedTheme));
|
||||
} else {
|
||||
setDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
||||
document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light');
|
||||
}, [darkMode]);
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
setDarkMode(!darkMode);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollToTop />
|
||||
<div className={`app ${darkMode ? 'dark' : 'light'}`} style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<Header darkMode={darkMode} toggleDarkMode={toggleDarkMode} />
|
||||
<main style={{ flex: 1 }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="travelmate" element={<TravelMate />} />
|
||||
<Route path="travelmate/policies" element={<Policies />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
BIN
frontend/src/assets/app_icon.png
Normal file
BIN
frontend/src/assets/app_icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 694 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
@@ -27,6 +27,21 @@ const Contact = () => {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Vérification du délai de 15 minutes par email
|
||||
const storageKey = `lastMessageTime_${formData.email}`;
|
||||
const lastMessageTime = localStorage.getItem(storageKey);
|
||||
|
||||
if (lastMessageTime) {
|
||||
const timeSinceLastMessage = Date.now() - parseInt(lastMessageTime, 10);
|
||||
const fifteenMinutes = 15 * 60 * 1000;
|
||||
|
||||
if (timeSinceLastMessage < fifteenMinutes) {
|
||||
const remainingMinutes = Math.ceil((fifteenMinutes - timeSinceLastMessage) / 60000);
|
||||
setError(`Veuillez attendre ${remainingMinutes} minutes avant d'envoyer un nouveau message avec cette adresse email.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
@@ -41,6 +56,7 @@ const Contact = () => {
|
||||
const result = await sendContactEmail(contactData);
|
||||
|
||||
if (result.success) {
|
||||
localStorage.setItem(`lastMessageTime_${formData.email}`, Date.now().toString());
|
||||
setIsSubmitted(true);
|
||||
setFormData({ name: '', email: '', subject: '', message: '' });
|
||||
|
||||
@@ -89,7 +105,7 @@ const Contact = () => {
|
||||
{
|
||||
icon: <Github size={24} />,
|
||||
name: "GitHub",
|
||||
url: "https://github.com/Dayron-HELHa", // Remplacez par votre profil
|
||||
url: "https://git.xeewy.be/Xeewy", // Remplacez par votre profil
|
||||
color: "#333"
|
||||
},
|
||||
{
|
||||
@@ -357,18 +373,6 @@ const Contact = () => {
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Footer */}
|
||||
<motion.footer
|
||||
className="contact-footer"
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.5 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<p>
|
||||
© 2025 Dayron Van Leemput.
|
||||
</p>
|
||||
</motion.footer>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
28
frontend/src/components/Footer.tsx
Normal file
28
frontend/src/components/Footer.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const Footer = () => {
|
||||
return (
|
||||
<motion.footer
|
||||
className="footer"
|
||||
style={{
|
||||
padding: '2rem 0',
|
||||
textAlign: 'center',
|
||||
borderTop: '1px solid var(--border-color, rgba(255, 255, 255, 0.1))',
|
||||
background: 'var(--bg-secondary, rgba(0, 0, 0, 0.2))',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
}}
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<p style={{ opacity: 0.7, margin: 0 }}>
|
||||
© {new Date().getFullYear()} Dayron Van Leemput.
|
||||
</p>
|
||||
</motion.footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Menu, X, Sun, Moon, Globe } from 'lucide-react';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useNavigate, useLocation, Link } from 'react-router-dom';
|
||||
|
||||
interface HeaderProps {
|
||||
darkMode: boolean;
|
||||
@@ -11,6 +12,8 @@ interface HeaderProps {
|
||||
const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { language, setLanguage, t } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const menuItems = [
|
||||
{ id: 'home', name: t('nav.home'), href: '#hero' },
|
||||
@@ -21,12 +24,26 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
|
||||
{ id: 'contact', name: t('nav.contact'), href: '#contact' }
|
||||
];
|
||||
|
||||
const scrollToSection = (href: string) => {
|
||||
const element = document.querySelector(href);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
const handleNavigation = (href: string) => {
|
||||
setIsMenuOpen(false);
|
||||
|
||||
if (location.pathname === `/${language}` || location.pathname === `/${language}/`) {
|
||||
// Already on home, just scroll
|
||||
const element = document.querySelector(href);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
} else {
|
||||
// Not on home, navigate then scroll (using a simple timeout for simplicity or hash)
|
||||
navigate(`/${language}`);
|
||||
// Small timeout to allow navigation to render Home before scrolling
|
||||
setTimeout(() => {
|
||||
const element = document.querySelector(href);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleLanguage = () => {
|
||||
@@ -46,9 +63,12 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<a href="#hero" onClick={(e) => { e.preventDefault(); scrollToSection('#hero'); }}>
|
||||
<Link to={`/${language}`} onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleNavigation('#hero');
|
||||
}}>
|
||||
Dayron Van Leemput
|
||||
</a>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
{/* Navigation desktop */}
|
||||
@@ -64,7 +84,7 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
|
||||
href={item.href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
scrollToSection(item.href);
|
||||
handleNavigation(item.href);
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
@@ -123,7 +143,7 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
|
||||
href={item.href}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
scrollToSection(item.href);
|
||||
handleNavigation(item.href);
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
@@ -89,7 +89,7 @@ const Hero = () => {
|
||||
transition={{ duration: 0.8, delay: 1.2 }}
|
||||
>
|
||||
<motion.a
|
||||
href="https://github.com/Dayron-HELHa" // Remplacez par votre profil GitHub
|
||||
href="https://git.xeewy.be/Xeewy" // Remplacez par votre profil GitHub
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="social-link"
|
||||
26
frontend/src/components/Home.tsx
Normal file
26
frontend/src/components/Home.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useEffect } from 'react';
|
||||
import Hero from './Hero';
|
||||
import About from './About';
|
||||
import Skills from './Skills';
|
||||
import Projects from './Projects';
|
||||
import Education from './Education';
|
||||
import Contact from './Contact';
|
||||
|
||||
const Home = () => {
|
||||
useEffect(() => {
|
||||
document.title = "Portfolio - Dayron Van Leemput";
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<About />
|
||||
<Skills />
|
||||
<Projects />
|
||||
<Education />
|
||||
<Contact />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
148
frontend/src/components/Policies.tsx
Normal file
148
frontend/src/components/Policies.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowLeft, ExternalLink, Camera, MapPin, Bell } from 'lucide-react';
|
||||
|
||||
const Policies = () => {
|
||||
const { t, language } = useLanguage();
|
||||
|
||||
const sections = [2, 3, 4, 5, 6]; // Sections after the permission cards (1 is before)
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const sectionVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0 }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="policies-page" style={{ paddingTop: '100px', minHeight: '100vh', paddingBottom: '50px' }}>
|
||||
<div className="container" style={{ maxWidth: '800px', margin: '0 auto', padding: '0 20px' }}>
|
||||
<Link
|
||||
to={`/${language}/travelmate`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
marginBottom: '40px',
|
||||
color: 'var(--text-color)',
|
||||
textDecoration: 'none',
|
||||
fontSize: '1rem',
|
||||
opacity: 0.8
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
{t('policies.back')}
|
||||
</Link>
|
||||
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{/* Header */}
|
||||
<motion.div variants={sectionVariants} style={{ marginBottom: '3rem', textAlign: 'center' }}>
|
||||
<h1 style={{ fontSize: '2.5rem', marginBottom: '1rem', fontWeight: 'bold' }}>{t('policies.title')}</h1>
|
||||
<p style={{ fontSize: '1.2rem', opacity: 0.7 }}>{t('policies.intro')}</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Section 1: Collection (Text) */}
|
||||
<motion.div variants={sectionVariants} className="policy-section" style={{ marginBottom: '2.5rem' }}>
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', fontWeight: '700' }}>
|
||||
{t('policies.section.1.title')}
|
||||
</h2>
|
||||
<p style={{ lineHeight: '1.8', opacity: 0.9, fontSize: '1rem' }}>
|
||||
{t('policies.section.1.content')}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Visual Permission Cards */}
|
||||
<motion.div variants={sectionVariants} style={{ marginBottom: '4rem' }}>
|
||||
<h3 style={{ fontSize: '1.2rem', marginBottom: '1.5rem', opacity: 0.8 }}>{t('policies.permissions.title')}</h3>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '1.5rem' }}>
|
||||
|
||||
{/* Camera */}
|
||||
<div style={{ background: 'var(--card-bg, rgba(255,255,255,0.05))', padding: '1.5rem', borderRadius: '1rem', border: '1px solid var(--border-color, rgba(255, 255, 255, 0.1))' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '1rem', color: 'var(--primary-color)' }}>
|
||||
<Camera size={24} />
|
||||
<h4 style={{ margin: 0, fontSize: '1.1rem' }}>{t('policies.data.camera')}</h4>
|
||||
</div>
|
||||
<p style={{ opacity: 0.7, fontSize: '0.9rem', lineHeight: '1.5' }}>{t('policies.data.camera.desc')}</p>
|
||||
</div>
|
||||
|
||||
{/* GPS */}
|
||||
<div style={{ background: 'var(--card-bg, rgba(255,255,255,0.05))', padding: '1.5rem', borderRadius: '1rem', border: '1px solid var(--border-color, rgba(255, 255, 255, 0.1))' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '1rem', color: 'var(--primary-color)' }}>
|
||||
<MapPin size={24} />
|
||||
<h4 style={{ margin: 0, fontSize: '1.1rem' }}>{t('policies.data.gps')}</h4>
|
||||
</div>
|
||||
<p style={{ opacity: 0.7, fontSize: '0.9rem', lineHeight: '1.5' }}>{t('policies.data.gps.desc')}</p>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<div style={{ background: 'var(--card-bg, rgba(255,255,255,0.05))', padding: '1.5rem', borderRadius: '1rem', border: '1px solid var(--border-color, rgba(255, 255, 255, 0.1))' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '1rem', color: 'var(--primary-color)' }}>
|
||||
<Bell size={24} />
|
||||
<h4 style={{ margin: 0, fontSize: '1.1rem' }}>{t('policies.data.notif')}</h4>
|
||||
</div>
|
||||
<p style={{ opacity: 0.7, fontSize: '0.9rem', lineHeight: '1.5' }}>{t('policies.data.notif.desc')}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Remaining Sections (Loop) */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2.5rem' }}>
|
||||
{sections.map((num) => (
|
||||
<motion.div key={num} variants={sectionVariants} className="policy-section">
|
||||
<h2 style={{ fontSize: '1.5rem', marginBottom: '1rem', fontWeight: '700' }}>
|
||||
{t(`policies.section.${num}.title`)}
|
||||
</h2>
|
||||
<p style={{ lineHeight: '1.8', opacity: 0.9, fontSize: '1rem' }}>
|
||||
{t(`policies.section.${num}.content`)}
|
||||
</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Google Policy Button */}
|
||||
<motion.div variants={sectionVariants} style={{ marginTop: '5rem', textAlign: 'center' }}>
|
||||
<a
|
||||
href="https://policies.google.com/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--primary-color)',
|
||||
color: 'var(--primary-color)',
|
||||
padding: '12px 30px',
|
||||
borderRadius: '50px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: '600',
|
||||
transition: 'all 0.3s ease'
|
||||
}}
|
||||
className="hover-scale"
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
{t('policies.googleBtn')}
|
||||
</a>
|
||||
</motion.div>
|
||||
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Policies;
|
||||
@@ -1,9 +1,10 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { ExternalLink, Github, MapPin, Wine } from 'lucide-react';
|
||||
import { ExternalLink, MapPin, Wine } from 'lucide-react';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const Projects = () => {
|
||||
const { t } = useLanguage();
|
||||
const { t, language } = useLanguage();
|
||||
const projects = [
|
||||
{
|
||||
id: 1,
|
||||
@@ -20,8 +21,7 @@ const Projects = () => {
|
||||
color: "#4CAF50",
|
||||
icon: <MapPin size={24} />,
|
||||
links: {
|
||||
github: "https://github.com/Dayron-HELHa/travel_mate", // Remplacez par votre lien GitHub
|
||||
demo: "#"
|
||||
demo: "/travelmate"
|
||||
},
|
||||
image: "/travel-mate-preview.png" // Ajoutez votre image dans le dossier public
|
||||
},
|
||||
@@ -39,7 +39,6 @@ const Projects = () => {
|
||||
color: "#E91E63", // A distinct color
|
||||
icon: <Wine size={24} />,
|
||||
links: {
|
||||
github: "#", // Assuming private or not provided
|
||||
demo: "https://shelbys.be"
|
||||
},
|
||||
image: "/shelbys-preview.png" // Placeholder, user might need to add this
|
||||
@@ -59,7 +58,6 @@ const Projects = () => {
|
||||
color: "#2196F3",
|
||||
icon: <ExternalLink size={24} />,
|
||||
links: {
|
||||
github: "https://github.com/Dayron-HELHa/xeewy.eu", // Remplacez par votre lien
|
||||
demo: "https://xeewy.be" // Remplacez par votre lien
|
||||
}
|
||||
}
|
||||
@@ -183,31 +181,32 @@ const Projects = () => {
|
||||
|
||||
<div className="project-footer">
|
||||
<div className="project-links">
|
||||
{project.links.github !== "#" && (
|
||||
<motion.a
|
||||
href={project.links.github}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="project-link"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Github size={20} />
|
||||
{t('projects.btn.code')}
|
||||
</motion.a>
|
||||
)}
|
||||
|
||||
{project.links.demo !== "#" && (
|
||||
<motion.a
|
||||
href={project.links.demo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="project-link primary"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<ExternalLink size={20} />
|
||||
{t('projects.btn.viewProject')}
|
||||
</motion.a>
|
||||
project.links.demo.startsWith('/') ? (
|
||||
<Link to={`/${language}${project.links.demo}`} style={{ textDecoration: 'none' }}>
|
||||
<motion.div
|
||||
className="project-link primary"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<ExternalLink size={20} />
|
||||
{t('projects.btn.viewProject')}
|
||||
</motion.div>
|
||||
</Link>
|
||||
) : (
|
||||
<motion.a
|
||||
href={project.links.demo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="project-link primary"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<ExternalLink size={20} />
|
||||
{t('projects.btn.viewProject')}
|
||||
</motion.a>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
18
frontend/src/components/ScrollToTop.tsx
Normal file
18
frontend/src/components/ScrollToTop.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
const ScrollToTop = () => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo({
|
||||
top: 0,
|
||||
left: 0,
|
||||
behavior: 'instant',
|
||||
});
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default ScrollToTop;
|
||||
271
frontend/src/components/TravelMate.tsx
Normal file
271
frontend/src/components/TravelMate.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import { useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Link, Outlet } from 'react-router-dom';
|
||||
import { Shield, Smartphone, Map, DollarSign, Users, Globe, Code } from 'lucide-react';
|
||||
import appIcon from '../assets/app_icon.png';
|
||||
|
||||
const itemVariants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0 }
|
||||
};
|
||||
|
||||
const FeatureCard = ({ title, icon: Icon, description }: { title: string, icon: any, description: string }) => (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="feature-card"
|
||||
style={{
|
||||
background: 'var(--card-bg, rgba(255, 255, 255, 0.03))', // Slightly more transparent for glass effect
|
||||
backdropFilter: 'blur(10px)', // Glassmorphism
|
||||
padding: '2rem',
|
||||
borderRadius: '1.5rem',
|
||||
border: '1px solid var(--border-color, rgba(255, 255, 255, 0.08))',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center', // Center items horizontally
|
||||
textAlign: 'center', // Center text
|
||||
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)'
|
||||
}}
|
||||
whileHover={{
|
||||
y: -5,
|
||||
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||
borderColor: 'var(--primary-color, #4f46e5)'
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
marginBottom: '1.5rem',
|
||||
background: 'var(--primary-color-alpha, rgba(79, 70, 229, 0.1))',
|
||||
width: 'fit-content',
|
||||
padding: '12px',
|
||||
borderRadius: '12px'
|
||||
}}>
|
||||
<Icon size={32} color="var(--primary-color)" />
|
||||
</div>
|
||||
<h3 style={{
|
||||
marginBottom: '1rem',
|
||||
fontSize: '1.5rem',
|
||||
fontWeight: '600',
|
||||
color: 'var(--text-color)' // Explicit color for dark mode
|
||||
}}>
|
||||
{title}
|
||||
</h3>
|
||||
<p style={{
|
||||
opacity: 0.8,
|
||||
lineHeight: '1.7',
|
||||
flex: 1,
|
||||
fontSize: '1rem',
|
||||
color: 'var(--text-color)' // Explicit color
|
||||
}}>
|
||||
{description}
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
const TravelMate = () => {
|
||||
const { t, language } = useLanguage();
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.2
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.title = "Travel Mate";
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="travel-mate-page" style={{ paddingTop: '100px', minHeight: '100vh', paddingBottom: '50px' }}>
|
||||
<div className="container" style={{ maxWidth: '1400px', margin: '0 auto', padding: '0 20px' }}>
|
||||
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{/* Header Section */}
|
||||
<motion.div variants={itemVariants} style={{ textAlign: 'center', marginBottom: '4rem', display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
|
||||
<motion.img
|
||||
src={appIcon}
|
||||
alt="Travel Mate Icon"
|
||||
style={{ width: '120px', height: '120px', borderRadius: '24px', marginBottom: '2rem', boxShadow: '0 10px 30px rgba(0,0,0,0.2)' }}
|
||||
whileHover={{ scale: 1.05, rotate: 5 }}
|
||||
transition={{ type: "spring", stiffness: 300 }}
|
||||
/>
|
||||
<h1 className="gradient-text" style={{ fontSize: '4rem', fontWeight: '800', marginBottom: '1rem' }}>
|
||||
{t('travelmate.page.mainTitle')}
|
||||
</h1>
|
||||
<p style={{ fontSize: '1.5rem', opacity: 0.7, maxWidth: '600px', margin: '0 auto 2rem auto' }}>
|
||||
{t('travelmate.page.subtitle')}
|
||||
</p>
|
||||
|
||||
{/* View Code Button */}
|
||||
<motion.a
|
||||
href="https://git.xeewy.be/Xeewy/TravelMate"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn btn-primary"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
textDecoration: 'none',
|
||||
fontSize: '1.1rem',
|
||||
padding: '0.8rem 1.5rem',
|
||||
borderRadius: '50px'
|
||||
}}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Code size={20} />
|
||||
{t('travelmate.viewCode') || "Voir le code"}
|
||||
</motion.a>
|
||||
</motion.div>
|
||||
|
||||
{/* Description as Intro */}
|
||||
<motion.div variants={itemVariants} style={{ marginBottom: '5rem', maxWidth: '800px', margin: '0 auto 5rem auto', textAlign: 'center' }}>
|
||||
<p style={{
|
||||
lineHeight: '1.8',
|
||||
fontSize: '1.4rem',
|
||||
opacity: 0.9,
|
||||
fontStyle: 'italic',
|
||||
color: 'var(--text-color)'
|
||||
}}>
|
||||
"{t('travelmate.page.intro')}"
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Highlights Sections */}
|
||||
<motion.div variants={itemVariants} style={{ marginBottom: '6rem' }}>
|
||||
<h2 style={{
|
||||
textAlign: 'center',
|
||||
marginBottom: '4rem',
|
||||
fontSize: '2.5rem',
|
||||
fontWeight: '700',
|
||||
color: 'var(--text-color)'
|
||||
}}>{t('travelmate.highlights.title')}</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '2.5rem' }}>
|
||||
|
||||
{[1, 2, 3, 4].map((num) => (
|
||||
<FeatureCard
|
||||
key={num}
|
||||
title={t(`travelmate.highlight.${num}.title`)}
|
||||
description={t(`travelmate.highlight.${num}.desc`)}
|
||||
icon={
|
||||
num === 1 ? Users :
|
||||
num === 2 ? DollarSign :
|
||||
num === 3 ? Map :
|
||||
Smartphone
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Conclusion */}
|
||||
<motion.div variants={itemVariants} style={{ marginBottom: '6rem', textAlign: 'center', maxWidth: '800px', margin: '0 auto 6rem auto' }}>
|
||||
<p style={{
|
||||
fontSize: '1.8rem',
|
||||
fontWeight: 'bold',
|
||||
color: 'var(--primary-color)',
|
||||
lineHeight: '1.4'
|
||||
}}>
|
||||
{t('travelmate.page.conclusion')}
|
||||
</p>
|
||||
</motion.div>
|
||||
{/* Tech Stack */}
|
||||
<motion.div variants={itemVariants} style={{ marginBottom: '5rem' }}>
|
||||
<h2 style={{ textAlign: 'center', marginBottom: '3rem' }}>{t('travelmate.tech.title')}</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '2rem' }}>
|
||||
|
||||
{/* Frontend */}
|
||||
<div style={{ padding: '1.5rem' }}>
|
||||
<h3 style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '1.5rem', color: 'var(--primary-color)' }}>
|
||||
<Smartphone /> {t('travelmate.tech.frontend')}
|
||||
</h3>
|
||||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||
{[1, 2, 3].map(i => (
|
||||
<li key={i} style={{ marginBottom: '0.8rem', paddingLeft: '1rem', borderLeft: '2px solid var(--primary-color)' }}>
|
||||
{t(`travelmate.tech.frontend.${i}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Backend */}
|
||||
<div style={{ padding: '1.5rem' }}>
|
||||
<h3 style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '1.5rem', color: 'var(--primary-color)' }}>
|
||||
<Globe /> {t('travelmate.tech.backend')}
|
||||
</h3>
|
||||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||
{[1, 2, 3, 4].map(i => (
|
||||
<li key={i} style={{ marginBottom: '0.8rem', paddingLeft: '1rem', borderLeft: '2px solid var(--primary-color)' }}>
|
||||
{t(`travelmate.tech.backend.${i}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* API */}
|
||||
<div style={{ padding: '1.5rem' }}>
|
||||
<h3 style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '1.5rem', color: 'var(--primary-color)' }}>
|
||||
<Code /> {t('travelmate.tech.api')}
|
||||
</h3>
|
||||
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||
{[1, 2, 3].map(i => (
|
||||
<li key={i} style={{ marginBottom: '0.8rem', paddingLeft: '1rem', borderLeft: '2px solid var(--primary-color)' }}>
|
||||
{t(`travelmate.tech.api.${i}`)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Policies CTA */}
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
style={{
|
||||
padding: '2rem',
|
||||
background: 'var(--card-bg, rgba(255, 255, 255, 0.05))',
|
||||
borderRadius: '1rem',
|
||||
border: '1px solid var(--border-color, rgba(255, 255, 255, 0.1))',
|
||||
textAlign: 'center',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto'
|
||||
}}
|
||||
>
|
||||
<Shield className="text-primary" size={48} style={{ marginBottom: '1rem', color: 'var(--primary-color)' }} />
|
||||
<h3 style={{ marginBottom: '1.5rem' }}>
|
||||
{t('policies.title')}
|
||||
</h3>
|
||||
<Link
|
||||
to={`/${language}/travelmate/policies`}
|
||||
className="btn btn-secondary"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
textDecoration: 'none'
|
||||
}}
|
||||
>
|
||||
{t('travelmate.policies.link')}
|
||||
</Link>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TravelMate;
|
||||
@@ -2,7 +2,7 @@
|
||||
// CONTEXTE DE LANGUE
|
||||
// ========================
|
||||
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// Types pour les langues supportées
|
||||
@@ -139,9 +139,77 @@ const translations = {
|
||||
'education.highschool.highlights.1': 'Sciences (Physique, Chimie, Biologie)',
|
||||
'education.highschool.highlights.2': 'Langues',
|
||||
|
||||
// Travel Mate Page
|
||||
'travelmate.page.mainTitle': 'Travel Mate 🌍',
|
||||
'travelmate.page.subtitle': 'Le compagnon de voyage indispensable',
|
||||
'travelmate.page.intro': 'Redécouvrez le voyage en groupe avec Travel Mate. Plus qu\'une simple application, c\'est votre copilote pour des aventures sans friction. Oubliez les tableaux Excel complexes et les débats sur "qui doit combien". Concentrez-vous sur l\'essentiel : créer des souvenirs inoubliables.',
|
||||
|
||||
'travelmate.highlights.title': '✨ Points Forts',
|
||||
'travelmate.highlight.1.title': 'Gestion de Groupe Simplifiée',
|
||||
'travelmate.highlight.1.desc': 'Créez votre voyage et invitez vos compagnons en un clic via un lien unique. L\'organisation démarre instantanément.',
|
||||
'travelmate.highlight.2.title': 'Dépenses Maîtrisées (Split)',
|
||||
'travelmate.highlight.2.desc': 'Suivez les dépenses en temps réel et laissez l\'application équilibrer les comptes automatiquement. Fini les calculs compliqués !',
|
||||
'travelmate.highlight.3.title': 'Planification Collaborative',
|
||||
'travelmate.highlight.3.desc': 'Un agenda partagé et une carte interactive pour que chacun puisse proposer et visualiser les activités du groupe.',
|
||||
'travelmate.highlight.4.title': 'Communication Fluide',
|
||||
'travelmate.highlight.4.desc': 'Un chat intégré pour centraliser les discussions et garder tout le monde sur la même longueur d\'onde.',
|
||||
|
||||
'travelmate.page.conclusion': 'Prêt à partir ? Avec Travel Mate, l\'aventure commence dès l\'organisation. Voyagez l\'esprit léger, on s\'occupe du reste.',
|
||||
|
||||
'travelmate.tech.title': '🛠️ Technologies utilisées',
|
||||
'travelmate.tech.frontend': 'Frontend Mobile (Flutter)',
|
||||
'travelmate.tech.frontend.1': 'Architecture BLoC pour une gestion d\'état robuste',
|
||||
'travelmate.tech.frontend.2': 'Interface utilisateur fluide et réactive',
|
||||
'travelmate.tech.frontend.3': 'Intégration native (Caméra, GPS, Notifications)',
|
||||
'travelmate.tech.backend': 'Backend (Firebase)',
|
||||
'travelmate.tech.backend.1': 'Authentication (Google, Apple, Email)',
|
||||
'travelmate.tech.backend.2': 'Firestore (Base de données temps réel)',
|
||||
'travelmate.tech.backend.3': 'Cloud Functions (Logique serveur)',
|
||||
'travelmate.tech.backend.4': 'Storage (Médias)',
|
||||
'travelmate.tech.api': 'APIs & Outils',
|
||||
'travelmate.tech.api.1': 'Google Maps API / Mapbox',
|
||||
'travelmate.tech.api.2': 'Stripe (tbc) pour les paiements',
|
||||
'travelmate.tech.api.3': 'CI/CD avec Gitea Actions',
|
||||
'travelmate.viewCode': 'Voir le code',
|
||||
|
||||
'travelmate.policies.link': 'Voir les politiques de confidentialité',
|
||||
|
||||
// Policies Page
|
||||
'policies.back': 'Retour',
|
||||
'policies.title': 'Politiques de confidentialité',
|
||||
'policies.intro': 'Votre vie privée est importante pour nous. Voici comment nous protégeons vos données.',
|
||||
|
||||
'policies.section.1.title': 'Collecte d\'informations',
|
||||
'policies.section.1.content': 'Nous collectons des informations que vous nous fournissez directement, comme votre nom, adresse e-mail et préférences de voyage.',
|
||||
|
||||
'policies.permissions.title': 'Permissions Appareil',
|
||||
'policies.data.camera': 'Caméra & Galerie',
|
||||
'policies.data.camera.desc': 'Pour ajouter des photos aux souvenirs ou scanner des factures.',
|
||||
'policies.data.gps': 'Localisation (GPS)',
|
||||
'policies.data.gps.desc': 'Pour la carte interactive et les suggestions d\'activités à proximité.',
|
||||
'policies.data.notif': 'Notifications',
|
||||
'policies.data.notif.desc': 'Pour vous alerter des nouveaux messages, dépenses ou changements de programme.',
|
||||
|
||||
'policies.section.2.title': 'Utilisation des données',
|
||||
'policies.section.2.content': 'Vos données sont utilisées pour améliorer votre expérience utilisateur et vous proposer des recommandations personnalisées.',
|
||||
|
||||
'policies.section.3.title': 'Protection des données',
|
||||
'policies.section.3.content': 'Nous mettons en place des mesures de sécurité appropriées pour protéger vos informations personnelles.',
|
||||
|
||||
'policies.section.4.title': 'Partage des données',
|
||||
'policies.section.4.content': 'Nous ne partageons pas vos informations personnelles avec des tiers sans votre consentement explicite.',
|
||||
|
||||
'policies.section.5.title': 'Vos droits',
|
||||
'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.googleBtn': 'Politique de confidentialité Google',
|
||||
|
||||
// Contact
|
||||
'contact.title': 'Contactez-moi',
|
||||
'contact.subtitle': 'Une question, un projet ou simplement envie d\'échanger ? N\'hésitez pas à me contacter !',
|
||||
'contact.subtitle': 'Une question, un projet ou simplement envie de discuter ? N\'hésitez pas à me contacter !',
|
||||
'contact.form.name': 'Nom complet',
|
||||
'contact.form.email': 'Email',
|
||||
'contact.form.subject': 'Sujet',
|
||||
@@ -150,7 +218,7 @@ const translations = {
|
||||
'contact.form.sending': 'Envoi en cours...',
|
||||
'contact.success': 'Message envoyé avec succès ! Je vous répondrai bientôt.',
|
||||
'contact.stayInTouch': 'Restons en contact',
|
||||
'contact.intro': 'Je suis toujours intéressé par de nouveaux projets, des collaborations ou simplement des discussions autour de la technologie. N\'hésitez pas à me contacter !',
|
||||
'contact.intro': 'Je suis toujours intéressé par de nouveaux projets, des collaborations ou simplement des discussions sur la technologie. N\'hésitez pas à me contacter !',
|
||||
'contact.findMeOn': 'Retrouvez-moi aussi sur :',
|
||||
'contact.sendMessage': 'Envoyez-moi un message',
|
||||
},
|
||||
@@ -276,6 +344,75 @@ const translations = {
|
||||
'education.highschool.highlights.1': 'Sciences (Physics, Chemistry, Biology)',
|
||||
'education.highschool.highlights.2': 'Languages',
|
||||
|
||||
// Travel Mate Page
|
||||
'travelmate.page.mainTitle': 'Travel Mate 🌍',
|
||||
'travelmate.page.subtitle': 'The essential travel companion',
|
||||
'travelmate.page.intro': 'Rediscover group travel with Travel Mate. More than just an app, it\'s your essential co-pilot for friction-free adventures. Forget complex spreadsheets and debates about "who owes what". Focus on what matters: making unforgettable memories.',
|
||||
|
||||
'travelmate.highlights.title': '✨ Key Highlights',
|
||||
'travelmate.highlight.1.title': 'Simplified Group Management',
|
||||
'travelmate.highlight.1.desc': 'Create your trip and invite companions with a single click via a unique link. Organization starts instantly.',
|
||||
'travelmate.highlight.2.title': 'Mastered Expenses (Split)',
|
||||
'travelmate.highlight.2.desc': 'Track expenses in real-time and let the app balance accounts automatically. No more complicated math!',
|
||||
'travelmate.highlight.3.title': 'Collaborative Planning',
|
||||
'travelmate.highlight.3.desc': 'A shared agenda and interactive map so everyone can suggest and visualize group activities.',
|
||||
'travelmate.highlight.4.title': 'Seamless Communication',
|
||||
'travelmate.highlight.4.desc': 'An integrated chat to centralize discussions and keep everyone on the same page.',
|
||||
|
||||
'travelmate.page.conclusion': 'Ready to go? With Travel Mate, the adventure begins with the planning. Travel with peace of mind, we\'ll handle the rest.',
|
||||
|
||||
'travelmate.tech.title': '🛠️ Technologies Used',
|
||||
'travelmate.tech.frontend': 'Mobile Frontend (Flutter)',
|
||||
'travelmate.tech.frontend.1': 'BLoC Architecture for robust state management',
|
||||
'travelmate.tech.frontend.2': 'Fluid and responsive User Interface',
|
||||
'travelmate.tech.frontend.3': 'Native Integration (Camera, GPS, Notifications)',
|
||||
'travelmate.tech.backend': 'Backend (Firebase)',
|
||||
'travelmate.tech.backend.1': 'Authentication (Google, Apple, Email)',
|
||||
'travelmate.tech.backend.2': 'Firestore (Real-time Database)',
|
||||
'travelmate.tech.backend.3': 'Cloud Functions (Server Logic)',
|
||||
'travelmate.tech.backend.4': 'Firebase Cloud Messaging - Notifications push',
|
||||
'travelmate.tech.backend.5': 'Storage (Media)',
|
||||
'travelmate.tech.api': 'APIs & Tools',
|
||||
'travelmate.tech.api.1': 'Google Maps API / Mapbox',
|
||||
'travelmate.tech.api.2': 'Stripe (tbc) for payments',
|
||||
'travelmate.tech.api.3': 'CI/CD with Gitea Actions',
|
||||
'travelmate.viewCode': 'View Code',
|
||||
|
||||
'travelmate.policies.link': 'View Privacy Policy',
|
||||
|
||||
// Policies Page
|
||||
'policies.back': 'Back',
|
||||
'policies.title': 'Privacy Policy',
|
||||
'policies.intro': 'Your privacy is important to us. Here is how we protect your data.',
|
||||
|
||||
'policies.section.1.title': 'Information Collection',
|
||||
'policies.section.1.content': 'We collect information that you provide directly to us, such as your name, email address, and travel preferences.',
|
||||
|
||||
'policies.permissions.title': 'Device Permissions',
|
||||
'policies.data.camera': 'Camera & Gallery',
|
||||
'policies.data.camera.desc': 'To add photos to memories or scan receipts.',
|
||||
'policies.data.gps': 'Location (GPS)',
|
||||
'policies.data.gps.desc': 'For the interactive map and nearby activity suggestions.',
|
||||
'policies.data.notif': 'Notifications',
|
||||
'policies.data.notif.desc': 'To alert you of new messages, expenses, or schedule changes.',
|
||||
|
||||
'policies.section.2.title': 'Data Usage',
|
||||
'policies.section.2.content': 'Your data is used to improve your user experience and offer you personalized recommendations.',
|
||||
|
||||
'policies.section.3.title': 'Data Protection',
|
||||
'policies.section.3.content': 'We implement appropriate security measures to protect your personal information.',
|
||||
|
||||
'policies.section.4.title': 'Data Sharing',
|
||||
'policies.section.4.content': 'We do not share your personal information with third parties without your explicit consent.',
|
||||
|
||||
'policies.section.5.title': 'Your Rights',
|
||||
'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.googleBtn': 'Google Privacy Policy',
|
||||
|
||||
// Contact
|
||||
'contact.title': 'Contact me',
|
||||
'contact.subtitle': 'A question, a project or just want to chat? Feel free to contact me!',
|
||||
@@ -301,16 +438,48 @@ interface LanguageProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||
|
||||
export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children }) => {
|
||||
const [language, setLanguage] = useState<Language>('fr');
|
||||
const { lang } = useParams<{ lang: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Validate language, default to 'fr' if undefined or invalid (though App.tsx handles defaults)
|
||||
const currentLanguage: Language = (lang === 'en' || lang === 'fr') ? lang : 'fr';
|
||||
|
||||
const setLanguage = (newLang: Language) => {
|
||||
if (newLang === currentLanguage) return;
|
||||
|
||||
// Replace the language segment in the URL
|
||||
// Assumes the first segment is the language
|
||||
const currentPath = location.pathname;
|
||||
const segments = currentPath.split('/');
|
||||
// segments[0] is empty string (before first slash)
|
||||
// segments[1] is the language
|
||||
if (segments[1] === 'fr' || segments[1] === 'en') {
|
||||
segments[1] = newLang;
|
||||
} else {
|
||||
// Should not happen if routing is correct, but safe fallback
|
||||
segments.splice(1, 0, newLang);
|
||||
}
|
||||
const newPath = segments.join('/');
|
||||
|
||||
navigate(newPath + location.search + location.hash);
|
||||
};
|
||||
|
||||
// Update HTML lang attribute
|
||||
React.useEffect(() => {
|
||||
document.documentElement.lang = currentLanguage;
|
||||
}, [currentLanguage]);
|
||||
|
||||
// Fonction de traduction
|
||||
const t = (key: string): string => {
|
||||
return translations[language][key as keyof typeof translations['fr']] || key;
|
||||
return translations[currentLanguage][key as keyof typeof translations['fr']] || key;
|
||||
};
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={{ language, setLanguage, t }}>
|
||||
<LanguageContext.Provider value={{ language: currentLanguage, setLanguage, t }}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
51
src/App.tsx
51
src/App.tsx
@@ -1,51 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { LanguageProvider } from './contexts/LanguageContext';
|
||||
import Header from './components/Header';
|
||||
import Hero from './components/Hero';
|
||||
import About from './components/About';
|
||||
import Skills from './components/Skills';
|
||||
import Projects from './components/Projects';
|
||||
import Education from './components/Education';
|
||||
import Contact from './components/Contact';
|
||||
import './styles/main.scss';
|
||||
|
||||
function App() {
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('darkMode');
|
||||
if (savedTheme) {
|
||||
setDarkMode(JSON.parse(savedTheme));
|
||||
} else {
|
||||
// Détection automatique du thème préféré de l'utilisateur
|
||||
setDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
||||
document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light');
|
||||
}, [darkMode]);
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
setDarkMode(!darkMode);
|
||||
};
|
||||
|
||||
return (
|
||||
<LanguageProvider>
|
||||
<div className={`app ${darkMode ? 'dark' : 'light'}`}>
|
||||
<Header darkMode={darkMode} toggleDarkMode={toggleDarkMode} />
|
||||
<main>
|
||||
<Hero />
|
||||
<About />
|
||||
<Skills />
|
||||
<Projects />
|
||||
<Education />
|
||||
<Contact />
|
||||
</main>
|
||||
</div>
|
||||
</LanguageProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
Reference in New Issue
Block a user