Compare commits

..

10 Commits

Author SHA1 Message Date
Van Leemput Dayron
572159b45a feat: Add data erasure request form and a new backend API endpoint for message submission. 2025-12-15 17:40:14 +01:00
Van Leemput Dayron
6c11cf5213 feat: Initialize backend with Express and MySQL, and restructure frontend with new routing and language support. 2025-12-15 17:03:03 +01:00
Van Leemput Dayron
56897a0c2d feat: Add ScrollToTop component to reset scroll position on route changes. 2025-12-15 00:45:24 +01:00
Van Leemput Dayron
a01c6c4356 feat: Dynamically set document title for Home and TravelMate pages. 2025-12-08 15:19:43 +01:00
Van Leemput Dayron
8f86da9925 feat: add French translations for the contact section 2025-12-08 15:08:31 +01:00
Van Leemput Dayron
00999578f0 feat: Add visual permission cards and intro text to the Policies page, including new icons, translations, and styling adjustments. 2025-12-06 16:30:51 +01:00
Van Leemput Dayron
b148b4d90e refactor: Policies page now dynamically renders sections and includes a Google privacy policy link, removing hardcoded content and tech details. 2025-12-06 16:23:08 +01:00
Van Leemput Dayron
28a8dcd170 feat: Add TravelMate project page and restructure site with routing, including new Home, Policies, and Footer components. 2025-12-06 12:12:01 +01:00
Van Leemput Dayron
404e493aa8 feat: Implement a 15-minute rate limit per email for contact form submissions. 2025-12-02 08:58:56 +01:00
Van Leemput Dayron
0518588121 feat: remove GitHub links and icons from project cards 2025-12-01 15:33:59 +01:00
56 changed files with 3376 additions and 238 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,8 @@
{
"hash": "a5aca759",
"configHash": "b6b52121",
"lockfileHash": "e3b0c442",
"browserHash": "5526a657",
"optimized": {},
"chunks": {}
}

3
.vite/deps/package.json Normal file
View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

View File

@@ -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
View File

@@ -0,0 +1,4 @@
node_modules
.env
.env.example
.env.local

1769
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
backend/package.json Normal file
View 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
View 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;

36
backend/src/index.ts Normal file
View File

@@ -0,0 +1,36 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import pool from './config/db';
import messagesRouter from './routes/messages';
dotenv.config();
const app = express();
const port = process.env.PORT || 3000;
app.use(cors());
app.use(express.json());
app.use('/api/messages', messagesRouter);
// 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}`);
});

View File

@@ -0,0 +1,51 @@
import { Router, Request, Response } from 'express';
import pool from '../config/db';
import { RowDataPacket, ResultSetHeader } from 'mysql2';
const router = Router();
// Validation helper
const isValidEmail = (email: string) => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
};
router.post('/', async (req: Request, res: Response): Promise<void> => {
const { nom, prenom, email, message } = req.body;
// 1. Validation
const errors: string[] = [];
if (!nom || nom.trim() === '') errors.push('Le nom est requis.');
if (!prenom || prenom.trim() === '') errors.push('Le prénom est requis.');
if (!email || email.trim() === '') {
errors.push("L'email est requis.");
} else if (!isValidEmail(email)) {
errors.push("L'format de l'email est invalide.");
}
// if (!message || message.trim() === '') errors.push('Le message est requis.'); // Message is now optional
if (errors.length > 0) {
res.status(400).json({ status: 'error', errors });
return;
}
// Default message if empty
const messageContent = (!message || message.trim() === '') ? 'Supprimer toutes les données' : message;
// 2. Insert into Database
try {
const query = 'INSERT INTO messages (nom, prenom, email, message) VALUES (?, ?, ?, ?)';
const [result] = await pool.query<ResultSetHeader>(query, [nom, prenom, email, messageContent]);
res.status(201).json({
status: 'success',
message: 'Demande envoyée avec succès.',
id: result.insertId
});
} catch (error: any) {
console.error('Error inserting message:', error);
res.status(500).json({ status: 'error', message: 'Erreur serveur lors de la sauvegarde.' });
}
});
export default router;

18
backend/tsconfig.json Normal file
View 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"
]
}

View File

View File

@@ -9,10 +9,12 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@emailjs/browser": "^4.4.1", "@emailjs/browser": "^4.4.1",
"axios": "^1.13.2",
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"react-router-dom": "^7.10.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
@@ -2134,6 +2136,23 @@
"dev": true, "dev": true,
"license": "Python-2.0" "license": "Python-2.0"
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -2210,6 +2229,19 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -2294,6 +2326,18 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2308,6 +2352,19 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2355,6 +2412,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
@@ -2369,6 +2435,20 @@
"node": ">=0.10" "node": ">=0.10"
} }
}, },
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.250", "version": "1.5.250",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.250.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.250.tgz",
@@ -2376,6 +2456,51 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -2744,6 +2869,42 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/framer-motion": { "node_modules/framer-motion": {
"version": "12.23.24", "version": "12.23.24",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz",
@@ -2786,6 +2947,15 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gensync": { "node_modules/gensync": {
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -2796,6 +2966,43 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2822,6 +3029,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graphemer": { "node_modules/graphemer": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
@@ -2839,6 +3058,45 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3056,6 +3314,15 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -3080,6 +3347,27 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -3298,6 +3586,12 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -3362,6 +3656,44 @@
"node": ">=0.10.0" "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": { "node_modules/readdirp": {
"version": "4.1.2", "version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -3501,6 +3833,12 @@
"semver": "bin/semver.js" "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": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",

View File

@@ -11,10 +11,12 @@
}, },
"dependencies": { "dependencies": {
"@emailjs/browser": "^4.4.1", "@emailjs/browser": "^4.4.1",
"axios": "^1.13.2",
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
"lucide-react": "^0.553.0", "lucide-react": "^0.553.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0",
"react-router-dom": "^7.10.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

76
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,76 @@
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 Home from './components/Home';
import Footer from './components/Footer';
import Policies from './components/Policies';
import TravelMate from './components/TravelMate';
import EraseData from './components/TravelMate/EraseData';
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="policies" element={<Policies />} />
</Route>
<Route path="/travelmate/erasedata" element={<EraseData />} />
<Route path="/policies" element={<Policies />} />
</Routes>
</main>
<Footer />
</div>
</>
);
}
export default App;

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 KiB

View File

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -27,9 +27,24 @@ const Contact = () => {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); 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); setIsSubmitting(true);
setError(null); setError(null);
try { try {
const contactData: ContactFormData = { const contactData: ContactFormData = {
name: formData.name, name: formData.name,
@@ -37,13 +52,14 @@ const Contact = () => {
subject: formData.subject, subject: formData.subject,
message: formData.message message: formData.message
}; };
const result = await sendContactEmail(contactData); const result = await sendContactEmail(contactData);
if (result.success) { if (result.success) {
localStorage.setItem(`lastMessageTime_${formData.email}`, Date.now().toString());
setIsSubmitted(true); setIsSubmitted(true);
setFormData({ name: '', email: '', subject: '', message: '' }); setFormData({ name: '', email: '', subject: '', message: '' });
// Reset du message de succès après 5 secondes // Reset du message de succès après 5 secondes
setTimeout(() => { setTimeout(() => {
setIsSubmitted(false); setIsSubmitted(false);
@@ -51,7 +67,7 @@ const Contact = () => {
} else { } else {
setError(result.message); setError(result.message);
} }
} catch (err) { } catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Une erreur est survenue lors de l\'envoi du message.'; const errorMessage = err instanceof Error ? err.message : 'Une erreur est survenue lors de l\'envoi du message.';
setError(errorMessage); setError(errorMessage);
@@ -72,7 +88,7 @@ const Contact = () => {
{ {
icon: <Phone size={24} />, icon: <Phone size={24} />,
title: "Téléphone", title: "Téléphone",
content: "+32 455 19 47 62", content: "+32 455 19 47 62",
link: "tel:+32455194762", link: "tel:+32455194762",
color: "#34A853" color: "#34A853"
}, },
@@ -89,7 +105,7 @@ const Contact = () => {
{ {
icon: <Github size={24} />, icon: <Github size={24} />,
name: "GitHub", name: "GitHub",
url: "https://github.com/Dayron-HELHa", // Remplacez par votre profil url: "https://git.xeewy.be/Xeewy", // Remplacez par votre profil
color: "#333" color: "#333"
}, },
{ {
@@ -176,7 +192,7 @@ const Contact = () => {
}} }}
viewport={{ once: true }} viewport={{ once: true }}
> >
<div <div
className="contact-icon" className="contact-icon"
style={{ backgroundColor: `${contact.color}20`, color: contact.color }} style={{ backgroundColor: `${contact.color}20`, color: contact.color }}
> >
@@ -210,7 +226,7 @@ const Contact = () => {
}} }}
viewport={{ once: true }} viewport={{ once: true }}
> >
<div <div
className="social-icon" className="social-icon"
style={{ backgroundColor: `${social.color}20`, color: social.color }} style={{ backgroundColor: `${social.color}20`, color: social.color }}
> >
@@ -226,7 +242,7 @@ const Contact = () => {
{/* Formulaire de contact */} {/* Formulaire de contact */}
<motion.div className="contact-form-container" variants={itemVariants}> <motion.div className="contact-form-container" variants={itemVariants}>
<h3>{t('contact.sendMessage')}</h3> <h3>{t('contact.sendMessage')}</h3>
{isSubmitted && ( {isSubmitted && (
<motion.div <motion.div
className="success-message" className="success-message"
@@ -238,7 +254,7 @@ const Contact = () => {
{t('contact.success')} {t('contact.success')}
</motion.div> </motion.div>
)} )}
{error && ( {error && (
<motion.div <motion.div
className="error-message" className="error-message"
@@ -253,7 +269,7 @@ const Contact = () => {
<form onSubmit={handleSubmit} className="contact-form"> <form onSubmit={handleSubmit} className="contact-form">
<div className="form-row"> <div className="form-row">
<motion.div <motion.div
className="form-group" className="form-group"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
@@ -272,7 +288,7 @@ const Contact = () => {
/> />
</motion.div> </motion.div>
<motion.div <motion.div
className="form-group" className="form-group"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
@@ -292,7 +308,7 @@ const Contact = () => {
</motion.div> </motion.div>
</div> </div>
<motion.div <motion.div
className="form-group" className="form-group"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
@@ -311,7 +327,7 @@ const Contact = () => {
/> />
</motion.div> </motion.div>
<motion.div <motion.div
className="form-group" className="form-group"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
@@ -357,18 +373,6 @@ const Contact = () => {
</motion.div> </motion.div>
</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> </div>
</section> </section>
); );

View 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;

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Menu, X, Sun, Moon, Globe } from 'lucide-react'; import { Menu, X, Sun, Moon, Globe } from 'lucide-react';
import { useLanguage } from '../contexts/LanguageContext'; import { useLanguage } from '../contexts/LanguageContext';
import { useNavigate, useLocation, Link } from 'react-router-dom';
interface HeaderProps { interface HeaderProps {
darkMode: boolean; darkMode: boolean;
@@ -11,6 +12,8 @@ interface HeaderProps {
const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => { const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
const { language, setLanguage, t } = useLanguage(); const { language, setLanguage, t } = useLanguage();
const navigate = useNavigate();
const location = useLocation();
const menuItems = [ const menuItems = [
{ id: 'home', name: t('nav.home'), href: '#hero' }, { 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' } { id: 'contact', name: t('nav.contact'), href: '#contact' }
]; ];
const scrollToSection = (href: string) => { const handleNavigation = (href: string) => {
const element = document.querySelector(href);
if (element) {
element.scrollIntoView({ behavior: 'smooth' });
}
setIsMenuOpen(false); 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 = () => { const toggleLanguage = () => {
@@ -46,9 +63,12 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }} 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 Dayron Van Leemput
</a> </Link>
</motion.div> </motion.div>
{/* Navigation desktop */} {/* Navigation desktop */}
@@ -64,7 +84,7 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
href={item.href} href={item.href}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
scrollToSection(item.href); handleNavigation(item.href);
}} }}
> >
{item.name} {item.name}
@@ -123,7 +143,7 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
href={item.href} href={item.href}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
scrollToSection(item.href); handleNavigation(item.href);
}} }}
> >
{item.name} {item.name}

View File

@@ -5,7 +5,7 @@ import dvlPhoto from '../assets/dvl.jpg';
const Hero = () => { const Hero = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const handleDownloadCV = () => { const handleDownloadCV = () => {
// Ici, vous pouvez ajouter le lien vers votre CV // Ici, vous pouvez ajouter le lien vers votre CV
const link = document.createElement('a'); const link = document.createElement('a');
@@ -32,7 +32,7 @@ const Hero = () => {
> >
{t('hero.title')} {t('hero.title')}
</motion.h1> </motion.h1>
<motion.h2 <motion.h2
className="hero-subtitle" className="hero-subtitle"
initial={{ opacity: 0, x: 50 }} initial={{ opacity: 0, x: 50 }}
@@ -41,7 +41,7 @@ const Hero = () => {
> >
{t('hero.subtitle')} {t('hero.subtitle')}
</motion.h2> </motion.h2>
<motion.p <motion.p
className="hero-description" className="hero-description"
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
@@ -66,7 +66,7 @@ const Hero = () => {
<Download size={20} /> <Download size={20} />
{t('btn.downloadCV')} {t('btn.downloadCV')}
</motion.button> </motion.button>
<motion.a <motion.a
href="#contact" href="#contact"
onClick={(e) => { onClick={(e) => {
@@ -89,7 +89,7 @@ const Hero = () => {
transition={{ duration: 0.8, delay: 1.2 }} transition={{ duration: 0.8, delay: 1.2 }}
> >
<motion.a <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" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="social-link" className="social-link"
@@ -98,7 +98,7 @@ const Hero = () => {
> >
<Github size={24} /> <Github size={24} />
</motion.a> </motion.a>
<motion.a <motion.a
href="https://www.linkedin.com/in/dayron-van-leemput-992a94398" // Remplacez par votre profil LinkedIn href="https://www.linkedin.com/in/dayron-van-leemput-992a94398" // Remplacez par votre profil LinkedIn
target="_blank" target="_blank"
@@ -124,8 +124,8 @@ const Hero = () => {
whileHover={{ scale: 1.05, rotate: 5 }} whileHover={{ scale: 1.05, rotate: 5 }}
transition={{ type: "spring", stiffness: 300, damping: 10 }} transition={{ type: "spring", stiffness: 300, damping: 10 }}
> >
<img <img
src={dvlPhoto} src={dvlPhoto}
alt="Dayron Van Leemput - Portrait" alt="Dayron Van Leemput - Portrait"
className="avatar-image" className="avatar-image"
/> />

View 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;

View 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;

View File

@@ -1,9 +1,10 @@
import { motion } from 'framer-motion'; 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 { useLanguage } from '../contexts/LanguageContext';
import { Link } from 'react-router-dom';
const Projects = () => { const Projects = () => {
const { t } = useLanguage(); const { t, language } = useLanguage();
const projects = [ const projects = [
{ {
id: 1, id: 1,
@@ -20,8 +21,7 @@ const Projects = () => {
color: "#4CAF50", color: "#4CAF50",
icon: <MapPin size={24} />, icon: <MapPin size={24} />,
links: { links: {
github: "https://github.com/Dayron-HELHa/travel_mate", // Remplacez par votre lien GitHub demo: "/travelmate"
demo: "#"
}, },
image: "/travel-mate-preview.png" // Ajoutez votre image dans le dossier public image: "/travel-mate-preview.png" // Ajoutez votre image dans le dossier public
}, },
@@ -39,7 +39,6 @@ const Projects = () => {
color: "#E91E63", // A distinct color color: "#E91E63", // A distinct color
icon: <Wine size={24} />, icon: <Wine size={24} />,
links: { links: {
github: "#", // Assuming private or not provided
demo: "https://shelbys.be" demo: "https://shelbys.be"
}, },
image: "/shelbys-preview.png" // Placeholder, user might need to add this image: "/shelbys-preview.png" // Placeholder, user might need to add this
@@ -59,7 +58,6 @@ const Projects = () => {
color: "#2196F3", color: "#2196F3",
icon: <ExternalLink size={24} />, icon: <ExternalLink size={24} />,
links: { links: {
github: "https://github.com/Dayron-HELHa/xeewy.eu", // Remplacez par votre lien
demo: "https://xeewy.be" // 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-footer">
<div className="project-links"> <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 !== "#" && ( {project.links.demo !== "#" && (
<motion.a project.links.demo.startsWith('/') ? (
href={project.links.demo} <Link to={`/${language}${project.links.demo}`} style={{ textDecoration: 'none' }}>
target="_blank" <motion.div
rel="noopener noreferrer" className="project-link primary"
className="project-link primary" whileHover={{ scale: 1.1 }}
whileHover={{ scale: 1.1 }} whileTap={{ scale: 0.95 }}
whileTap={{ scale: 0.95 }} >
> <ExternalLink size={20} />
<ExternalLink size={20} /> {t('projects.btn.viewProject')}
{t('projects.btn.viewProject')} </motion.div>
</motion.a> </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>
</div> </div>

View 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;

View File

@@ -0,0 +1,289 @@
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>
<div style={{ display: 'flex', gap: '1rem', justifyContent: 'center', flexWrap: 'wrap' }}>
<Link
to={`/${language}/travelmate/policies`}
className="btn btn-secondary"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '10px',
textDecoration: 'none'
}}
>
{t('travelmate.policies.link')}
</Link>
<Link
to={`/${language}/travelmate/erasedata`}
className="btn btn-secondary"
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '10px',
textDecoration: 'none',
background: 'rgba(239, 68, 68, 0.1)',
color: '#EF4444',
borderColor: 'rgba(239, 68, 68, 0.2)'
}}
>
<Shield size={18} />
{t('travelmate.erasedata.link')}
</Link>
</div>
</motion.div>
</motion.div>
<Outlet />
</div>
</div>
);
};
export default TravelMate;

View File

@@ -0,0 +1,200 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { useLanguage } from '../../contexts/LanguageContext';
import { ArrowLeft, Send, CheckCircle, AlertCircle } from 'lucide-react';
import { Link } from 'react-router-dom';
import axios from 'axios';
const EraseData = () => {
const { t, language } = useLanguage();
const [formData, setFormData] = useState({
nom: '',
prenom: '',
email: '',
message: '',
confirm: false
});
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.confirm) return;
setStatus('submitting');
setErrorMessage('');
try {
// Using absolute URL for localhost dev, in prod simple /api might work if proxied or configured
// Assuming localhost:3000 for backend based on previous steps
await axios.post('http://localhost:3000/api/messages', {
nom: formData.nom,
prenom: formData.prenom,
email: formData.email,
message: formData.message
});
setStatus('success');
setFormData({ nom: '', prenom: '', email: '', message: '', confirm: false });
} catch (error: any) {
console.error('Submission error', error);
setStatus('error');
setErrorMessage(error.response?.data?.message || 'Une erreur est survenue. Veuillez réessayer.');
}
};
const containerVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 }
};
return (
<div className="erase-data-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: '2rem',
textDecoration: 'none',
color: 'var(--text-color)',
opacity: 0.8
}}
>
<ArrowLeft size={18} />
{t('policies.back')}
</Link>
<motion.div
initial="hidden"
animate="visible"
variants={containerVariants}
style={{
background: 'var(--card-bg, rgba(255, 255, 255, 0.05))',
padding: '2.5rem',
borderRadius: '1.5rem',
border: '1px solid var(--border-color, rgba(255, 255, 255, 0.1))',
}}
>
<h1 style={{ marginBottom: '1.5rem', fontSize: '2rem' }}>{t('erasedata.title')}</h1>
<p style={{ marginBottom: '2rem', opacity: 0.8, lineHeight: '1.6' }}>
{t('erasedata.description')}
</p>
{status === 'success' ? (
<div style={{
padding: '2rem',
background: 'rgba(16, 185, 129, 0.1)',
borderRadius: '1rem',
border: '1px solid rgba(16, 185, 129, 0.2)',
textAlign: 'center',
color: '#10B981'
}}>
<CheckCircle size={48} style={{ marginBottom: '1rem' }} />
<h3>{t('erasedata.success.title')}</h3>
<p>{t('erasedata.success.message')}</p>
</div>
) : (
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>{t('erasedata.form.lastname')}</label>
<input
type="text"
required
value={formData.nom}
onChange={e => setFormData({ ...formData, nom: e.target.value })}
className="form-input"
style={{ width: '100%', padding: '0.8rem', borderRadius: '0.5rem', border: '1px solid var(--border-color)', background: 'rgba(0,0,0,0.2)', color: 'inherit' }}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>{t('erasedata.form.firstname')}</label>
<input
type="text"
required
value={formData.prenom}
onChange={e => setFormData({ ...formData, prenom: e.target.value })}
className="form-input"
style={{ width: '100%', padding: '0.8rem', borderRadius: '0.5rem', border: '1px solid var(--border-color)', background: 'rgba(0,0,0,0.2)', color: 'inherit' }}
/>
</div>
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>{t('erasedata.form.email')}</label>
<input
type="email"
required
value={formData.email}
onChange={e => setFormData({ ...formData, email: e.target.value })}
className="form-input"
style={{ width: '100%', padding: '0.8rem', borderRadius: '0.5rem', border: '1px solid var(--border-color)', background: 'rgba(0,0,0,0.2)', color: 'inherit' }}
/>
</div>
<div>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>{t('erasedata.form.message')}</label>
<textarea
required={false}
rows={4}
value={formData.message}
onChange={e => setFormData({ ...formData, message: e.target.value })}
className="form-input"
style={{ width: '100%', padding: '0.8rem', borderRadius: '0.5rem', border: '1px solid var(--border-color)', background: 'rgba(0,0,0,0.2)', color: 'inherit' }}
placeholder={t('erasedata.form.placeholder')}
/>
</div>
<label style={{ display: 'flex', alignItems: 'flex-start', gap: '10px', cursor: 'pointer', opacity: 0.9 }}>
<input
type="checkbox"
required
checked={formData.confirm}
onChange={e => setFormData({ ...formData, confirm: e.target.checked })}
style={{ marginTop: '4px', width: '18px', height: '18px' }}
/>
<span style={{ fontSize: '0.9rem' }}>
{t('erasedata.form.confirm')}
</span>
</label>
{status === 'error' && (
<div style={{ color: '#EF4444', display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.95rem' }}>
<AlertCircle size={16} />
{errorMessage}
</div>
)}
<button
type="submit"
disabled={!formData.confirm || status === 'submitting'}
className="btn btn-primary"
style={{
marginTop: '1rem',
padding: '1rem',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '10px',
opacity: (!formData.confirm || status === 'submitting') ? 0.5 : 1,
cursor: (!formData.confirm || status === 'submitting') ? 'not-allowed' : 'pointer'
}}
>
{status === 'submitting' ? t('erasedata.form.submitting') : (
<>
<Send size={18} />
{t('erasedata.form.submit')}
</>
)}
</button>
</form>
)}
</motion.div>
</div>
</div>
);
};
export default EraseData;

View File

@@ -2,7 +2,7 @@
// CONTEXTE DE LANGUE // CONTEXTE DE LANGUE
// ======================== // ========================
import React, { createContext, useContext, useState } from 'react'; import React, { createContext, useContext } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
// Types pour les langues supportées // Types pour les langues supportées
@@ -139,9 +139,92 @@ const translations = {
'education.highschool.highlights.1': 'Sciences (Physique, Chimie, Biologie)', 'education.highschool.highlights.1': 'Sciences (Physique, Chimie, Biologie)',
'education.highschool.highlights.2': 'Langues', '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',
// Erase Data Page
'erasedata.title': 'Demande de suppression de données',
'erasedata.description': 'Conformément au RGPD et à notre politique de confidentialité, vous pouvez demander la suppression de vos données personnelles associées à Travel Mate. Veuillez remplir le formulaire ci-dessous.',
'erasedata.success.title': 'Demande envoyée avec succès',
'erasedata.success.message': 'Nous traiterons votre demande dans les plus brefs délais.',
'erasedata.form.lastname': 'Nom',
'erasedata.form.firstname': 'Prénom',
'erasedata.form.email': 'Email du compte à supprimer',
'erasedata.form.message': 'Message / Raison (Optionnel)',
'erasedata.form.placeholder': 'Je souhaite supprimer mon compte car...',
'erasedata.form.confirm': 'Je confirme vouloir supprimer mes données personnelles. Je comprends que cette action est irréversible et entraînera la perte de l\'accès à mon compte Travel Mate.',
'erasedata.form.submit': 'Envoyer la demande',
'erasedata.form.submitting': 'Envoi en cours...',
'travelmate.policies.link': 'Voir les politiques de confidentialité',
'travelmate.erasedata.link': 'Supprimer mes données',
// 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
'contact.title': 'Contactez-moi', '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.name': 'Nom complet',
'contact.form.email': 'Email', 'contact.form.email': 'Email',
'contact.form.subject': 'Sujet', 'contact.form.subject': 'Sujet',
@@ -150,7 +233,7 @@ const translations = {
'contact.form.sending': 'Envoi en cours...', 'contact.form.sending': 'Envoi en cours...',
'contact.success': 'Message envoyé avec succès ! Je vous répondrai bientôt.', 'contact.success': 'Message envoyé avec succès ! Je vous répondrai bientôt.',
'contact.stayInTouch': 'Restons en contact', '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.findMeOn': 'Retrouvez-moi aussi sur :',
'contact.sendMessage': 'Envoyez-moi un message', 'contact.sendMessage': 'Envoyez-moi un message',
}, },
@@ -276,6 +359,90 @@ const translations = {
'education.highschool.highlights.1': 'Sciences (Physics, Chemistry, Biology)', 'education.highschool.highlights.1': 'Sciences (Physics, Chemistry, Biology)',
'education.highschool.highlights.2': 'Languages', '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',
// Erase Data Page
'erasedata.title': 'Data Erasure Request',
'erasedata.description': 'In accordance with GDPR and our privacy policy, you can request the deletion of your personal data associated with Travel Mate. Please fill out the form below.',
'erasedata.success.title': 'Request sent successfully',
'erasedata.success.message': 'We will process your request as soon as possible.',
'erasedata.form.lastname': 'Last Name',
'erasedata.form.firstname': 'First Name',
'erasedata.form.email': 'Account Email to Delete',
'erasedata.form.message': 'Message / Reason (Optional)',
'erasedata.form.placeholder': 'I wish to delete my account because...',
'erasedata.form.confirm': 'I confirm that I want to delete my personal data. I understand that this action is irreversible and will result in the loss of access to my Travel Mate account.',
'erasedata.form.submit': 'Send Request',
'erasedata.form.submitting': 'Sending...',
'travelmate.policies.link': 'View Privacy Policy',
'travelmate.erasedata.link': 'Request Data Erasure',
// 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
'contact.title': 'Contact me', 'contact.title': 'Contact me',
'contact.subtitle': 'A question, a project or just want to chat? Feel free to contact me!', 'contact.subtitle': 'A question, a project or just want to chat? Feel free to contact me!',
@@ -301,16 +468,48 @@ interface LanguageProviderProps {
children: ReactNode; children: ReactNode;
} }
import { useNavigate, useParams, useLocation } from 'react-router-dom';
export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children }) => { 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 // Fonction de traduction
const t = (key: string): string => { 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 ( return (
<LanguageContext.Provider value={{ language, setLanguage, t }}> <LanguageContext.Provider value={{ language: currentLanguage, setLanguage, t }}>
{children} {children}
</LanguageContext.Provider> </LanguageContext.Provider>
); );

View File

@@ -134,6 +134,19 @@ p {
max-width: 65ch; max-width: 65ch;
} }
// Classes globales pour les boutons
.btn {
@include button-base();
&-primary {
@include button-primary();
}
&-secondary {
@include button-secondary();
}
}
// Classes utilitaires // Classes utilitaires
.container { .container {
max-width: map-get($breakpoints, xl); max-width: map-get($breakpoints, xl);

View File

@@ -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;