Compare commits
32 Commits
cdff0c8c5c
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97fba0435a | ||
|
|
24dfc5d077 | ||
|
|
4a6d224dbd | ||
|
|
7e2672c262 | ||
|
|
5dec349259 | ||
|
|
cbbfe40d1b | ||
|
|
e39d496ce2 | ||
|
|
25589ddff9 | ||
|
|
35f11521f7 | ||
|
|
9896dfdcec | ||
|
|
539b0ad314 | ||
|
|
fc4e843ed3 | ||
|
|
572159b45a | ||
|
|
6c11cf5213 | ||
|
|
56897a0c2d | ||
|
|
a01c6c4356 | ||
|
|
8f86da9925 | ||
|
|
00999578f0 | ||
|
|
b148b4d90e | ||
|
|
28a8dcd170 | ||
|
|
404e493aa8 | ||
|
|
0518588121 | ||
|
|
59c1b8c8ad | ||
|
|
1318b357b8 | ||
|
|
806ef5e1d8 | ||
|
|
f4a5a6e396 | ||
|
|
16d5a224eb | ||
|
|
6d26784241 | ||
|
|
15e200dfab | ||
|
|
5be7a0c821 | ||
|
|
e8722cf25d | ||
|
|
a4b4423ff4 |
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"
|
||||||
|
}
|
||||||
68
Jenkinsfile
vendored
Normal file
68
Jenkinsfile
vendored
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
tools {
|
||||||
|
nodejs 'NodeJS 20'
|
||||||
|
}
|
||||||
|
|
||||||
|
environment {
|
||||||
|
FRONT_DEST = '/var/www/xeewy.be'
|
||||||
|
BACK_DEST = '/var/www/xeewy/backend'
|
||||||
|
SERVICE_NAME = 'xeewy-backend'
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
// --- FRONTEND (Rien ne change ici) ---
|
||||||
|
stage('Build Frontend') {
|
||||||
|
steps {
|
||||||
|
echo '--- Building React Frontend ---'
|
||||||
|
dir('frontend') {
|
||||||
|
sh 'npm install'
|
||||||
|
sh 'npm run build'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Deploy Frontend') {
|
||||||
|
steps {
|
||||||
|
echo '--- Deploying Frontend ---'
|
||||||
|
sh "rm -rf ${FRONT_DEST}/*"
|
||||||
|
sh "cp -r frontend/dist/* ${FRONT_DEST}/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- BACKEND (C'est ici qu'on change) ---
|
||||||
|
stage('Build Backend') {
|
||||||
|
steps {
|
||||||
|
echo '--- Building TypeScript Backend ---'
|
||||||
|
dir('backend') {
|
||||||
|
sh 'npm install'
|
||||||
|
sh 'npm run build' // Compile le TS vers JS (dossier dist/)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Deploy Backend') {
|
||||||
|
steps {
|
||||||
|
echo '--- Deploying Backend ---'
|
||||||
|
// On copie le dossier compilé (dist) vers le serveur
|
||||||
|
sh "rm -rf ${BACK_DEST}/*"
|
||||||
|
sh "cp backend/package.json ${BACK_DEST}/"
|
||||||
|
sh "cp backend/package-lock.json ${BACK_DEST}/"
|
||||||
|
|
||||||
|
// Copie du dossier dist (le résultat de la compilation)
|
||||||
|
sh "cp -r backend/dist ${BACK_DEST}/"
|
||||||
|
|
||||||
|
// Installation des dépendances de prod uniquement
|
||||||
|
sh "cd ${BACK_DEST} && npm install --production"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Restart Service') {
|
||||||
|
steps {
|
||||||
|
echo '--- Restarting Service ---'
|
||||||
|
sh "sudo systemctl restart ${SERVICE_NAME}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
38
backend/src/index.ts
Normal file
38
backend/src/index.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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);
|
||||||
|
import supportRouter from './routes/support';
|
||||||
|
app.use('/api/support', supportRouter);
|
||||||
|
|
||||||
|
// 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}`);
|
||||||
|
});
|
||||||
51
backend/src/routes/messages.ts
Normal file
51
backend/src/routes/messages.ts
Normal 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;
|
||||||
56
backend/src/routes/support.ts
Normal file
56
backend/src/routes/support.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Router, Request, Response } from 'express';
|
||||||
|
import pool from '../config/db';
|
||||||
|
import { 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, account_email, contact_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 (!account_email || account_email.trim() === '') {
|
||||||
|
errors.push("L'email du compte est requis.");
|
||||||
|
} else if (!isValidEmail(account_email)) {
|
||||||
|
errors.push("Le format de l'email du compte est invalide.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contact_email || contact_email.trim() === '') {
|
||||||
|
errors.push("L'email de contact est requis.");
|
||||||
|
} else if (!isValidEmail(contact_email)) {
|
||||||
|
errors.push("Le format de l'email de contact est invalide.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!message || message.trim() === '') errors.push('Le message est requis.');
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
res.status(400).json({ status: 'error', errors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Insert into Database
|
||||||
|
try {
|
||||||
|
const query = 'INSERT INTO support_requests (nom, prenom, account_email, contact_email, message) VALUES (?, ?, ?, ?, ?)';
|
||||||
|
const [result] = await pool.query<ResultSetHeader>(query, [nom, prenom, account_email, contact_email, message]);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
status: 'success',
|
||||||
|
message: 'Demande de support envoyée avec succès.',
|
||||||
|
id: result.insertId
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error inserting support request:', error);
|
||||||
|
res.status(500).json({ status: 'error', message: 'Erreur serveur lors de la sauvegarde.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
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"
|
||||||
|
]
|
||||||
|
}
|
||||||
2
.gitignore → frontend/.gitignore
vendored
2
.gitignore → frontend/.gitignore
vendored
@@ -22,3 +22,5 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
.env
|
||||||
|
EMAILJS_SETUP.md
|
||||||
@@ -144,10 +144,10 @@ Ce projet est sous licence MIT. Voir le fichier `LICENSE` pour plus de détails.
|
|||||||
|
|
||||||
**Dayron Van Leemput**
|
**Dayron Van Leemput**
|
||||||
- 🎓 Étudiant en Technologies de l'Informatique - HELHa Tournai
|
- 🎓 Étudiant en Technologies de l'Informatique - HELHa Tournai
|
||||||
- 💼 [LinkedIn](https://linkedin.com/in/dayronvanleemput)
|
- 💼 [LinkedIn](https://www.linkedin.com/in/dayron-van-leemput-992a94398)
|
||||||
- 🐱 [GitHub](https://github.com/dayronvanleemput)
|
- 🐱 [GitHub](https://github.com/Dayron-HELHa)
|
||||||
- 📧 Email : dayron.vanleemput@example.com
|
- 📧 Email : dev.dayronvl@gmail.com
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Développé avec ❤️ par Dayron Van Leemput - Novembre 2025*
|
*Développé par Dayron Van Leemput - Novembre 2025*
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/public/personnes.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>xeewy.eu</title>
|
<title>xeewy.be</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
350
package-lock.json → frontend/package-lock.json
generated
350
package-lock.json → frontend/package-lock.json
generated
@@ -8,10 +8,13 @@
|
|||||||
"name": "xeewy.eu",
|
"name": "xeewy.eu",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@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",
|
||||||
@@ -312,6 +315,15 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@emailjs/browser": {
|
||||||
|
"version": "4.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@emailjs/browser/-/browser-4.4.1.tgz",
|
||||||
|
"integrity": "sha512-DGSlP9sPvyFba3to2A50kDtZ+pXVp/0rhmqs2LmbMS3I5J8FSOgLwzY2Xb4qfKlOVHh29EAutLYwe5yuEZmEFg==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
@@ -2124,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",
|
||||||
@@ -2200,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",
|
||||||
@@ -2284,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",
|
||||||
@@ -2298,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",
|
||||||
@@ -2345,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",
|
||||||
@@ -2359,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",
|
||||||
@@ -2366,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",
|
||||||
@@ -2734,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",
|
||||||
@@ -2776,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",
|
||||||
@@ -2786,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",
|
||||||
@@ -2812,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",
|
||||||
@@ -2829,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",
|
||||||
@@ -3046,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",
|
||||||
@@ -3070,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",
|
||||||
@@ -3288,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",
|
||||||
@@ -3352,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",
|
||||||
@@ -3491,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",
|
||||||
@@ -10,10 +10,13 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@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",
|
||||||
1309
frontend/public/Dayron_Van_Leemput_CV.pdf
Normal file
1309
frontend/public/Dayron_Van_Leemput_CV.pdf
Normal file
File diff suppressed because it is too large
Load Diff
BIN
frontend/public/personnes.png
Normal file
BIN
frontend/public/personnes.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
79
frontend/src/App.tsx
Normal file
79
frontend/src/App.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
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 Support from './components/TravelMate/Support';
|
||||||
|
import HomeSync from './components/HomeSync';
|
||||||
|
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 />} />
|
||||||
|
<Route path="/travelmate/erasedata" element={<EraseData />} />
|
||||||
|
<Route path="/homesync" element={<HomeSync />} />
|
||||||
|
<Route path="/policies" element={<Policies />} />
|
||||||
|
<Route path="/travelmate/support" element={<Support />} />
|
||||||
|
</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 |
BIN
frontend/src/assets/dvl.jpg
Executable file
BIN
frontend/src/assets/dvl.jpg
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 94 KiB |
@@ -1,12 +1,15 @@
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { User, Heart, Target, Coffee } from 'lucide-react';
|
import { User, Heart, Target, Coffee } from 'lucide-react';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
|
||||||
const About = () => {
|
const About = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
const stats = [
|
const stats = [
|
||||||
{ icon: <User size={24} />, value: "3ème", label: "Année d'études" },
|
{ icon: <User size={24} />, value: t('about.stats.year'), label: t('about.stats.yearLabel') },
|
||||||
{ icon: <Heart size={24} />, value: "100%", label: "Passion" },
|
{ icon: <Heart size={24} />, value: t('about.stats.passion'), label: t('about.stats.passionLabel') },
|
||||||
{ icon: <Target size={24} />, value: "∞", label: "Objectifs" },
|
{ icon: <Target size={24} />, value: t('about.stats.goals'), label: t('about.stats.goalsLabel') },
|
||||||
{ icon: <Coffee size={24} />, value: "☕", label: "Fuel quotidien" }
|
{ icon: <Coffee size={24} />, value: t('about.stats.fuel'), label: t('about.stats.fuelLabel') }
|
||||||
];
|
];
|
||||||
|
|
||||||
const containerVariants = {
|
const containerVariants = {
|
||||||
@@ -36,20 +39,22 @@ const About = () => {
|
|||||||
<section id="about" className="about">
|
<section id="about" className="about">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
key="about-header"
|
||||||
className="section-header"
|
className="section-header"
|
||||||
initial={{ opacity: 0, y: 50 }}
|
initial={{ opacity: 0, y: 50 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
>
|
>
|
||||||
<h2 className="section-title">À propos de moi</h2>
|
<h2 className="section-title">{t('about.title')}</h2>
|
||||||
<p className="section-subtitle">
|
<p className="section-subtitle">
|
||||||
Découvrez qui je suis et ce qui me passionne
|
{t('about.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="about-content">
|
<div className="about-content">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
key="about-text"
|
||||||
className="about-text"
|
className="about-text"
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
@@ -57,37 +62,29 @@ const About = () => {
|
|||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
>
|
>
|
||||||
<motion.div className="about-card" variants={itemVariants}>
|
<motion.div className="about-card" variants={itemVariants}>
|
||||||
<h3>Mon parcours</h3>
|
<h3>{t('about.journey.title')}</h3>
|
||||||
<p>
|
<p>
|
||||||
Actuellement en 3ème année de <strong>Technologies de l'Informatique</strong> à la
|
{t('about.journey.content')}
|
||||||
HELHa de Tournai, je me passionne pour le développement d'applications et les
|
|
||||||
nouvelles technologies. Mon parcours m'a permis d'acquérir une solide base
|
|
||||||
technique et une approche méthodique du développement.
|
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div className="about-card" variants={itemVariants}>
|
<motion.div className="about-card" variants={itemVariants}>
|
||||||
<h3>Ma passion</h3>
|
<h3>{t('about.passion.title')}</h3>
|
||||||
<p>
|
<p>
|
||||||
Ce qui m'anime le plus, c'est la création d'solutions innovantes qui résolvent
|
{t('about.passion.content')}
|
||||||
des problèmes réels. J'aime particulièrement le développement mobile avec
|
|
||||||
<strong> Flutter</strong> et le développement web moderne avec
|
|
||||||
<strong> React</strong> et <strong> TypeScript</strong>.
|
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div className="about-card" variants={itemVariants}>
|
<motion.div className="about-card" variants={itemVariants}>
|
||||||
<h3>Mes objectifs</h3>
|
<h3>{t('about.goals.title')}</h3>
|
||||||
<p>
|
<p>
|
||||||
Je cherche constamment à améliorer mes compétences et à rester à jour avec
|
{t('about.goals.content')}
|
||||||
les dernières tendances technologiques. Mon objectif est de devenir un
|
|
||||||
développeur full-stack polyvalent et de contribuer à des projets qui ont
|
|
||||||
un impact positif.
|
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
|
key="about-stats"
|
||||||
className="about-stats"
|
className="about-stats"
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
@@ -96,7 +93,7 @@ const About = () => {
|
|||||||
>
|
>
|
||||||
{stats.map((stat, index) => (
|
{stats.map((stat, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={index}
|
key={`stat-${index}`}
|
||||||
className="stat-card"
|
className="stat-card"
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
whileHover={{
|
whileHover={{
|
||||||
@@ -116,6 +113,7 @@ const About = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
|
key="about-highlight"
|
||||||
className="about-highlight"
|
className="about-highlight"
|
||||||
initial={{ opacity: 0, scale: 0.9 }}
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
whileInView={{ opacity: 1, scale: 1 }}
|
whileInView={{ opacity: 1, scale: 1 }}
|
||||||
@@ -123,13 +121,11 @@ const About = () => {
|
|||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
>
|
>
|
||||||
<div className="highlight-content">
|
<div className="highlight-content">
|
||||||
<h3>En quelques mots</h3>
|
<h3>{t('about.quote.title')}</h3>
|
||||||
<p>
|
<p>
|
||||||
"La technologie n'est rien. Ce qui est important, c'est d'avoir la foi en les gens,
|
"{t('about.quote.content')}"
|
||||||
qu'ils soient fondamentalement bons et intelligents, et si vous leur donnez des outils,
|
|
||||||
ils feront des choses merveilleuses avec."
|
|
||||||
</p>
|
</p>
|
||||||
<cite>- Steve Jobs</cite>
|
<cite>- {t('about.quote.author')}</cite>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Mail, Phone, MapPin, Send, Github, Linkedin, MessageCircle, CheckCircle } from 'lucide-react';
|
import { Mail, Phone, MapPin, Send, Github, Linkedin, MessageCircle, CheckCircle, AlertCircle } from 'lucide-react';
|
||||||
|
import { sendContactEmail } from '../services/emailService';
|
||||||
|
import type { ContactFormData } from '../services/emailService';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
|
||||||
const Contact = () => {
|
const Contact = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
@@ -11,6 +15,7 @@ const Contact = () => {
|
|||||||
});
|
});
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
@@ -22,33 +27,68 @@ 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);
|
||||||
|
|
||||||
// Simulation d'envoi (remplacez par votre logique d'envoi réelle)
|
try {
|
||||||
setTimeout(() => {
|
const contactData: ContactFormData = {
|
||||||
|
name: formData.name,
|
||||||
|
email: formData.email,
|
||||||
|
subject: formData.subject,
|
||||||
|
message: formData.message
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await sendContactEmail(contactData);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
localStorage.setItem(`lastMessageTime_${formData.email}`, Date.now().toString());
|
||||||
|
setIsSubmitted(true);
|
||||||
|
setFormData({ name: '', email: '', subject: '', message: '' });
|
||||||
|
|
||||||
|
// Reset du message de succès après 5 secondes
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsSubmitted(false);
|
||||||
|
}, 5000);
|
||||||
|
} else {
|
||||||
|
setError(result.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
const errorMessage = err instanceof Error ? err.message : 'Une erreur est survenue lors de l\'envoi du message.';
|
||||||
|
setError(errorMessage);
|
||||||
|
console.error('Erreur lors de l\'envoi de l\'email:', err);
|
||||||
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
setIsSubmitted(true);
|
}
|
||||||
setFormData({ name: '', email: '', subject: '', message: '' });
|
|
||||||
|
|
||||||
// Reset du message de succès après 5 secondes
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsSubmitted(false);
|
|
||||||
}, 5000);
|
|
||||||
}, 2000);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const contactInfo = [
|
const contactInfo = [
|
||||||
{
|
{
|
||||||
icon: <Mail size={24} />,
|
icon: <Mail size={24} />,
|
||||||
title: "Email",
|
title: "Email",
|
||||||
content: "dayronvanleemput@gmail.com", // Remplacez par votre email
|
content: "dayronvanleemput@gmail.com",
|
||||||
link: "mailto:dayronvanleemput@gmail.com",
|
link: "mailto:dayronvanleemput@gmail.com",
|
||||||
color: "#EA4335"
|
color: "#EA4335"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Phone size={24} />,
|
icon: <Phone size={24} />,
|
||||||
title: "Téléphone",
|
title: "Téléphone",
|
||||||
content: "+32 455 19 47 62", // Remplacez par votre numéro
|
content: "+32 455 19 47 62",
|
||||||
link: "tel:+32455194762",
|
link: "tel:+32455194762",
|
||||||
color: "#34A853"
|
color: "#34A853"
|
||||||
},
|
},
|
||||||
@@ -65,13 +105,13 @@ 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"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Linkedin size={24} />,
|
icon: <Linkedin size={24} />,
|
||||||
name: "LinkedIn",
|
name: "LinkedIn",
|
||||||
url: "https://linkedin.com/in/dayronvanleemput", // Remplacez par votre profil
|
url: "https://www.linkedin.com/in/dayron-van-leemput-992a94398", // Remplacez par votre profil
|
||||||
color: "#0077B5"
|
color: "#0077B5"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -103,19 +143,21 @@ const Contact = () => {
|
|||||||
<section id="contact" className="contact">
|
<section id="contact" className="contact">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
key="contact-header"
|
||||||
className="section-header"
|
className="section-header"
|
||||||
initial={{ opacity: 0, y: 50 }}
|
initial={{ opacity: 0, y: 50 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
>
|
>
|
||||||
<h2 className="section-title">Contactez-moi</h2>
|
<h2 className="section-title">{t('contact.title')}</h2>
|
||||||
<p className="section-subtitle">
|
<p className="section-subtitle">
|
||||||
Une question, un projet ou simplement envie d'échanger ? N'hésitez pas à me contacter !
|
{t('contact.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
|
key="contact-content"
|
||||||
className="contact-content"
|
className="contact-content"
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
@@ -127,12 +169,10 @@ const Contact = () => {
|
|||||||
<div className="contact-intro">
|
<div className="contact-intro">
|
||||||
<h3>
|
<h3>
|
||||||
<MessageCircle size={24} />
|
<MessageCircle size={24} />
|
||||||
Restons en contact
|
{t('contact.stayInTouch')}
|
||||||
</h3>
|
</h3>
|
||||||
<p>
|
<p>
|
||||||
Je suis toujours intéressé par de nouveaux projets, des collaborations
|
{t('contact.intro')}
|
||||||
ou simplement des discussions autour de la technologie. N'hésitez pas
|
|
||||||
à me contacter !
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -167,7 +207,7 @@ const Contact = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="social-links">
|
<div className="social-links">
|
||||||
<h4>Retrouvez-moi aussi sur :</h4>
|
<h4>{t('contact.findMeOn')}</h4>
|
||||||
<div className="social-grid">
|
<div className="social-grid">
|
||||||
{socialLinks.map((social, index) => (
|
{socialLinks.map((social, index) => (
|
||||||
<motion.a
|
<motion.a
|
||||||
@@ -201,7 +241,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>Envoyez-moi un message</h3>
|
<h3>{t('contact.sendMessage')}</h3>
|
||||||
|
|
||||||
{isSubmitted && (
|
{isSubmitted && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -211,7 +251,19 @@ const Contact = () => {
|
|||||||
exit={{ opacity: 0, scale: 0.8 }}
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
>
|
>
|
||||||
<CheckCircle size={20} />
|
<CheckCircle size={20} />
|
||||||
Message envoyé avec succès ! Je vous répondrai bientôt.
|
{t('contact.success')}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
className="error-message"
|
||||||
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
|
>
|
||||||
|
<AlertCircle size={20} />
|
||||||
|
{error}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -224,7 +276,7 @@ const Contact = () => {
|
|||||||
transition={{ duration: 0.5, delay: 0.1 }}
|
transition={{ duration: 0.5, delay: 0.1 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
>
|
>
|
||||||
<label htmlFor="name">Nom complet</label>
|
<label htmlFor="name">{t('contact.form.name')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="name"
|
id="name"
|
||||||
@@ -232,7 +284,7 @@ const Contact = () => {
|
|||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
placeholder="Votre nom"
|
placeholder={t('contact.form.name')}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -243,7 +295,7 @@ const Contact = () => {
|
|||||||
transition={{ duration: 0.5, delay: 0.2 }}
|
transition={{ duration: 0.5, delay: 0.2 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
>
|
>
|
||||||
<label htmlFor="email">Email</label>
|
<label htmlFor="email">{t('contact.form.email')}</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
id="email"
|
id="email"
|
||||||
@@ -263,7 +315,7 @@ const Contact = () => {
|
|||||||
transition={{ duration: 0.5, delay: 0.3 }}
|
transition={{ duration: 0.5, delay: 0.3 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
>
|
>
|
||||||
<label htmlFor="subject">Sujet</label>
|
<label htmlFor="subject">{t('contact.form.subject')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="subject"
|
id="subject"
|
||||||
@@ -271,7 +323,7 @@ const Contact = () => {
|
|||||||
value={formData.subject}
|
value={formData.subject}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
placeholder="Objet de votre message"
|
placeholder={t('contact.form.subject')}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -282,7 +334,7 @@ const Contact = () => {
|
|||||||
transition={{ duration: 0.5, delay: 0.4 }}
|
transition={{ duration: 0.5, delay: 0.4 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
>
|
>
|
||||||
<label htmlFor="message">Message</label>
|
<label htmlFor="message">{t('contact.form.message')}</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="message"
|
id="message"
|
||||||
name="message"
|
name="message"
|
||||||
@@ -290,7 +342,7 @@ const Contact = () => {
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
rows={6}
|
rows={6}
|
||||||
placeholder="Votre message..."
|
placeholder={t('contact.form.message')}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -308,12 +360,12 @@ const Contact = () => {
|
|||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<div className="loading-spinner" />
|
<div className="loading-spinner" />
|
||||||
Envoi en cours...
|
{t('contact.form.sending')}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Send size={20} />
|
<Send size={20} />
|
||||||
Envoyer le message
|
{t('contact.form.send')}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
@@ -321,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. Développé avec ❤️ en React et TypeScript.
|
|
||||||
</p>
|
|
||||||
</motion.footer>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -1,42 +1,61 @@
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { GraduationCap, Calendar, MapPin, Award, BookOpen, Target } from 'lucide-react';
|
import { GraduationCap, Calendar, MapPin, Award, BookOpen, Target } from 'lucide-react';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
|
||||||
const Education = () => {
|
const Education = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const education = [
|
const education = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
degree: "Bachelier en Technologies de l'Informatique",
|
degree: t('education.degree'),
|
||||||
school: "HELHa - Haute École Louvain en Hainaut",
|
school: t('education.school'),
|
||||||
location: "Tournai, Belgique",
|
location: t('education.location'),
|
||||||
period: "2023 - 2026",
|
period: t('education.period'),
|
||||||
currentYear: "3ème année",
|
currentYear: t('education.currentYear'),
|
||||||
status: "En cours",
|
status: t('education.status'),
|
||||||
description: "Formation complète en développement logiciel, programmation, bases de données, réseaux et gestion de projets informatiques.",
|
description: t('education.description'),
|
||||||
highlights: [
|
highlights: [
|
||||||
"Programmation orientée objet (Java, C#)",
|
t('education.highlights.0'),
|
||||||
"Développement web (HTML, CSS, JavaScript, React)",
|
t('education.highlights.1'),
|
||||||
"Développement mobile (Flutter, Dart)",
|
t('education.highlights.2'),
|
||||||
"Bases de données et SQL",
|
t('education.highlights.3'),
|
||||||
"Gestion de projets",
|
t('education.highlights.4'),
|
||||||
"Réseaux et systèmes"
|
t('education.highlights.5')
|
||||||
],
|
],
|
||||||
color: "#4CAF50",
|
color: "#4CAF50",
|
||||||
icon: <GraduationCap size={24} />
|
icon: <GraduationCap size={24} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
degree: t('education.highschool.degree'),
|
||||||
|
school: t('education.highschool.school'),
|
||||||
|
location: t('education.highschool.location'),
|
||||||
|
period: t('education.highschool.period'),
|
||||||
|
currentYear: "",
|
||||||
|
status: t('education.highschool.status'),
|
||||||
|
description: t('education.highschool.description'),
|
||||||
|
highlights: [
|
||||||
|
t('education.highschool.highlights.0'),
|
||||||
|
t('education.highschool.highlights.1'),
|
||||||
|
t('education.highschool.highlights.2')
|
||||||
|
],
|
||||||
|
color: "#3F51B5",
|
||||||
|
icon: <GraduationCap size={24} />
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const certifications = [
|
const certifications = [
|
||||||
{
|
{
|
||||||
title: "Développement Mobile Flutter",
|
title: t('education.cert1.title'),
|
||||||
provider: "Formation autodidacte",
|
provider: t('education.cert1.provider'),
|
||||||
date: "2024",
|
date: t('education.cert1.date'),
|
||||||
skills: ["Dart", "Flutter", "Firebase", "API REST"],
|
skills: ["Dart", "Flutter", "Firebase", "API REST"],
|
||||||
color: "#2196F3"
|
color: "#2196F3"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "React & TypeScript",
|
title: t('education.cert2.title'),
|
||||||
provider: "Projets personnels",
|
provider: t('education.cert2.provider'),
|
||||||
date: "2024",
|
date: t('education.cert2.date'),
|
||||||
skills: ["React", "TypeScript", "Hooks", "Context API"],
|
skills: ["React", "TypeScript", "Hooks", "Context API"],
|
||||||
color: "#FF9800"
|
color: "#FF9800"
|
||||||
}
|
}
|
||||||
@@ -69,21 +88,23 @@ const Education = () => {
|
|||||||
<section id="education" className="education">
|
<section id="education" className="education">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
key="education-header"
|
||||||
className="section-header"
|
className="section-header"
|
||||||
initial={{ opacity: 0, y: 50 }}
|
initial={{ opacity: 0, y: 50 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
>
|
>
|
||||||
<h2 className="section-title">Formation & Apprentissage</h2>
|
<h2 className="section-title">{t('education.title')}</h2>
|
||||||
<p className="section-subtitle">
|
<p className="section-subtitle">
|
||||||
Mon parcours académique et mes apprentissages continus
|
{t('education.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="education-content">
|
<div className="education-content">
|
||||||
{/* Formation principale */}
|
{/* Formation principale */}
|
||||||
<motion.div
|
<motion.div
|
||||||
|
key="education-main"
|
||||||
className="education-main"
|
className="education-main"
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
@@ -178,7 +199,7 @@ const Education = () => {
|
|||||||
>
|
>
|
||||||
<h3 className="certifications-title">
|
<h3 className="certifications-title">
|
||||||
<Award size={24} />
|
<Award size={24} />
|
||||||
Formations complémentaires & Autodidacte
|
{t('education.certifications.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="certifications-grid">
|
<div className="certifications-grid">
|
||||||
{certifications.map((cert, index) => (
|
{certifications.map((cert, index) => (
|
||||||
@@ -224,13 +245,13 @@ const Education = () => {
|
|||||||
transition={{ duration: 0.8, delay: 0.4 }}
|
transition={{ duration: 0.8, delay: 0.4 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
>
|
>
|
||||||
<h3>Objectifs d'apprentissage 2025</h3>
|
<h3>{t('education.learningGoals2025')}</h3>
|
||||||
<div className="goals-grid">
|
<div className="goals-grid">
|
||||||
{[
|
{[
|
||||||
{ goal: "Maîtriser Firebase et les services cloud", progress: 60 },
|
{ goal: t('education.goal1'), progress: 60 },
|
||||||
{ goal: "Approfondir Spring Boot pour le backend", progress: 30 },
|
{ goal: t('education.goal2'), progress: 30 },
|
||||||
{ goal: "Apprendre Docker et les conteneurs", progress: 20 },
|
{ goal: t('education.goal3'), progress: 20 },
|
||||||
{ goal: "Développer mes compétences en UI/UX", progress: 45 }
|
{ goal: t('education.goal4'), progress: 45 }
|
||||||
].map((item, index) => (
|
].map((item, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={index}
|
key={index}
|
||||||
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;
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Menu, X, Sun, Moon } from 'lucide-react';
|
import { Menu, X, Sun, Moon, Globe } from 'lucide-react';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { useNavigate, useLocation, Link } from 'react-router-dom';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
darkMode: boolean;
|
darkMode: boolean;
|
||||||
@@ -9,22 +11,43 @@ 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 navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ name: 'Accueil', href: '#hero' },
|
{ id: 'home', name: t('nav.home'), href: '#hero' },
|
||||||
{ name: 'À propos', href: '#about' },
|
{ id: 'about', name: t('nav.about'), href: '#about' },
|
||||||
{ name: 'Compétences', href: '#skills' },
|
{ id: 'skills', name: t('nav.skills'), href: '#skills' },
|
||||||
{ name: 'Projets', href: '#projects' },
|
{ id: 'projects', name: t('nav.projects'), href: '#projects' },
|
||||||
{ name: 'Formation', href: '#education' },
|
{ id: 'education', name: t('nav.education'), href: '#education' },
|
||||||
{ name: '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 = () => {
|
||||||
|
setLanguage(language === 'fr' ? 'en' : 'fr');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -40,16 +63,19 @@ 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 */}
|
||||||
<ul className="nav-menu desktop-menu">
|
<ul className="nav-menu desktop-menu">
|
||||||
{menuItems.map((item, index) => (
|
{menuItems.map((item, index) => (
|
||||||
<motion.li
|
<motion.li
|
||||||
key={item.name}
|
key={item.id}
|
||||||
initial={{ opacity: 0, y: -20 }}
|
initial={{ opacity: 0, y: -20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||||
@@ -58,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}
|
||||||
@@ -68,13 +94,26 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className="nav-controls">
|
<div className="nav-controls">
|
||||||
|
{/* Toggle langue */}
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.1 }}
|
||||||
|
whileTap={{ scale: 0.9 }}
|
||||||
|
onClick={toggleLanguage}
|
||||||
|
className="language-toggle"
|
||||||
|
aria-label={t('btn.changeLang')}
|
||||||
|
title={t('btn.changeLang')}
|
||||||
|
>
|
||||||
|
<Globe size={18} />
|
||||||
|
<span className="language-text">{language === 'fr' ? 'EN' : 'FR'}</span>
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
{/* Toggle thème */}
|
{/* Toggle thème */}
|
||||||
<motion.button
|
<motion.button
|
||||||
whileHover={{ scale: 1.1 }}
|
whileHover={{ scale: 1.1 }}
|
||||||
whileTap={{ scale: 0.9 }}
|
whileTap={{ scale: 0.9 }}
|
||||||
onClick={toggleDarkMode}
|
onClick={toggleDarkMode}
|
||||||
className="theme-toggle"
|
className="theme-toggle"
|
||||||
aria-label="Changer de thème"
|
aria-label={t('btn.changeTheme')}
|
||||||
>
|
>
|
||||||
{darkMode ? <Sun size={20} /> : <Moon size={20} />}
|
{darkMode ? <Sun size={20} /> : <Moon size={20} />}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
@@ -85,7 +124,7 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
|
|||||||
whileTap={{ scale: 0.9 }}
|
whileTap={{ scale: 0.9 }}
|
||||||
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
onClick={() => setIsMenuOpen(!isMenuOpen)}
|
||||||
className="menu-toggle"
|
className="menu-toggle"
|
||||||
aria-label="Menu"
|
aria-label={t('btn.menu')}
|
||||||
>
|
>
|
||||||
{isMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
{isMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
@@ -99,12 +138,12 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
|
|||||||
transition={{ duration: 0.3 }}
|
transition={{ duration: 0.3 }}
|
||||||
>
|
>
|
||||||
{menuItems.map((item) => (
|
{menuItems.map((item) => (
|
||||||
<li key={item.name}>
|
<li key={item.id}>
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
href={item.href}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
scrollToSection(item.href);
|
handleNavigation(item.href);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{item.name}
|
{item.name}
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Download, Github, Linkedin, Mail } from 'lucide-react';
|
import { Download, Github, Linkedin, Mail } from 'lucide-react';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import dvlPhoto from '../assets/dvl.jpg';
|
||||||
|
|
||||||
const Hero = () => {
|
const Hero = () => {
|
||||||
|
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');
|
||||||
link.href = '/cv-dayron-van-leemput.pdf'; // Ajoutez votre CV dans le dossier public
|
link.href = '/Dayron_Van_Leemput_CV.pdf'; // Ajoutez votre CV dans le dossier public
|
||||||
link.download = 'CV-Dayron-Van-Leemput.pdf';
|
link.download = 'Dayron_Van_Leemput_CV.pdf';
|
||||||
link.click();
|
link.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -14,6 +18,7 @@ const Hero = () => {
|
|||||||
<section id="hero" className="hero">
|
<section id="hero" className="hero">
|
||||||
<div className="hero-content">
|
<div className="hero-content">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
key="hero-text"
|
||||||
className="hero-text"
|
className="hero-text"
|
||||||
initial={{ opacity: 0, y: 50 }}
|
initial={{ opacity: 0, y: 50 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
@@ -25,7 +30,7 @@ const Hero = () => {
|
|||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ duration: 0.8, delay: 0.4 }}
|
transition={{ duration: 0.8, delay: 0.4 }}
|
||||||
>
|
>
|
||||||
Dayron Van Leemput
|
{t('hero.title')}
|
||||||
</motion.h1>
|
</motion.h1>
|
||||||
|
|
||||||
<motion.h2
|
<motion.h2
|
||||||
@@ -34,7 +39,7 @@ const Hero = () => {
|
|||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ duration: 0.8, delay: 0.6 }}
|
transition={{ duration: 0.8, delay: 0.6 }}
|
||||||
>
|
>
|
||||||
Étudiant en Technologies de l'Informatique
|
{t('hero.subtitle')}
|
||||||
</motion.h2>
|
</motion.h2>
|
||||||
|
|
||||||
<motion.p
|
<motion.p
|
||||||
@@ -43,8 +48,7 @@ const Hero = () => {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.8, delay: 0.8 }}
|
transition={{ duration: 0.8, delay: 0.8 }}
|
||||||
>
|
>
|
||||||
Bac 3 à la HELHa de Tournai | Jeune développeur passionné par les nouvelles technologies
|
{t('hero.description')}
|
||||||
et le développement d'applications innovantes
|
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -60,7 +64,7 @@ const Hero = () => {
|
|||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
>
|
>
|
||||||
<Download size={20} />
|
<Download size={20} />
|
||||||
Télécharger mon CV
|
{t('btn.downloadCV')}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
<motion.a
|
<motion.a
|
||||||
@@ -74,7 +78,7 @@ const Hero = () => {
|
|||||||
whileTap={{ scale: 0.95 }}
|
whileTap={{ scale: 0.95 }}
|
||||||
>
|
>
|
||||||
<Mail size={20} />
|
<Mail size={20} />
|
||||||
Me contacter
|
{t('btn.contactMe')}
|
||||||
</motion.a>
|
</motion.a>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
@@ -85,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/dayronvanleemput" // 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"
|
||||||
@@ -96,7 +100,7 @@ const Hero = () => {
|
|||||||
</motion.a>
|
</motion.a>
|
||||||
|
|
||||||
<motion.a
|
<motion.a
|
||||||
href="https://linkedin.com/in/dayronvanleemput" // Remplacez par votre profil LinkedIn
|
href="https://www.linkedin.com/in/dayron-van-leemput-992a94398" // Remplacez par votre profil LinkedIn
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="social-link"
|
className="social-link"
|
||||||
@@ -109,6 +113,7 @@ const Hero = () => {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
|
key="hero-image"
|
||||||
className="hero-image"
|
className="hero-image"
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
@@ -119,10 +124,11 @@ 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 }}
|
||||||
>
|
>
|
||||||
{/* Vous pouvez remplacer ceci par votre photo */}
|
<img
|
||||||
<div className="avatar-placeholder">
|
src={dvlPhoto}
|
||||||
<span>DV</span>
|
alt="Dayron Van Leemput - Portrait"
|
||||||
</div>
|
className="avatar-image"
|
||||||
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
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;
|
||||||
261
frontend/src/components/HomeSync.tsx
Normal file
261
frontend/src/components/HomeSync.tsx
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { Smartphone, Calendar, ShoppingCart, Users, Lock, Code, Database } from 'lucide-react';
|
||||||
|
// import appIcon from '../assets/app_icon.png'; // Using same icon as placeholder if no specific HomeSync icon
|
||||||
|
|
||||||
|
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))',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
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',
|
||||||
|
textAlign: 'center',
|
||||||
|
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)'
|
||||||
|
}}>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<p style={{
|
||||||
|
opacity: 0.8,
|
||||||
|
lineHeight: '1.7',
|
||||||
|
flex: 1,
|
||||||
|
fontSize: '1rem',
|
||||||
|
color: 'var(--text-color)'
|
||||||
|
}}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const HomeSync = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = "Home Sync";
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="home-sync-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' }}>
|
||||||
|
{/* Placeholder Icon - ideally this would be a specific Home Sync icon */}
|
||||||
|
<motion.div
|
||||||
|
style={{
|
||||||
|
width: '120px',
|
||||||
|
height: '120px',
|
||||||
|
borderRadius: '24px',
|
||||||
|
marginBottom: '2rem',
|
||||||
|
boxShadow: '0 10px 30px rgba(0,0,0,0.2)',
|
||||||
|
background: 'linear-gradient(135deg, #6366f1 0%, #a855f7 100%)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
whileHover={{ scale: 1.05, rotate: 5 }}
|
||||||
|
transition={{ type: "spring", stiffness: 300 }}
|
||||||
|
>
|
||||||
|
<Users size={64} color="white" />
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<h1 className="gradient-text" style={{ fontSize: '4rem', fontWeight: '800', marginBottom: '1rem' }}>
|
||||||
|
{t('homesync.page.mainTitle')}
|
||||||
|
</h1>
|
||||||
|
<p style={{ fontSize: '1.5rem', opacity: 0.7, maxWidth: '600px', margin: '0 auto 2rem auto' }}>
|
||||||
|
{t('homesync.page.subtitle')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* View Code Button - Placeholder link since none provided, or maybe just remove if not public */}
|
||||||
|
{/* Assuming no link provided in description, maybe just skip or add a placeholder?
|
||||||
|
The prompt says "Fais lui une page dédiée" but doesn't explicitly give a repo link.
|
||||||
|
I'll leave it as a '#', user can update. */
|
||||||
|
}
|
||||||
|
<motion.div
|
||||||
|
className="btn"
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontSize: '1.1rem',
|
||||||
|
padding: '0.8rem 1.5rem',
|
||||||
|
borderRadius: '50px',
|
||||||
|
background: 'rgba(255,255,255,0.05)',
|
||||||
|
color: 'var(--text-color)',
|
||||||
|
opacity: 0.7,
|
||||||
|
cursor: 'default'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Lock size={20} />
|
||||||
|
{t('homesync.viewCode')}
|
||||||
|
</motion.div>
|
||||||
|
</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('homesync.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('homesync.highlights.title')}</h2>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '2.5rem' }}>
|
||||||
|
|
||||||
|
<FeatureCard
|
||||||
|
title={t('homesync.highlight.1.title')}
|
||||||
|
description={t('homesync.highlight.1.desc')}
|
||||||
|
icon={Calendar}
|
||||||
|
/>
|
||||||
|
<FeatureCard
|
||||||
|
title={t('homesync.highlight.2.title')}
|
||||||
|
description={t('homesync.highlight.2.desc')}
|
||||||
|
icon={ShoppingCart}
|
||||||
|
/>
|
||||||
|
<FeatureCard
|
||||||
|
title={t('homesync.highlight.3.title')}
|
||||||
|
description={t('homesync.highlight.3.desc')}
|
||||||
|
icon={Users}
|
||||||
|
/>
|
||||||
|
<FeatureCard
|
||||||
|
title={t('homesync.highlight.4.title')}
|
||||||
|
description={t('homesync.highlight.4.desc')}
|
||||||
|
icon={Lock}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</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('homesync.page.conclusion')}
|
||||||
|
</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Tech Stack */}
|
||||||
|
<motion.div variants={itemVariants} style={{ marginBottom: '5rem' }}>
|
||||||
|
<h2 style={{ textAlign: 'center', marginBottom: '3rem' }}>{t('homesync.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('homesync.tech.frontend')}
|
||||||
|
</h3>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||||
|
{[1, 2].map(i => (
|
||||||
|
<li key={i} style={{ marginBottom: '0.8rem', paddingLeft: '1rem', borderLeft: '2px solid var(--primary-color)' }}>
|
||||||
|
{t(`homesync.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)' }}>
|
||||||
|
<Database /> {t('homesync.tech.backend')}
|
||||||
|
</h3>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||||
|
{[1, 2].map(i => (
|
||||||
|
<li key={i} style={{ marginBottom: '0.8rem', paddingLeft: '1rem', borderLeft: '2px solid var(--primary-color)' }}>
|
||||||
|
{t(`homesync.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('homesync.tech.api')}
|
||||||
|
</h3>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0 }}>
|
||||||
|
{[1].map(i => (
|
||||||
|
<li key={i} style={{ marginBottom: '0.8rem', paddingLeft: '1rem', borderLeft: '2px solid var(--primary-color)' }}>
|
||||||
|
{t(`homesync.tech.api.${i}`)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HomeSync;
|
||||||
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,45 +1,83 @@
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ExternalLink, Github, MapPin } from 'lucide-react';
|
import { ExternalLink, MapPin, Wine, Users } from 'lucide-react';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
const Projects = () => {
|
const Projects = () => {
|
||||||
|
const { t, language } = useLanguage();
|
||||||
const projects = [
|
const projects = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
title: "Travel Mate",
|
title: t('projects.travelMate.title'),
|
||||||
description: "Application mobile conçue pour simplifier l'organisation de voyages de groupe. Elle permet de centraliser toutes les informations importantes d'un voyage : planification, gestion des dépenses, découverte d'activités et coordination entre les participants.",
|
description: t('projects.travelMate.description'),
|
||||||
status: "Bientôt disponible sur App Store et Play Store",
|
status: t('projects.status.available'),
|
||||||
technologies: ["Dart", "Flutter", "Firebase"],
|
technologies: ["Dart", "Flutter", "Firebase"],
|
||||||
features: [
|
features: [
|
||||||
"Planification de voyage collaborative",
|
t('projects.travelMate.feature1'),
|
||||||
"Gestion des dépenses partagées",
|
t('projects.travelMate.feature2'),
|
||||||
"Découverte d'activités locales",
|
t('projects.travelMate.feature3'),
|
||||||
"Coordination en temps réel"
|
t('projects.travelMate.feature4')
|
||||||
],
|
],
|
||||||
color: "#4CAF50",
|
color: "#4CAF50",
|
||||||
icon: <MapPin size={24} />,
|
icon: <MapPin size={24} />,
|
||||||
links: {
|
links: {
|
||||||
github: "#", // 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"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4, // New ID for Home Sync
|
||||||
|
title: t('projects.homeSync.title'),
|
||||||
|
description: t('projects.homeSync.description'),
|
||||||
|
status: t('projects.status.personal'),
|
||||||
|
technologies: ["Flutter", "Firebase", "BLoC"],
|
||||||
|
features: [
|
||||||
|
t('projects.homeSync.feature1'),
|
||||||
|
t('projects.homeSync.feature2'),
|
||||||
|
t('projects.homeSync.feature3'),
|
||||||
|
t('projects.homeSync.feature4')
|
||||||
|
],
|
||||||
|
color: "#8B5CF6", // Purple/Indigo shade for family/sync theme
|
||||||
|
icon: <Users size={24} />, // Inherited import or need to add
|
||||||
|
links: {
|
||||||
|
demo: "/homesync"
|
||||||
|
},
|
||||||
|
image: "/home-sync-preview.png" // Placeholder
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: t('projects.shelbys.title'),
|
||||||
|
description: t('projects.shelbys.description'),
|
||||||
|
status: t('projects.status.online'),
|
||||||
|
technologies: ["React", "Vite", "TailwindCSS", "Framer Motion"],
|
||||||
|
features: [
|
||||||
|
t('projects.shelbys.feature1'),
|
||||||
|
t('projects.shelbys.feature2'),
|
||||||
|
t('projects.shelbys.feature4')
|
||||||
|
],
|
||||||
|
color: "#E91E63",
|
||||||
|
icon: <Wine size={24} />,
|
||||||
|
links: {
|
||||||
|
demo: "https://shelbys.be"
|
||||||
|
},
|
||||||
|
image: "/shelbys-preview.png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
title: "Portfolio Web",
|
title: t('projects.portfolio.title'),
|
||||||
description: "Site web personnel moderne et responsive développé avec React et TypeScript. Inclut des animations fluides, un mode sombre/clair et une architecture modulaire.",
|
description: t('projects.portfolio.description'),
|
||||||
status: "Projet actuel",
|
status: t('projects.status.current'),
|
||||||
technologies: ["React", "TypeScript", "Framer Motion", "CSS3"],
|
technologies: ["React", "TypeScript", "Framer Motion", "CSS3"],
|
||||||
features: [
|
features: [
|
||||||
"Design responsive",
|
t('projects.portfolio.feature1'),
|
||||||
"Animations fluides",
|
t('projects.portfolio.feature2'),
|
||||||
"Mode sombre/clair",
|
t('projects.portfolio.feature3'),
|
||||||
"Performance optimisée"
|
t('projects.portfolio.feature4')
|
||||||
],
|
],
|
||||||
color: "#2196F3",
|
color: "#2196F3",
|
||||||
icon: <ExternalLink size={24} />,
|
icon: <ExternalLink size={24} />,
|
||||||
links: {
|
links: {
|
||||||
github: "https://github.com/dayronvanleemput/portfolio", // Remplacez par votre lien
|
demo: "https://xeewy.be"
|
||||||
demo: "https://dayronvanleemput.dev" // Remplacez par votre lien
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
@@ -71,19 +109,21 @@ const Projects = () => {
|
|||||||
<section id="projects" className="projects">
|
<section id="projects" className="projects">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
key="projects-header"
|
||||||
className="section-header"
|
className="section-header"
|
||||||
initial={{ opacity: 0, y: 50 }}
|
initial={{ opacity: 0, y: 50 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
>
|
>
|
||||||
<h2 className="section-title">Mes Projets</h2>
|
<h2 className="section-title">{t('projects.title')}</h2>
|
||||||
<p className="section-subtitle">
|
<p className="section-subtitle">
|
||||||
Découvrez les projets sur lesquels j'ai travaillé et qui me tiennent à cœur
|
{t('projects.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
|
key="projects-grid"
|
||||||
className="projects-grid"
|
className="projects-grid"
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
@@ -138,7 +178,7 @@ const Projects = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="project-features">
|
<div className="project-features">
|
||||||
<h4>Fonctionnalités principales :</h4>
|
<h4>{t('projects.features')}</h4>
|
||||||
<ul>
|
<ul>
|
||||||
{project.features.map((feature, featureIndex) => (
|
{project.features.map((feature, featureIndex) => (
|
||||||
<motion.li
|
<motion.li
|
||||||
@@ -160,31 +200,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} />
|
|
||||||
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')}
|
||||||
Voir le projet
|
</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>
|
||||||
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;
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Code, Database, Smartphone, Globe, Server, Wrench } from 'lucide-react';
|
import { Code, Database, Smartphone, Globe, Server, Wrench } from 'lucide-react';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
|
||||||
const Skills = () => {
|
const Skills = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
const skillCategories = [
|
const skillCategories = [
|
||||||
{
|
{
|
||||||
|
id: 'mobile',
|
||||||
icon: <Smartphone size={32} />,
|
icon: <Smartphone size={32} />,
|
||||||
title: "Mobile",
|
title: t('skills.category.mobile'),
|
||||||
color: "#4FC3F7",
|
color: "#4FC3F7",
|
||||||
skills: [
|
skills: [
|
||||||
{ name: "Dart", level: 85, color: "#0175C2" },
|
{ name: "Dart", level: 85, color: "#0175C2" },
|
||||||
@@ -13,8 +16,9 @@ const Skills = () => {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 'frontend',
|
||||||
icon: <Globe size={32} />,
|
icon: <Globe size={32} />,
|
||||||
title: "Frontend",
|
title: t('skills.category.frontend'),
|
||||||
color: "#42A5F5",
|
color: "#42A5F5",
|
||||||
skills: [
|
skills: [
|
||||||
{ name: "React", level: 75, color: "#61DAFB" },
|
{ name: "React", level: 75, color: "#61DAFB" },
|
||||||
@@ -23,8 +27,9 @@ const Skills = () => {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 'backend',
|
||||||
icon: <Server size={32} />,
|
icon: <Server size={32} />,
|
||||||
title: "Backend",
|
title: t('skills.category.backend'),
|
||||||
color: "#66BB6A",
|
color: "#66BB6A",
|
||||||
skills: [
|
skills: [
|
||||||
{ name: "Java", level: 75, color: "#ED8B00" },
|
{ name: "Java", level: 75, color: "#ED8B00" },
|
||||||
@@ -32,8 +37,9 @@ const Skills = () => {
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Database size={32} />,
|
id: 'tools',
|
||||||
title: "Outils & Autres",
|
icon: <Wrench size={32} />,
|
||||||
|
title: t('skills.category.tools'),
|
||||||
color: "#AB47BC",
|
color: "#AB47BC",
|
||||||
skills: [
|
skills: [
|
||||||
{ name: "Git", level: 70, color: "#F05032" },
|
{ name: "Git", level: 70, color: "#F05032" },
|
||||||
@@ -70,19 +76,21 @@ const Skills = () => {
|
|||||||
<section id="skills" className="skills">
|
<section id="skills" className="skills">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<motion.div
|
<motion.div
|
||||||
|
key="skills-header"
|
||||||
className="section-header"
|
className="section-header"
|
||||||
initial={{ opacity: 0, y: 50 }}
|
initial={{ opacity: 0, y: 50 }}
|
||||||
whileInView={{ opacity: 1, y: 0 }}
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
>
|
>
|
||||||
<h2 className="section-title">Compétences & Technologies</h2>
|
<h2 className="section-title">{t('skills.title')}</h2>
|
||||||
<p className="section-subtitle">
|
<p className="section-subtitle">
|
||||||
Les technologies que je maîtrise et avec lesquelles j'aime travailler
|
{t('skills.subtitle')}
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<motion.div
|
<motion.div
|
||||||
|
key="skills-grid"
|
||||||
className="skills-grid"
|
className="skills-grid"
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
@@ -91,7 +99,7 @@ const Skills = () => {
|
|||||||
>
|
>
|
||||||
{skillCategories.map((category, categoryIndex) => (
|
{skillCategories.map((category, categoryIndex) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={category.title}
|
key={category.id}
|
||||||
className="skill-category"
|
className="skill-category"
|
||||||
variants={categoryVariants}
|
variants={categoryVariants}
|
||||||
whileHover={{
|
whileHover={{
|
||||||
@@ -157,13 +165,13 @@ const Skills = () => {
|
|||||||
transition={{ duration: 0.8, delay: 0.3 }}
|
transition={{ duration: 0.8, delay: 0.3 }}
|
||||||
viewport={{ once: true }}
|
viewport={{ once: true }}
|
||||||
>
|
>
|
||||||
<h3 className="soft-skills-title">Autres compétences</h3>
|
<h3 className="soft-skills-title">{t('skills.otherSkills')}</h3>
|
||||||
<div className="soft-skills-grid">
|
<div className="soft-skills-grid">
|
||||||
{[
|
{[
|
||||||
{ name: "Résolution de problèmes", icon: <Wrench size={20} /> },
|
{ name: t('skills.problemSolving'), icon: <Wrench size={20} /> },
|
||||||
{ name: "Travail en équipe", icon: <Code size={20} /> },
|
{ name: t('skills.teamwork'), icon: <Code size={20} /> },
|
||||||
{ name: "Apprentissage continu", icon: <Database size={20} /> },
|
{ name: t('skills.continuousLearning'), icon: <Database size={20} /> },
|
||||||
{ name: "Communication", icon: <Globe size={20} /> }
|
{ name: t('skills.communication'), icon: <Globe size={20} /> }
|
||||||
].map((softSkill, index) => (
|
].map((softSkill, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={softSkill.name}
|
key={softSkill.name}
|
||||||
337
frontend/src/components/TravelMate.tsx
Normal file
337
frontend/src/components/TravelMate.tsx
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Shield, Smartphone, Map, DollarSign, Users, Globe, Code, ArrowLeft } from 'lucide-react';
|
||||||
|
import 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].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>
|
||||||
|
|
||||||
|
{/* Legal & Support Section */}
|
||||||
|
<motion.div variants={itemVariants} style={{ marginBottom: '6rem' }}>
|
||||||
|
<h2 style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: '4rem',
|
||||||
|
fontSize: '2.5rem',
|
||||||
|
fontWeight: '700',
|
||||||
|
color: 'var(--text-color)'
|
||||||
|
}}>{t('travelmate.legal.title')}</h2>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: '2rem' }}>
|
||||||
|
{/* Privacy Policy Card */}
|
||||||
|
<Link to={`/${language}/travelmate/policies`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||||
|
<motion.div
|
||||||
|
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)' }}
|
||||||
|
style={{
|
||||||
|
background: 'var(--card-bg, rgba(255, 255, 255, 0.03))',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
padding: '2rem',
|
||||||
|
borderRadius: '1.5rem',
|
||||||
|
border: '1px solid var(--border-color, rgba(255, 255, 255, 0.08))',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
transition: 'all 0.3s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Shield size={32} style={{ color: 'var(--primary-color)', marginBottom: '1.5rem' }} />
|
||||||
|
<h3 style={{ fontSize: '1.25rem', fontWeight: '600', marginBottom: '1rem' }}>{t('policies.title')}</h3>
|
||||||
|
<p style={{ opacity: 0.7, marginBottom: '2rem', flex: 1, lineHeight: '1.6' }}>
|
||||||
|
{t('travelmate.legal.privacy.desc')}
|
||||||
|
</p>
|
||||||
|
<span style={{ color: 'var(--primary-color)', fontWeight: '500', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
{t('travelmate.policies.link')} <ArrowLeft size={16} style={{ rotate: '180deg' }} />
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Erase Data Card */}
|
||||||
|
<Link to={`/${language}/travelmate/erasedata`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||||
|
<motion.div
|
||||||
|
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: '#EF4444' }}
|
||||||
|
style={{
|
||||||
|
background: 'var(--card-bg, rgba(255, 255, 255, 0.03))',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
padding: '2rem',
|
||||||
|
borderRadius: '1.5rem',
|
||||||
|
border: '1px solid var(--border-color, rgba(255, 255, 255, 0.08))',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
transition: 'all 0.3s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Shield size={32} style={{ color: '#EF4444', marginBottom: '1.5rem' }} />
|
||||||
|
<h3 style={{ fontSize: '1.25rem', fontWeight: '600', marginBottom: '1rem' }}>{t('erasedata.title').split(' ').slice(0, 2).join(' ')}...</h3> {/* Truncate title or use specific one */}
|
||||||
|
<p style={{ opacity: 0.7, marginBottom: '2rem', flex: 1, lineHeight: '1.6' }}>
|
||||||
|
{t('travelmate.legal.erasedata.desc')}
|
||||||
|
</p>
|
||||||
|
<span style={{ color: '#EF4444', fontWeight: '500', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
{t('travelmate.erasedata.link')} <ArrowLeft size={16} style={{ rotate: '180deg' }} />
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Support Card */}
|
||||||
|
<Link to={`/${language}/travelmate/support`} style={{ textDecoration: 'none', color: 'inherit' }}>
|
||||||
|
<motion.div
|
||||||
|
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: '#10B981' }}
|
||||||
|
style={{
|
||||||
|
background: 'var(--card-bg, rgba(255, 255, 255, 0.03))',
|
||||||
|
backdropFilter: 'blur(10px)',
|
||||||
|
padding: '2rem',
|
||||||
|
borderRadius: '1.5rem',
|
||||||
|
border: '1px solid var(--border-color, rgba(255, 255, 255, 0.08))',
|
||||||
|
height: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
transition: 'all 0.3s ease'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Users size={32} style={{ color: '#10B981', marginBottom: '1.5rem' }} />
|
||||||
|
<h3 style={{ fontSize: '1.25rem', fontWeight: '600', marginBottom: '1rem' }}>{t('support.title')}</h3>
|
||||||
|
<p style={{ opacity: 0.7, marginBottom: '2rem', flex: 1, lineHeight: '1.6' }}>
|
||||||
|
{t('travelmate.legal.support.desc')}
|
||||||
|
</p>
|
||||||
|
<span style={{ color: '#10B981', fontWeight: '500', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
{t('travelmate.support.link')} <ArrowLeft size={16} style={{ rotate: '180deg' }} />
|
||||||
|
</span>
|
||||||
|
</motion.div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TravelMate;
|
||||||
278
frontend/src/components/TravelMate/EraseData.tsx
Normal file
278
frontend/src/components/TravelMate/EraseData.tsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
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 [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||||
|
|
||||||
|
const handleInitialSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!formData.confirm) return;
|
||||||
|
setShowConfirmModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFinalSubmit = async () => {
|
||||||
|
setShowConfirmModal(false);
|
||||||
|
setStatus('submitting');
|
||||||
|
setErrorMessage('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post('/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', position: 'relative' }}>
|
||||||
|
<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={handleInitialSubmit} 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>
|
||||||
|
|
||||||
|
{/* Confirmation Modal */}
|
||||||
|
{showConfirmModal && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.7)',
|
||||||
|
backdropFilter: 'blur(5px)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
padding: '20px'
|
||||||
|
}}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
style={{
|
||||||
|
background: '#1a1a1a', // Assuming dark theme is default or hardcoded for now, ideally use var(--card-bg) but opacity might be issue
|
||||||
|
backgroundColor: 'var(--card-bg, #1a1a1a)',
|
||||||
|
padding: '2rem',
|
||||||
|
borderRadius: '1rem',
|
||||||
|
border: '1px solid var(--border-color, rgba(255,255,255,0.1))',
|
||||||
|
maxWidth: '500px',
|
||||||
|
width: '100%',
|
||||||
|
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<AlertCircle size={24} color="var(--primary-color)" />
|
||||||
|
{t('modal.confirm.title')}
|
||||||
|
</h2>
|
||||||
|
<p style={{ marginBottom: '1.5rem', opacity: 0.8 }}>
|
||||||
|
{t('modal.confirm.message')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.05)', padding: '1rem', borderRadius: '0.5rem', marginBottom: '2rem', fontSize: '0.95rem' }}>
|
||||||
|
<p style={{ marginBottom: '0.5rem' }}><strong>{t('erasedata.form.lastname')}:</strong> {formData.nom}</p>
|
||||||
|
<p style={{ marginBottom: '0.5rem' }}><strong>{t('erasedata.form.firstname')}:</strong> {formData.prenom}</p>
|
||||||
|
<p style={{ marginBottom: '0.5rem' }}><strong>{t('erasedata.form.email')}:</strong> {formData.email}</p>
|
||||||
|
{formData.message && <p><strong>{t('erasedata.form.message')}:</strong> <span style={{ opacity: 0.8, display: 'block', marginTop: '4px', fontStyle: 'italic' }}>"{formData.message}"</span></p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirmModal(false)}
|
||||||
|
style={{
|
||||||
|
padding: '0.8rem 1.5rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
color: 'inherit',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('modal.btn.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleFinalSubmit}
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{
|
||||||
|
padding: '0.8rem 1.5rem',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('modal.btn.confirm')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EraseData;
|
||||||
324
frontend/src/components/TravelMate/Support.tsx
Normal file
324
frontend/src/components/TravelMate/Support.tsx
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
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 Support = () => {
|
||||||
|
const { t, language } = useLanguage();
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
nom: '',
|
||||||
|
prenom: '',
|
||||||
|
account_email: '',
|
||||||
|
contact_email: '',
|
||||||
|
useSameEmail: false,
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle');
|
||||||
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
|
const handleSameEmailChange = (checked: boolean) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
useSameEmail: checked,
|
||||||
|
contact_email: checked ? prev.account_email : prev.contact_email
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAccountEmailChange = (value: string) => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
account_email: value,
|
||||||
|
contact_email: prev.useSameEmail ? value : prev.contact_email
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||||
|
|
||||||
|
const handleInitialSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setShowConfirmModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFinalSubmit = async () => {
|
||||||
|
setShowConfirmModal(false);
|
||||||
|
setStatus('submitting');
|
||||||
|
setErrorMessage('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post('/api/support', {
|
||||||
|
nom: formData.nom,
|
||||||
|
prenom: formData.prenom,
|
||||||
|
account_email: formData.account_email,
|
||||||
|
contact_email: formData.contact_email,
|
||||||
|
message: formData.message
|
||||||
|
});
|
||||||
|
setStatus('success');
|
||||||
|
setFormData({
|
||||||
|
nom: '',
|
||||||
|
prenom: '',
|
||||||
|
account_email: '',
|
||||||
|
contact_email: '',
|
||||||
|
useSameEmail: false,
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Submission error', error);
|
||||||
|
setStatus('error');
|
||||||
|
setErrorMessage(error.response?.data?.message || t('support.error.generic'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: { opacity: 1, y: 0 }
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="support-page" style={{ paddingTop: '100px', minHeight: '100vh', paddingBottom: '50px', position: 'relative' }}>
|
||||||
|
<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('support.title')}</h1>
|
||||||
|
<p style={{ marginBottom: '2rem', opacity: 0.8, lineHeight: '1.6' }}>
|
||||||
|
{t('support.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('support.success.title')}</h3>
|
||||||
|
<p>{t('support.success.message')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleInitialSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '1.5rem' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>{t('support.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('support.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('support.form.accountemail')}</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={formData.account_email}
|
||||||
|
onChange={e => handleAccountEmailChange(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 style={{ marginBottom: '-0.5rem' }}>
|
||||||
|
<label style={{ display: 'flex', alignItems: 'center', gap: '10px', cursor: 'pointer', opacity: 0.9 }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.useSameEmail}
|
||||||
|
onChange={e => handleSameEmailChange(e.target.checked)}
|
||||||
|
style={{ width: '18px', height: '18px' }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: '0.9rem' }}>
|
||||||
|
{t('support.form.sameemail')}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>{t('support.form.contactemail')}</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={formData.contact_email}
|
||||||
|
onChange={e => setFormData({ ...formData, contact_email: e.target.value })}
|
||||||
|
disabled={formData.useSameEmail} // Optional: disable if same
|
||||||
|
className="form-input"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.8rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
background: formData.useSameEmail ? 'rgba(0,0,0,0.1)' : 'rgba(0,0,0,0.2)',
|
||||||
|
color: 'inherit',
|
||||||
|
opacity: formData.useSameEmail ? 0.7 : 1
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: '500' }}>{t('support.form.message')}</label>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
rows={5}
|
||||||
|
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' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<div style={{ color: '#EF4444', display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.95rem' }}>
|
||||||
|
<AlertCircle size={16} />
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={status === 'submitting'}
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{
|
||||||
|
marginTop: '1rem',
|
||||||
|
padding: '1rem',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '10px',
|
||||||
|
opacity: status === 'submitting' ? 0.7 : 1,
|
||||||
|
cursor: status === 'submitting' ? 'wait' : 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status === 'submitting' ? t('support.form.submitting') : (
|
||||||
|
<>
|
||||||
|
<Send size={18} />
|
||||||
|
{t('support.form.submit')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirmation Modal */}
|
||||||
|
{showConfirmModal && (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0, 0, 0, 0.7)',
|
||||||
|
backdropFilter: 'blur(5px)',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 1000,
|
||||||
|
padding: '20px'
|
||||||
|
}}>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
style={{
|
||||||
|
background: '#1a1a1a',
|
||||||
|
backgroundColor: 'var(--card-bg, #1a1a1a)',
|
||||||
|
padding: '2rem',
|
||||||
|
borderRadius: '1rem',
|
||||||
|
border: '1px solid var(--border-color, rgba(255,255,255,0.1))',
|
||||||
|
maxWidth: '500px',
|
||||||
|
width: '100%',
|
||||||
|
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<AlertCircle size={24} color="var(--primary-color)" />
|
||||||
|
{t('modal.confirm.title')}
|
||||||
|
</h2>
|
||||||
|
<p style={{ marginBottom: '1.5rem', opacity: 0.8 }}>
|
||||||
|
{t('modal.confirm.message')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.05)', padding: '1rem', borderRadius: '0.5rem', marginBottom: '2rem', fontSize: '0.95rem' }}>
|
||||||
|
<p style={{ marginBottom: '0.5rem' }}><strong>{t('support.form.lastname')}:</strong> {formData.nom}</p>
|
||||||
|
<p style={{ marginBottom: '0.5rem' }}><strong>{t('support.form.firstname')}:</strong> {formData.prenom}</p>
|
||||||
|
<p style={{ marginBottom: '0.5rem' }}><strong>{t('support.form.accountemail')}:</strong> {formData.account_email}</p>
|
||||||
|
<p style={{ marginBottom: '0.5rem' }}><strong>{t('support.form.contactemail')}:</strong> {formData.contact_email}</p>
|
||||||
|
{formData.message && <p><strong>{t('support.form.message')}:</strong> <span style={{ opacity: 0.8, display: 'block', marginTop: '4px', fontStyle: 'italic' }}>"{formData.message}"</span></p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowConfirmModal(false)}
|
||||||
|
style={{
|
||||||
|
padding: '0.8rem 1.5rem',
|
||||||
|
borderRadius: '0.5rem',
|
||||||
|
background: 'transparent',
|
||||||
|
border: '1px solid var(--border-color)',
|
||||||
|
color: 'inherit',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('modal.btn.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleFinalSubmit}
|
||||||
|
className="btn btn-primary"
|
||||||
|
style={{
|
||||||
|
padding: '0.8rem 1.5rem',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('modal.btn.confirm')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Support;
|
||||||
647
frontend/src/contexts/LanguageContext.tsx
Normal file
647
frontend/src/contexts/LanguageContext.tsx
Normal file
@@ -0,0 +1,647 @@
|
|||||||
|
// ========================
|
||||||
|
// CONTEXTE DE LANGUE
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
import React, { createContext, useContext } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
// Types pour les langues supportées
|
||||||
|
export type Language = 'fr' | 'en';
|
||||||
|
|
||||||
|
// Interface pour le contexte
|
||||||
|
interface LanguageContextType {
|
||||||
|
language: Language;
|
||||||
|
setLanguage: (lang: Language) => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Textes traduits
|
||||||
|
const translations = {
|
||||||
|
fr: {
|
||||||
|
// Navigation
|
||||||
|
'nav.home': 'Accueil',
|
||||||
|
'nav.about': 'À propos',
|
||||||
|
'nav.skills': 'Compétences',
|
||||||
|
'nav.projects': 'Projets',
|
||||||
|
'nav.education': 'Formation',
|
||||||
|
'nav.contact': 'Contact',
|
||||||
|
|
||||||
|
// Boutons
|
||||||
|
'btn.changeTheme': 'Changer de thème',
|
||||||
|
'btn.menu': 'Menu',
|
||||||
|
'btn.changeLang': 'English',
|
||||||
|
'btn.downloadCV': 'Télécharger CV',
|
||||||
|
'btn.viewProjects': 'Voir mes projets',
|
||||||
|
'btn.contactMe': 'Me contacter',
|
||||||
|
|
||||||
|
// Modal
|
||||||
|
'modal.confirm.title': 'Vérification',
|
||||||
|
'modal.confirm.message': 'Veuillez vérifier vos informations avant d\'envoyer.',
|
||||||
|
'modal.btn.cancel': 'Annuler',
|
||||||
|
'modal.btn.confirm': 'Confirmer & Envoyer',
|
||||||
|
|
||||||
|
// Hero
|
||||||
|
'hero.greeting': 'Salut, je suis',
|
||||||
|
'hero.title': 'Dayron Van Leemput',
|
||||||
|
'hero.subtitle': 'Étudiant en Technologies de l\'Informatique',
|
||||||
|
'hero.description': 'Bac 3 à la HELHa de Tournai | Jeune développeur passionné par les nouvelles technologies et le développement d\'applications innovantes',
|
||||||
|
|
||||||
|
// About
|
||||||
|
'about.title': 'À propos de moi',
|
||||||
|
'about.subtitle': 'Découvrez mon parcours et mes passions',
|
||||||
|
'about.stats.year': '3ème',
|
||||||
|
'about.stats.yearLabel': 'Année d\'études',
|
||||||
|
'about.stats.passion': '100%',
|
||||||
|
'about.stats.passionLabel': 'Passion',
|
||||||
|
'about.stats.goals': '∞',
|
||||||
|
'about.stats.goalsLabel': 'Objectifs',
|
||||||
|
'about.stats.fuel': '☕',
|
||||||
|
'about.stats.fuelLabel': 'Fuel quotidien',
|
||||||
|
'about.journey.title': 'Mon parcours',
|
||||||
|
'about.journey.content': 'Actuellement en 3ème année de Technologies de l\'Informatique à la HELHa de Tournai, je me passionne pour le développement d\'applications et les nouvelles technologies. Mon parcours m\'a permis d\'acquérir une solide base technique et une approche méthodique du développement.',
|
||||||
|
'about.passion.title': 'Ma passion',
|
||||||
|
'about.passion.content': 'Ce qui m\'anime le plus, c\'est la création de solutions innovantes qui résolvent des problèmes réels. J\'aime particulièrement le développement mobile avec Flutter et le développement web moderne avec React et TypeScript.',
|
||||||
|
'about.goals.title': 'Mes objectifs',
|
||||||
|
'about.goals.content': 'Je cherche constamment à améliorer mes compétences et à rester à jour avec les dernières tendances technologiques. Mon objectif est de devenir un développeur full-stack polyvalent et de contribuer à des projets qui ont un impact positif.',
|
||||||
|
'about.quote.title': 'En quelques mots',
|
||||||
|
'about.quote.content': 'La technologie n\'est rien. Ce qui est important, c\'est d\'avoir la foi en les gens, qu\'ils soient fondamentalement bons et intelligents, et si vous leur donnez des outils, ils feront des choses merveilleuses avec.',
|
||||||
|
'about.quote.author': 'Steve Jobs',
|
||||||
|
|
||||||
|
// Skills
|
||||||
|
'skills.title': 'Mes Compétences',
|
||||||
|
'skills.subtitle': 'Technologies et outils que je maîtrise',
|
||||||
|
'skills.category.mobile': 'Mobile',
|
||||||
|
'skills.category.frontend': 'Frontend',
|
||||||
|
'skills.category.backend': 'Backend',
|
||||||
|
'skills.category.tools': 'Outils & Autres',
|
||||||
|
'skills.otherSkills': 'Autres compétences',
|
||||||
|
'skills.problemSolving': 'Résolution de problèmes',
|
||||||
|
'skills.teamwork': 'Travail en équipe',
|
||||||
|
'skills.continuousLearning': 'Apprentissage continu',
|
||||||
|
'skills.communication': 'Communication',
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
'projects.title': 'Mes Projets',
|
||||||
|
'projects.subtitle': 'Découvrez mes réalisations et mes expériences',
|
||||||
|
'projects.status.available': 'Bientôt disponible sur App Store et Play Store',
|
||||||
|
'projects.status.current': 'Projet actuel',
|
||||||
|
'projects.status.personal': 'Projet personnel',
|
||||||
|
'projects.status.online': 'En ligne',
|
||||||
|
'projects.features': 'Fonctionnalités principales :',
|
||||||
|
'projects.btn.code': 'Code',
|
||||||
|
'projects.btn.viewProject': 'Voir le projet',
|
||||||
|
'projects.travelMate.title': 'Travel Mate',
|
||||||
|
'projects.travelMate.description': 'Application mobile conçue pour simplifier l\'organisation de voyages de groupe. Elle permet de centraliser toutes les informations importantes d\'un voyage : planification, gestion des dépenses, découverte d\'activités et coordination entre les participants.',
|
||||||
|
'projects.travelMate.feature1': 'Planification de voyage collaborative',
|
||||||
|
'projects.travelMate.feature2': 'Gestion des dépenses partagées',
|
||||||
|
'projects.travelMate.feature3': 'Découverte d\'activités locales',
|
||||||
|
'projects.travelMate.feature4': 'Coordination en temps réel',
|
||||||
|
'projects.portfolio.title': 'Portfolio Web',
|
||||||
|
'projects.portfolio.description': 'Site web personnel moderne et responsive développé avec React et TypeScript. Inclut des animations fluides, un mode sombre/clair et une architecture modulaire.',
|
||||||
|
'projects.portfolio.feature1': 'Design responsive',
|
||||||
|
'projects.portfolio.feature2': 'Animations fluides',
|
||||||
|
'projects.portfolio.feature3': 'Mode sombre/clair',
|
||||||
|
'projects.portfolio.feature4': 'Performance optimisée',
|
||||||
|
'projects.shelbys.title': 'Shelbys Bar',
|
||||||
|
'projects.shelbys.description': 'Site vitrine élégant pour le bar Shelbys Bar. Présente l\'ambiance, le menu et les événements du bar avec un design moderne et immersif.',
|
||||||
|
'projects.shelbys.feature1': 'Design moderne et immersif',
|
||||||
|
'projects.shelbys.feature2': 'Présentation du menu',
|
||||||
|
'projects.shelbys.feature4': 'Informations pratiques',
|
||||||
|
'projects.homeSync.title': 'Home Sync',
|
||||||
|
'projects.homeSync.description': 'Application de gestion familiale tout-en-un. Synchronisez les calendriers, partagez des listes de courses et gérez votre foyer en toute simplicité.',
|
||||||
|
'projects.homeSync.feature1': 'Calendrier familial partagé',
|
||||||
|
'projects.homeSync.feature2': 'Listes de courses collaboratives',
|
||||||
|
'projects.homeSync.feature3': 'Gestion de foyer simple',
|
||||||
|
'projects.homeSync.feature4': 'Synchronisation Google Agenda',
|
||||||
|
|
||||||
|
// Education
|
||||||
|
'education.title': 'Formation',
|
||||||
|
'education.subtitle': 'Mon parcours académique et professionnel',
|
||||||
|
'education.learningGoals2025': 'Objectifs d\'apprentissage 2025',
|
||||||
|
'education.goal1': 'Maîtriser Firebase et les services cloud',
|
||||||
|
'education.goal2': 'Approfondir Spring Boot pour le backend',
|
||||||
|
'education.goal3': 'Apprendre Docker et les conteneurs',
|
||||||
|
'education.goal4': 'Développer mes compétences en UI/UX',
|
||||||
|
'education.degree': 'Bachelier en Technologies de l\'Informatique',
|
||||||
|
'education.school': 'HELHa - Haute École Louvain en Hainaut',
|
||||||
|
'education.location': 'Tournai, Belgique',
|
||||||
|
'education.period': '2023 - 2026',
|
||||||
|
'education.currentYear': '3ème année',
|
||||||
|
'education.status': 'En cours',
|
||||||
|
'education.description': 'Formation complète en développement logiciel, programmation, bases de données, réseaux et gestion de projets informatiques.',
|
||||||
|
'education.highlights.0': 'Programmation orientée objet (Java, C#)',
|
||||||
|
'education.highlights.1': 'Développement web (HTML, CSS, JavaScript, React)',
|
||||||
|
'education.highlights.2': 'Développement mobile (Flutter, Dart)',
|
||||||
|
'education.highlights.3': 'Bases de données et SQL',
|
||||||
|
'education.highlights.4': 'Gestion de projets',
|
||||||
|
'education.highlights.5': 'Réseaux et systèmes',
|
||||||
|
'education.certifications.title': 'Certifications & Formations',
|
||||||
|
'education.cert1.title': 'Développement Mobile Flutter',
|
||||||
|
'education.cert1.provider': 'Formation autodidacte',
|
||||||
|
'education.cert1.date': '2024',
|
||||||
|
'education.cert2.title': 'React & TypeScript',
|
||||||
|
'education.cert2.provider': 'Projets personnels',
|
||||||
|
'education.cert2.date': '2024',
|
||||||
|
'education.highschool.degree': 'CESS (Certificat d\'Enseignement Secondaire Supérieur)',
|
||||||
|
'education.highschool.school': 'Athénée Royal d\'Ath',
|
||||||
|
'education.highschool.location': 'Ath, Belgique',
|
||||||
|
'education.highschool.period': '2016 - 2022',
|
||||||
|
'education.highschool.status': 'Diplômé',
|
||||||
|
'education.highschool.description': 'Section Math-Sciences (Math 8h - Sciences 7h)',
|
||||||
|
'education.highschool.highlights.0': 'Mathématiques avancées',
|
||||||
|
'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': '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',
|
||||||
|
'travelmate.erasedata.link': 'Suppression',
|
||||||
|
'travelmate.support.link': 'Assistance',
|
||||||
|
|
||||||
|
'travelmate.legal.title': 'Centre d\'Aide & Légal',
|
||||||
|
'travelmate.legal.privacy.desc': 'Consultez nos engagements concernant vos données personnelles.',
|
||||||
|
'travelmate.legal.erasedata.desc': 'Exercez votre droit à l\'oubli en demandant la suppression de vos données.',
|
||||||
|
'travelmate.legal.support.desc': 'Une question ? Un problème ? Contactez notre équipe support.',
|
||||||
|
|
||||||
|
// Support Page
|
||||||
|
'support.title': 'Assistance Travel Mate',
|
||||||
|
'support.description': 'Besoin d\'aide ? Remplissez le formulaire ci-dessous.',
|
||||||
|
'support.form.lastname': 'Nom',
|
||||||
|
'support.form.firstname': 'Prénom',
|
||||||
|
'support.form.accountemail': 'Email du compte',
|
||||||
|
'support.form.contactemail': 'Email de contact',
|
||||||
|
'support.form.sameemail': 'Utiliser le même email pour le contact',
|
||||||
|
'support.form.message': 'Message',
|
||||||
|
'support.form.submit': 'Envoyer',
|
||||||
|
'support.form.submitting': 'Envoi en cours...',
|
||||||
|
'support.success.title': 'Demande envoyée',
|
||||||
|
'support.success.message': 'Nous vous répondrons dès que possible.',
|
||||||
|
'support.error.generic': 'Une erreur est survenue.',
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
|
||||||
|
// Home Sync Page
|
||||||
|
'homesync.page.mainTitle': 'Home Sync 👨👩👧👦',
|
||||||
|
'homesync.page.subtitle': 'L\'organisateur familial ultime',
|
||||||
|
'homesync.page.intro': 'Bienvenue dans Home Sync, l\'application conçue pour simplifier la gestion quotidienne de votre foyer ! Synchronisez vos emplois du temps, gérez des listes de courses partagées et restez organisés, le tout en temps réel. (Projet privé)',
|
||||||
|
|
||||||
|
'homesync.highlights.title': '✨ Fonctionnalités Principales',
|
||||||
|
'homesync.highlight.1.title': 'Calendrier Partagé & Intelligent',
|
||||||
|
'homesync.highlight.1.desc': 'Vue centralisée de tous les événements de la famille. Synchronisation Google Agenda bidirectionnelle (import/export) avec indicateurs visuels.',
|
||||||
|
'homesync.highlight.2.title': 'Listes de Courses Collaboratives',
|
||||||
|
'homesync.highlight.2.desc': 'Ajoutez et cochez des articles en temps réel. Organisation par état et bientôt fonction glisser-déposer pour le parcours en magasin.',
|
||||||
|
'homesync.highlight.3.title': 'Gestion de Foyer',
|
||||||
|
'homesync.highlight.3.desc': 'Créez un foyer ou rejoignez-en un existant via un ID unique. Gestion simple des profils utilisateurs.',
|
||||||
|
'homesync.highlight.4.title': 'Sécurité & Authentification',
|
||||||
|
'homesync.highlight.4.desc': 'Connexion sécurisée via Email ou Google. Vos données familiales restent privées et protégées.',
|
||||||
|
|
||||||
|
'homesync.page.conclusion': 'Reprenez le contrôle de votre organisation familiale avec Home Sync.',
|
||||||
|
|
||||||
|
'homesync.tech.title': '🛠️ Stack Technique',
|
||||||
|
'homesync.tech.frontend': 'Frontend (Flutter)',
|
||||||
|
'homesync.tech.frontend.1': 'Interface utilisateur moderne et réactive',
|
||||||
|
'homesync.tech.frontend.2': 'Gestion d\'état avec BLoC',
|
||||||
|
'homesync.tech.backend': 'Backend / Database (Firebase)',
|
||||||
|
'homesync.tech.backend.1': 'Firestore pour les données temps réel',
|
||||||
|
'homesync.tech.backend.2': 'Authentication (Email, Google)',
|
||||||
|
'homesync.tech.api': 'APIs',
|
||||||
|
'homesync.tech.api.1': 'Google Calendar API',
|
||||||
|
'homesync.viewCode': 'Code source (Privé)',
|
||||||
|
|
||||||
|
// Contact
|
||||||
|
'contact.title': 'Contactez-moi',
|
||||||
|
'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',
|
||||||
|
'contact.form.message': 'Message',
|
||||||
|
'contact.form.send': 'Envoyer le message',
|
||||||
|
'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 sur la technologie. N\'hésitez pas à me contacter !',
|
||||||
|
'contact.findMeOn': 'Retrouvez-moi aussi sur :',
|
||||||
|
'contact.sendMessage': 'Envoyez-moi un message',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
// Navigation
|
||||||
|
'nav.home': 'Home',
|
||||||
|
'nav.about': 'About',
|
||||||
|
'nav.skills': 'Skills',
|
||||||
|
'nav.projects': 'Projects',
|
||||||
|
'nav.education': 'Education',
|
||||||
|
'nav.contact': 'Contact',
|
||||||
|
|
||||||
|
// Boutons
|
||||||
|
'btn.changeTheme': 'Change theme',
|
||||||
|
'btn.menu': 'Menu',
|
||||||
|
'btn.changeLang': 'Français',
|
||||||
|
'btn.downloadCV': 'Download CV',
|
||||||
|
'btn.viewProjects': 'View my projects',
|
||||||
|
'btn.contactMe': 'Contact me',
|
||||||
|
|
||||||
|
// Modal
|
||||||
|
'modal.confirm.title': 'Verification',
|
||||||
|
'modal.confirm.message': 'Please review your information before sending.',
|
||||||
|
'modal.btn.cancel': 'Cancel',
|
||||||
|
'modal.btn.confirm': 'Confirm & Send',
|
||||||
|
|
||||||
|
// Hero
|
||||||
|
'hero.greeting': 'Hi, I am',
|
||||||
|
'hero.title': 'Dayron Van Leemput',
|
||||||
|
'hero.subtitle': 'Computer Technology Student',
|
||||||
|
'hero.description': 'Bachelor 3 at HELHa Tournai | Young developer passionate about new technologies and innovative application development',
|
||||||
|
|
||||||
|
// About
|
||||||
|
'about.title': 'About me',
|
||||||
|
'about.subtitle': 'Discover my journey and passions',
|
||||||
|
'about.stats.year': '3rd',
|
||||||
|
'about.stats.yearLabel': 'Year of studies',
|
||||||
|
'about.stats.passion': '100%',
|
||||||
|
'about.stats.passionLabel': 'Passion',
|
||||||
|
'about.stats.goals': '∞',
|
||||||
|
'about.stats.goalsLabel': 'Goals',
|
||||||
|
'about.stats.fuel': '☕',
|
||||||
|
'about.stats.fuelLabel': 'Daily fuel',
|
||||||
|
'about.journey.title': 'My journey',
|
||||||
|
'about.journey.content': 'Currently in my 3rd year of Computer Technology at HELHa Tournai, I am passionate about application development and new technologies. My journey has allowed me to acquire a solid technical foundation and a methodical approach to development.',
|
||||||
|
'about.passion.title': 'My passion',
|
||||||
|
'about.passion.content': 'What drives me most is creating innovative solutions that solve real problems. I particularly enjoy mobile development with Flutter and modern web development with React and TypeScript.',
|
||||||
|
'about.goals.title': 'My goals',
|
||||||
|
'about.goals.content': 'I constantly seek to improve my skills and stay up to date with the latest technological trends. My goal is to become a versatile full-stack developer and contribute to projects that have a positive impact.',
|
||||||
|
'about.quote.title': 'In a few words',
|
||||||
|
'about.quote.content': 'Technology is nothing. What\'s important is that you have a faith in people, that they\'re basically good and smart, and if you give them tools, they\'ll do wonderful things with them.',
|
||||||
|
'about.quote.author': 'Steve Jobs',
|
||||||
|
|
||||||
|
// Skills
|
||||||
|
'skills.title': 'My Skills',
|
||||||
|
'skills.subtitle': 'Technologies and tools I master',
|
||||||
|
'skills.category.mobile': 'Mobile',
|
||||||
|
'skills.category.frontend': 'Frontend',
|
||||||
|
'skills.category.backend': 'Backend',
|
||||||
|
'skills.category.tools': 'Tools & Others',
|
||||||
|
'skills.otherSkills': 'Other skills',
|
||||||
|
'skills.problemSolving': 'Problem solving',
|
||||||
|
'skills.teamwork': 'Teamwork',
|
||||||
|
'skills.continuousLearning': 'Continuous learning',
|
||||||
|
'skills.communication': 'Communication',
|
||||||
|
|
||||||
|
// Projects
|
||||||
|
'projects.title': 'My Projects',
|
||||||
|
'projects.subtitle': 'Discover my achievements and experiences',
|
||||||
|
'projects.status.available': 'Coming soon on App Store and Play Store',
|
||||||
|
'projects.status.current': 'Current project',
|
||||||
|
'projects.status.personal': 'Personal project',
|
||||||
|
'projects.status.online': 'Online',
|
||||||
|
'projects.features': 'Main features:',
|
||||||
|
'projects.btn.code': 'Code',
|
||||||
|
'projects.btn.viewProject': 'View project',
|
||||||
|
'projects.travelMate.title': 'Travel Mate',
|
||||||
|
'projects.travelMate.description': 'Mobile application designed to simplify group travel organization. It allows centralizing all important travel information: planning, expense management, activity discovery and coordination between participants.',
|
||||||
|
'projects.travelMate.feature1': 'Collaborative travel planning',
|
||||||
|
'projects.travelMate.feature2': 'Shared expense management',
|
||||||
|
'projects.travelMate.feature3': 'Local activity discovery',
|
||||||
|
'projects.travelMate.feature4': 'Real-time coordination',
|
||||||
|
'projects.portfolio.title': 'Web Portfolio',
|
||||||
|
'projects.portfolio.description': 'Modern and responsive personal website developed with React and TypeScript. Includes fluid animations, dark/light mode and modular architecture.',
|
||||||
|
'projects.portfolio.feature1': 'Responsive design',
|
||||||
|
'projects.portfolio.feature2': 'Fluid animations',
|
||||||
|
'projects.portfolio.feature3': 'Dark/light mode',
|
||||||
|
'projects.portfolio.feature4': 'Optimized performance',
|
||||||
|
'projects.shelbys.title': 'Shelbys Bar',
|
||||||
|
'projects.shelbys.description': 'Elegant showcase website for Shelbys Bar. Presents the atmosphere, menu, and events of the bar with a modern and immersive design.',
|
||||||
|
'projects.shelbys.feature1': 'Modern and immersive design',
|
||||||
|
'projects.shelbys.feature2': 'Menu presentation',
|
||||||
|
'projects.shelbys.feature4': 'Practical information',
|
||||||
|
'projects.homeSync.title': 'Home Sync',
|
||||||
|
'projects.homeSync.description': 'All-in-one family management application. Sync calendars, share shopping lists, and manage your household with ease.',
|
||||||
|
'projects.homeSync.feature1': 'Shared family calendar',
|
||||||
|
'projects.homeSync.feature2': 'Collaborative shopping lists',
|
||||||
|
'projects.homeSync.feature3': 'Simple household management',
|
||||||
|
'projects.homeSync.feature4': 'Google Calendar synchronization',
|
||||||
|
|
||||||
|
// Education
|
||||||
|
'education.title': 'Education',
|
||||||
|
'education.subtitle': 'My academic and professional journey',
|
||||||
|
'education.learningGoals2025': '2025 Learning Goals',
|
||||||
|
'education.goal1': 'Master Firebase and cloud services',
|
||||||
|
'education.goal2': 'Deepen Spring Boot for backend',
|
||||||
|
'education.goal3': 'Learn Docker and containers',
|
||||||
|
'education.goal4': 'Develop my UI/UX skills',
|
||||||
|
'education.degree': 'Bachelor in Computer Technology',
|
||||||
|
'education.school': 'HELHa - Haute École Louvain en Hainaut',
|
||||||
|
'education.location': 'Tournai, Belgium',
|
||||||
|
'education.period': '2023 - 2026',
|
||||||
|
'education.currentYear': '3rd year',
|
||||||
|
'education.status': 'In progress',
|
||||||
|
'education.description': 'Complete training in software development, programming, databases, networks and IT project management.',
|
||||||
|
'education.highlights.0': 'Object-oriented programming (Java, C#)',
|
||||||
|
'education.highlights.1': 'Web development (HTML, CSS, JavaScript, React)',
|
||||||
|
'education.highlights.2': 'Mobile development (Flutter, Dart)',
|
||||||
|
'education.highlights.3': 'Databases and SQL',
|
||||||
|
'education.highlights.4': 'Project management',
|
||||||
|
'education.highlights.5': 'Networks and systems',
|
||||||
|
'education.certifications.title': 'Certifications & Training',
|
||||||
|
'education.cert1.title': 'Flutter Mobile Development',
|
||||||
|
'education.cert1.provider': 'Self-taught training',
|
||||||
|
'education.cert1.date': '2024',
|
||||||
|
'education.cert2.title': 'React & TypeScript',
|
||||||
|
'education.cert2.provider': 'Personal projects',
|
||||||
|
'education.cert2.date': '2024',
|
||||||
|
'education.highschool.degree': 'CESS (Certificate of Upper Secondary Education)',
|
||||||
|
'education.highschool.school': 'Athénée Royal d\'Ath',
|
||||||
|
'education.highschool.location': 'Ath, Belgium',
|
||||||
|
'education.highschool.period': '2016 - 2022',
|
||||||
|
'education.highschool.status': 'Graduated',
|
||||||
|
'education.highschool.description': 'Math-Science Section (Math 8h - Science 7h)',
|
||||||
|
'education.highschool.highlights.0': 'Advanced Mathematics',
|
||||||
|
'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': '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': 'Data Deletion',
|
||||||
|
'travelmate.support.link': 'Support',
|
||||||
|
|
||||||
|
'travelmate.legal.title': 'Help & Legal Center',
|
||||||
|
'travelmate.legal.privacy.desc': 'Read about our commitments regarding your personal data.',
|
||||||
|
'travelmate.legal.erasedata.desc': 'Exercise your right to be forgotten by requesting data deletion.',
|
||||||
|
'travelmate.legal.support.desc': 'A question? A problem? Contact our support team.',
|
||||||
|
|
||||||
|
// Support Page
|
||||||
|
'support.title': 'Travel Mate Support',
|
||||||
|
'support.description': 'Need help? Fill out the form below.',
|
||||||
|
'support.form.lastname': 'Last Name',
|
||||||
|
'support.form.firstname': 'First Name',
|
||||||
|
'support.form.accountemail': 'Account Email',
|
||||||
|
'support.form.contactemail': 'Contact Email',
|
||||||
|
'support.form.sameemail': 'Use same email for contact',
|
||||||
|
'support.form.message': 'Message',
|
||||||
|
'support.form.submit': 'Send',
|
||||||
|
'support.form.submitting': 'Sending...',
|
||||||
|
'support.success.title': 'Request Sent',
|
||||||
|
'support.success.message': 'We will reply as soon as possible.',
|
||||||
|
'support.error.generic': 'An error occurred.',
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
|
||||||
|
// Home Sync Page
|
||||||
|
'homesync.page.mainTitle': 'Home Sync 👨👩👧👦',
|
||||||
|
'homesync.page.subtitle': 'The ultimate family organizer',
|
||||||
|
'homesync.page.intro': 'Welcome to Home Sync, the app designed to simplify your household\'s daily management! Sync schedules, manage shared shopping lists, and stay organized, all in real-time. (Private Project)',
|
||||||
|
|
||||||
|
'homesync.highlights.title': '✨ Main Features',
|
||||||
|
'homesync.highlight.1.title': 'Shared & Smart Calendar',
|
||||||
|
'homesync.highlight.1.desc': 'Centralized view of all family events. Bidirectional Google Calendar synchronization (import/export) with visual indicators.',
|
||||||
|
'homesync.highlight.2.title': 'Collaborative Shopping Lists',
|
||||||
|
'homesync.highlight.2.desc': 'Add and check off items in real-time. Organized by status and coming soon: drag-and-drop for store routing.',
|
||||||
|
'homesync.highlight.3.title': 'Household Management',
|
||||||
|
'homesync.highlight.3.desc': 'Create a new household or join an existing one via a unique ID. Simple user profile management.',
|
||||||
|
'homesync.highlight.4.title': 'Security & Authentication',
|
||||||
|
'homesync.highlight.4.desc': 'Secure login via Email or Google. Your family data remains private and protected.',
|
||||||
|
|
||||||
|
'homesync.page.conclusion': 'Take back control of your family organization with Home Sync.',
|
||||||
|
|
||||||
|
'homesync.tech.title': '🛠️ Tech Stack',
|
||||||
|
'homesync.tech.frontend': 'Frontend (Flutter)',
|
||||||
|
'homesync.tech.frontend.1': 'Modern and responsive user interface',
|
||||||
|
'homesync.tech.frontend.2': 'State Management with BLoC',
|
||||||
|
'homesync.tech.backend': 'Backend / Database (Firebase)',
|
||||||
|
'homesync.tech.backend.1': 'Firestore for real-time data',
|
||||||
|
'homesync.tech.backend.2': 'Authentication (Email, Google)',
|
||||||
|
'homesync.tech.api': 'APIs',
|
||||||
|
'homesync.tech.api.1': 'Google Calendar API',
|
||||||
|
'homesync.viewCode': 'Source Code (Private)',
|
||||||
|
|
||||||
|
// Contact
|
||||||
|
'contact.title': 'Contact me',
|
||||||
|
'contact.subtitle': 'A question, a project or just want to chat? Feel free to contact me!',
|
||||||
|
'contact.form.name': 'Full name',
|
||||||
|
'contact.form.email': 'Email',
|
||||||
|
'contact.form.subject': 'Subject',
|
||||||
|
'contact.form.message': 'Message',
|
||||||
|
'contact.form.send': 'Send message',
|
||||||
|
'contact.form.sending': 'Sending...',
|
||||||
|
'contact.success': 'Message sent successfully! I will reply soon.',
|
||||||
|
'contact.stayInTouch': 'Let\'s stay in touch',
|
||||||
|
'contact.intro': 'I am always interested in new projects, collaborations or simply discussions about technology. Feel free to contact me!',
|
||||||
|
'contact.findMeOn': 'Find me also on:',
|
||||||
|
'contact.sendMessage': 'Send me a message',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Création du contexte
|
||||||
|
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
// Provider
|
||||||
|
interface LanguageProviderProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children }) => {
|
||||||
|
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[currentLanguage][key as keyof typeof translations['fr']] || key;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LanguageContext.Provider value={{ language: currentLanguage, setLanguage, t }}>
|
||||||
|
{children}
|
||||||
|
</LanguageContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hook personnalisé
|
||||||
|
export const useLanguage = (): LanguageContextType => {
|
||||||
|
const context = useContext(LanguageContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useLanguage must be used within a LanguageProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
121
frontend/src/services/emailService.ts
Normal file
121
frontend/src/services/emailService.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
// ========================
|
||||||
|
// SERVICE D'ENVOI D'EMAIL
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
import emailjs from '@emailjs/browser';
|
||||||
|
|
||||||
|
// Configuration EmailJS depuis les variables d'environnement
|
||||||
|
const EMAILJS_CONFIG = {
|
||||||
|
SERVICE_ID: import.meta.env.VITE_EMAILJS_SERVICE_ID || 'YOUR_SERVICE_ID',
|
||||||
|
TEMPLATE_ID: import.meta.env.VITE_EMAILJS_TEMPLATE_ID || 'YOUR_TEMPLATE_ID',
|
||||||
|
PUBLIC_KEY: import.meta.env.VITE_EMAILJS_PUBLIC_KEY || 'YOUR_PUBLIC_KEY'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Interface pour les données du formulaire
|
||||||
|
export interface ContactFormData {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
subject: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface pour la réponse
|
||||||
|
export interface EmailResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction pour envoyer l'email
|
||||||
|
export const sendContactEmail = async (formData: ContactFormData): Promise<EmailResponse> => {
|
||||||
|
try {
|
||||||
|
// Validation des données
|
||||||
|
if (!formData.name || !formData.email || !formData.subject || !formData.message) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Tous les champs sont requis.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation de l'email
|
||||||
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
if (!emailRegex.test(formData.email)) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Veuillez entrer une adresse email valide.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérification de la configuration EmailJS
|
||||||
|
if (!validateEmailJSConfig()) {
|
||||||
|
console.warn('Configuration EmailJS non trouvée, utilisation du fallback mailto');
|
||||||
|
// Fallback vers mailto
|
||||||
|
const mailtoLink = createMailtoLink(formData);
|
||||||
|
window.location.href = mailtoLink;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Ouverture de votre client email par défaut...'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Envoi avec EmailJS...');
|
||||||
|
console.log('Service ID:', EMAILJS_CONFIG.SERVICE_ID);
|
||||||
|
console.log('Template ID:', EMAILJS_CONFIG.TEMPLATE_ID);
|
||||||
|
|
||||||
|
// Préparation des paramètres pour EmailJS
|
||||||
|
const templateParams = {
|
||||||
|
from_name: formData.name,
|
||||||
|
from_email: formData.email,
|
||||||
|
subject: formData.subject,
|
||||||
|
message: formData.message,
|
||||||
|
to_name: 'Dayron Van Leemput', // Votre nom
|
||||||
|
reply_to: formData.email
|
||||||
|
};
|
||||||
|
|
||||||
|
// Envoi avec EmailJS
|
||||||
|
const response = await emailjs.send(
|
||||||
|
EMAILJS_CONFIG.SERVICE_ID,
|
||||||
|
EMAILJS_CONFIG.TEMPLATE_ID,
|
||||||
|
templateParams,
|
||||||
|
EMAILJS_CONFIG.PUBLIC_KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'Message envoyé avec succès ! Je vous répondrai bientôt.'
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error('Erreur lors de l\'envoi');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de l\'envoi de l\'email:', error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: 'Une erreur s\'est produite lors de l\'envoi. Veuillez réessayer ou me contacter directement.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction alternative pour fallback (formulaire mailto)
|
||||||
|
export const createMailtoLink = (formData: ContactFormData): string => {
|
||||||
|
const subject = encodeURIComponent(`[Portfolio] ${formData.subject}`);
|
||||||
|
const body = encodeURIComponent(
|
||||||
|
`Bonjour Dayron,\n\n` +
|
||||||
|
`${formData.message}\n\n` +
|
||||||
|
`Cordialement,\n${formData.name}\n` +
|
||||||
|
`Email: ${formData.email}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return `mailto:dayron.vanleemput@example.com?subject=${subject}&body=${body}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fonction pour valider les credentials EmailJS
|
||||||
|
export const validateEmailJSConfig = (): boolean => {
|
||||||
|
return (
|
||||||
|
EMAILJS_CONFIG.SERVICE_ID !== 'YOUR_SERVICE_ID' &&
|
||||||
|
EMAILJS_CONFIG.TEMPLATE_ID !== 'YOUR_TEMPLATE_ID' &&
|
||||||
|
EMAILJS_CONFIG.PUBLIC_KEY !== 'YOUR_PUBLIC_KEY'
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
@@ -138,6 +138,19 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
background: #ff4757;
|
||||||
|
color: white;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid #ff3838;
|
||||||
|
}
|
||||||
|
|
||||||
.contact-form {
|
.contact-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -93,7 +93,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.theme-toggle,
|
.theme-toggle,
|
||||||
.menu-toggle {
|
.menu-toggle,
|
||||||
|
.language-toggle {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -113,6 +114,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.language-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
|
||||||
|
.language-text {
|
||||||
|
font-size: map-get($font-sizes, sm);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.menu-toggle {
|
.menu-toggle {
|
||||||
display: none;
|
display: none;
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
&-title {
|
&-title {
|
||||||
font-size: map-get($font-sizes, 4xl);
|
font-size: map-get($font-sizes, 4xl);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
margin-bottom: 16px;
|
margin: 20px;
|
||||||
@include gradient-text();
|
@include gradient-text();
|
||||||
|
|
||||||
@include respond-to(sm) {
|
@include respond-to(sm) {
|
||||||
@@ -99,6 +99,10 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
|
||||||
|
@include respond-to(md) {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.social-link {
|
.social-link {
|
||||||
@include flex-center();
|
@include flex-center();
|
||||||
width: 48px;
|
width: 48px;
|
||||||
@@ -131,27 +135,56 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-placeholder {
|
.avatar-image {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: linear-gradient(135deg, $primary-color, $accent-color);
|
object-fit: cover;
|
||||||
@include flex-center();
|
object-position: center;
|
||||||
font-size: map-get($font-sizes, 4xl);
|
|
||||||
font-weight: 800;
|
|
||||||
color: white;
|
|
||||||
@include box-shadow(large);
|
@include box-shadow(large);
|
||||||
|
border: 4px solid white;
|
||||||
|
|
||||||
|
// Ajouter un effet de dégradé subtil en overlay
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg,
|
||||||
|
rgba($primary-color, 0.1) 0%,
|
||||||
|
rgba($accent-color, 0.1) 100%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
@include respond-to(md) {
|
@include respond-to(md) {
|
||||||
width: 250px;
|
width: 250px;
|
||||||
height: 250px;
|
height: 250px;
|
||||||
font-size: map-get($font-sizes, 3xl);
|
border-width: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include respond-to(sm) {
|
@include respond-to(sm) {
|
||||||
width: 200px;
|
width: 200px;
|
||||||
height: 200px;
|
height: 200px;
|
||||||
font-size: map-get($font-sizes, 2xl);
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effet de survol amélioré pour les images
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
@include box-shadow(large);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background: linear-gradient(135deg,
|
||||||
|
rgba($primary-color, 0.2) 0%,
|
||||||
|
rgba($accent-color, 0.2) 100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10,7 +10,9 @@
|
|||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
|
|
||||||
&-grid {
|
&-grid {
|
||||||
@include grid-responsive(400px, 40px);
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 40px;
|
||||||
margin-bottom: 60px;
|
margin-bottom: 60px;
|
||||||
|
|
||||||
@include respond-to(md) {
|
@include respond-to(md) {
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |
48
src/App.tsx
48
src/App.tsx
@@ -1,48 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
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 (
|
|
||||||
<div className={`app ${darkMode ? 'dark' : 'light'}`}>
|
|
||||||
<Header darkMode={darkMode} toggleDarkMode={toggleDarkMode} />
|
|
||||||
<main>
|
|
||||||
<Hero />
|
|
||||||
<About />
|
|
||||||
<Skills />
|
|
||||||
<Projects />
|
|
||||||
<Education />
|
|
||||||
<Contact />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
Reference in New Issue
Block a user