feat: Initialize backend with Express and MySQL, and restructure frontend with new routing and language support.

This commit is contained in:
Van Leemput Dayron
2025-12-15 17:03:03 +01:00
parent 56897a0c2d
commit 6c11cf5213
54 changed files with 2000 additions and 174 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

View File

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

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

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

View File

@@ -1,105 +0,0 @@
# Configuration EmailJS pour le Portfolio
Ce guide vous explique comment configurer EmailJS pour que le formulaire de contact de votre portfolio fonctionne réellement.
## 1. Créer un compte EmailJS
1. Allez sur [EmailJS.com](https://www.emailjs.com/)
2. Créez un compte gratuit (jusqu'à 200 emails/mois)
3. Connectez-vous à votre dashboard
## 2. Configurer le service email
1. Dans votre dashboard EmailJS, allez dans **Email Services**
2. Cliquez sur **Add New Service**
3. Choisissez votre fournisseur email (Gmail, Outlook, etc.)
4. Suivez les instructions pour connecter votre compte email
5. Notez votre **Service ID** (ex: `service_xyz123`)
## 3. Créer un template d'email
1. Allez dans **Email Templates**
2. Cliquez sur **Create New Template**
3. Configurez votre template avec ces variables :
- `{{from_name}}` - Nom de l'expéditeur
- `{{from_email}}` - Email de l'expéditeur
- `{{subject}}` - Sujet du message
- `{{message}}` - Corps du message
Exemple de template :
```
Nouveau message depuis votre portfolio
De: {{from_name}} ({{from_email}})
Sujet: {{subject}}
Message:
{{message}}
---
Ce message a été envoyé depuis votre formulaire de contact.
```
4. Notez votre **Template ID** (ex: `template_abc456`)
## 4. Obtenir votre clé publique
1. Allez dans **Account** > **General**
2. Trouvez votre **Public Key** (ex: `user_def789`)
## 5. Configurer les variables d'environnement
Créez un fichier `.env` à la racine de votre projet :
```env
VITE_EMAILJS_SERVICE_ID=your_service_id_here
VITE_EMAILJS_TEMPLATE_ID=your_template_id_here
VITE_EMAILJS_PUBLIC_KEY=your_public_key_here
```
## 6. Sécurité et limitations
### Version gratuite d'EmailJS :
- 200 emails par mois maximum
- Pas de limitation de domaine
- Support communautaire
### Pour la production :
- Considérez un plan payant pour plus d'emails
- Ajoutez une validation côté serveur si nécessaire
- Implémentez un captcha pour éviter le spam
## 7. Test du formulaire
1. Remplissez les variables d'environnement
2. Redémarrez votre serveur de développement
3. Testez le formulaire de contact
4. Vérifiez la réception de l'email
## 8. Dépannage
### Erreurs communes :
**"Invalid template ID"**
- Vérifiez que le Template ID est correct
- Assurez-vous que le template est publié
**"Forbidden"**
- Vérifiez votre Public Key
- Assurez-vous que le service est actif
**Email non reçu**
- Vérifiez vos spams
- Vérifiez la configuration du service email
- Testez depuis le dashboard EmailJS
### Fallback automatique
Si EmailJS ne fonctionne pas, le système bascule automatiquement vers `mailto:` pour ouvrir le client email par défaut de l'utilisateur.
## 9. Fichiers modifiés
- `src/services/emailService.ts` - Service d'envoi d'emails
- `src/components/Contact.tsx` - Intégration du service
- `src/styles/components/_contact.scss` - Styles des messages d'erreur
Le formulaire de contact est maintenant prêt à fonctionner avec une vraie logique d'envoi d'emails !

4
backend/.gitignore vendored Normal file
View File

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

1769
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
backend/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "xeewy-server",
"version": "1.0.0",
"description": "Backend API for xeewy.be",
"main": "dist/index.js",
"scripts": {
"start": "node dist/index.js",
"dev": "nodemon src/index.ts",
"build": "tsc"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.1",
"mysql2": "^3.11.3"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.8.6",
"nodemon": "^3.1.7",
"ts-node": "^10.9.2",
"typescript": "^5.6.3"
}
}

17
backend/src/config/db.ts Normal file
View File

@@ -0,0 +1,17 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
port: Number(process.env.DB_PORT) || 3306,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
export default pool;

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

@@ -0,0 +1,32 @@
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
import pool from './config/db';
dotenv.config();
const app = express();
const port = process.env.PORT || 3000;
app.use(cors());
app.use(express.json());
// Basic health check
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date() });
});
// Database connection test
app.get('/api/test-db', async (req, res) => {
try {
const [rows] = await pool.query('SELECT 1 + 1 AS solution');
res.json({ status: 'connected', result: rows });
} catch (error: any) {
console.error('Database connection error:', error);
res.status(500).json({ status: 'error', message: error.message });
}
});
app.listen(port, () => {
console.log(`Server running on port ${port}`);
});

18
backend/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "es2016",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules"
]
}

View File

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

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

@@ -0,0 +1,72 @@
import { useState, useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom';
import { LanguageProvider } from './contexts/LanguageContext';
import Header from './components/Header';
import Footer from './components/Footer';
import Home from './components/Home';
import TravelMate from './components/TravelMate';
import Policies from './components/Policies';
import ScrollToTop from './components/ScrollToTop';
import './styles/main.scss';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Navigate to="/fr" replace />} />
<Route path="/:lang/*" element={
<LanguageProvider>
<AppContent />
</LanguageProvider>
} />
</Routes>
</BrowserRouter>
);
}
function AppContent() {
const [darkMode, setDarkMode] = useState(false);
const useParamsLang = useParams();
// Sync URL language with context if needed, or validate it
useEffect(() => {
// Optional: Check if useParamsLang.lang is valid, else redirect
}, [useParamsLang]);
useEffect(() => {
const savedTheme = localStorage.getItem('darkMode');
if (savedTheme) {
setDarkMode(JSON.parse(savedTheme));
} else {
setDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches);
}
}, []);
useEffect(() => {
localStorage.setItem('darkMode', JSON.stringify(darkMode));
document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light');
}, [darkMode]);
const toggleDarkMode = () => {
setDarkMode(!darkMode);
};
return (
<>
<ScrollToTop />
<div className={`app ${darkMode ? 'dark' : 'light'}`} style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<Header darkMode={darkMode} toggleDarkMode={toggleDarkMode} />
<main style={{ flex: 1 }}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="travelmate" element={<TravelMate />} />
<Route path="travelmate/policies" element={<Policies />} />
</Routes>
</main>
<Footer />
</div>
</>
);
}
export default App;

View File

Before

Width:  |  Height:  |  Size: 694 KiB

After

Width:  |  Height:  |  Size: 694 KiB

View File

Before

Width:  |  Height:  |  Size: 94 KiB

After

Width:  |  Height:  |  Size: 94 KiB

View File

@@ -27,7 +27,7 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
const handleNavigation = (href: string) => { const handleNavigation = (href: string) => {
setIsMenuOpen(false); setIsMenuOpen(false);
if (location.pathname === '/') { if (location.pathname === `/${language}` || location.pathname === `/${language}/`) {
// Already on home, just scroll // Already on home, just scroll
const element = document.querySelector(href); const element = document.querySelector(href);
if (element) { if (element) {
@@ -35,7 +35,7 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
} }
} else { } else {
// Not on home, navigate then scroll (using a simple timeout for simplicity or hash) // Not on home, navigate then scroll (using a simple timeout for simplicity or hash)
navigate('/'); navigate(`/${language}`);
// Small timeout to allow navigation to render Home before scrolling // Small timeout to allow navigation to render Home before scrolling
setTimeout(() => { setTimeout(() => {
const element = document.querySelector(href); const element = document.querySelector(href);
@@ -63,7 +63,7 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
> >
<Link to="/" onClick={(e) => { <Link to={`/${language}`} onClick={(e) => {
e.preventDefault(); e.preventDefault();
handleNavigation('#hero'); handleNavigation('#hero');
}}> }}>

View File

@@ -4,7 +4,7 @@ import { Link } from 'react-router-dom';
import { ArrowLeft, ExternalLink, Camera, MapPin, Bell } from 'lucide-react'; import { ArrowLeft, ExternalLink, Camera, MapPin, Bell } from 'lucide-react';
const Policies = () => { const Policies = () => {
const { t } = useLanguage(); const { t, language } = useLanguage();
const sections = [2, 3, 4, 5, 6]; // Sections after the permission cards (1 is before) const sections = [2, 3, 4, 5, 6]; // Sections after the permission cards (1 is before)
@@ -27,7 +27,7 @@ const Policies = () => {
<div className="policies-page" style={{ paddingTop: '100px', minHeight: '100vh', paddingBottom: '50px' }}> <div className="policies-page" style={{ paddingTop: '100px', minHeight: '100vh', paddingBottom: '50px' }}>
<div className="container" style={{ maxWidth: '800px', margin: '0 auto', padding: '0 20px' }}> <div className="container" style={{ maxWidth: '800px', margin: '0 auto', padding: '0 20px' }}>
<Link <Link
to="/travelmate" to={`/${language}/travelmate`}
style={{ style={{
display: 'inline-flex', display: 'inline-flex',
alignItems: 'center', alignItems: 'center',

View File

@@ -4,7 +4,7 @@ import { useLanguage } from '../contexts/LanguageContext';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
const Projects = () => { const Projects = () => {
const { t } = useLanguage(); const { t, language } = useLanguage();
const projects = [ const projects = [
{ {
id: 1, id: 1,
@@ -184,7 +184,7 @@ const Projects = () => {
{project.links.demo !== "#" && ( {project.links.demo !== "#" && (
project.links.demo.startsWith('/') ? ( project.links.demo.startsWith('/') ? (
<Link to={project.links.demo} style={{ textDecoration: 'none' }}> <Link to={`/${language}${project.links.demo}`} style={{ textDecoration: 'none' }}>
<motion.div <motion.div
className="project-link primary" className="project-link primary"
whileHover={{ scale: 1.1 }} whileHover={{ scale: 1.1 }}

View File

@@ -64,7 +64,7 @@ const FeatureCard = ({ title, icon: Icon, description }: { title: string, icon:
); );
const TravelMate = () => { const TravelMate = () => {
const { t } = useLanguage(); const { t, language } = useLanguage();
const containerVariants = { const containerVariants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
@@ -248,7 +248,7 @@ const TravelMate = () => {
{t('policies.title')} {t('policies.title')}
</h3> </h3>
<Link <Link
to="/travelmate/policies" to={`/${language}/travelmate/policies`}
className="btn btn-secondary" className="btn btn-secondary"
style={{ style={{
display: 'inline-flex', display: 'inline-flex',

View File

@@ -2,7 +2,7 @@
// CONTEXTE DE LANGUE // CONTEXTE DE LANGUE
// ======================== // ========================
import React, { createContext, useContext, useState } from 'react'; import React, { createContext, useContext } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
// Types pour les langues supportées // Types pour les langues supportées
@@ -169,7 +169,7 @@ const translations = {
'travelmate.tech.api': 'APIs & Outils', 'travelmate.tech.api': 'APIs & Outils',
'travelmate.tech.api.1': 'Google Maps API / Mapbox', 'travelmate.tech.api.1': 'Google Maps API / Mapbox',
'travelmate.tech.api.2': 'Stripe (tbc) pour les paiements', 'travelmate.tech.api.2': 'Stripe (tbc) pour les paiements',
'travelmate.tech.api.3': 'CI/CD avec GitHub Actions', 'travelmate.tech.api.3': 'CI/CD avec Gitea Actions',
'travelmate.viewCode': 'Voir le code', 'travelmate.viewCode': 'Voir le code',
'travelmate.policies.link': 'Voir les politiques de confidentialité', 'travelmate.policies.link': 'Voir les politiques de confidentialité',
@@ -370,11 +370,12 @@ const translations = {
'travelmate.tech.backend.1': 'Authentication (Google, Apple, Email)', 'travelmate.tech.backend.1': 'Authentication (Google, Apple, Email)',
'travelmate.tech.backend.2': 'Firestore (Real-time Database)', 'travelmate.tech.backend.2': 'Firestore (Real-time Database)',
'travelmate.tech.backend.3': 'Cloud Functions (Server Logic)', 'travelmate.tech.backend.3': 'Cloud Functions (Server Logic)',
'travelmate.tech.backend.4': 'Storage (Media)', 'travelmate.tech.backend.4': 'Firebase Cloud Messaging - Notifications push',
'travelmate.tech.backend.5': 'Storage (Media)',
'travelmate.tech.api': 'APIs & Tools', 'travelmate.tech.api': 'APIs & Tools',
'travelmate.tech.api.1': 'Google Maps API / Mapbox', 'travelmate.tech.api.1': 'Google Maps API / Mapbox',
'travelmate.tech.api.2': 'Stripe (tbc) for payments', 'travelmate.tech.api.2': 'Stripe (tbc) for payments',
'travelmate.tech.api.3': 'CI/CD with GitHub Actions', 'travelmate.tech.api.3': 'CI/CD with Gitea Actions',
'travelmate.viewCode': 'View Code', 'travelmate.viewCode': 'View Code',
'travelmate.policies.link': 'View Privacy Policy', 'travelmate.policies.link': 'View Privacy Policy',
@@ -437,16 +438,48 @@ interface LanguageProviderProps {
children: ReactNode; children: ReactNode;
} }
import { useNavigate, useParams, useLocation } from 'react-router-dom';
export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children }) => { export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children }) => {
const [language, setLanguage] = useState<Language>('fr'); const { lang } = useParams<{ lang: string }>();
const navigate = useNavigate();
const location = useLocation();
// Validate language, default to 'fr' if undefined or invalid (though App.tsx handles defaults)
const currentLanguage: Language = (lang === 'en' || lang === 'fr') ? lang : 'fr';
const setLanguage = (newLang: Language) => {
if (newLang === currentLanguage) return;
// Replace the language segment in the URL
// Assumes the first segment is the language
const currentPath = location.pathname;
const segments = currentPath.split('/');
// segments[0] is empty string (before first slash)
// segments[1] is the language
if (segments[1] === 'fr' || segments[1] === 'en') {
segments[1] = newLang;
} else {
// Should not happen if routing is correct, but safe fallback
segments.splice(1, 0, newLang);
}
const newPath = segments.join('/');
navigate(newPath + location.search + location.hash);
};
// Update HTML lang attribute
React.useEffect(() => {
document.documentElement.lang = currentLanguage;
}, [currentLanguage]);
// Fonction de traduction // Fonction de traduction
const t = (key: string): string => { const t = (key: string): string => {
return translations[language][key as keyof typeof translations['fr']] || key; return translations[currentLanguage][key as keyof typeof translations['fr']] || key;
}; };
return ( return (
<LanguageContext.Provider value={{ language, setLanguage, t }}> <LanguageContext.Provider value={{ language: currentLanguage, setLanguage, t }}>
{children} {children}
</LanguageContext.Provider> </LanguageContext.Provider>
); );

View File

@@ -1,53 +0,0 @@
import { useState, useEffect } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { LanguageProvider } from './contexts/LanguageContext';
import Header from './components/Header';
import Footer from './components/Footer';
import Home from './components/Home';
import TravelMate from './components/TravelMate';
import Policies from './components/Policies';
import ScrollToTop from './components/ScrollToTop';
import './styles/main.scss';
function App() {
const [darkMode, setDarkMode] = useState(false);
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 (
<LanguageProvider>
<BrowserRouter>
<ScrollToTop />
<div className={`app ${darkMode ? 'dark' : 'light'}`} style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
<Header darkMode={darkMode} toggleDarkMode={toggleDarkMode} />
<main style={{ flex: 1 }}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/travelmate" element={<TravelMate />} />
<Route path="/travelmate/policies" element={<Policies />} />
</Routes>
</main>
<Footer />
</div>
</BrowserRouter>
</LanguageProvider>
);
}
export default App;