feat: Initialize backend with Express and MySQL, and restructure frontend with new routing and language support.
This commit is contained in:
8
.vite/deps/_metadata.json
Normal file
8
.vite/deps/_metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hash": "a5aca759",
|
||||
"configHash": "b6b52121",
|
||||
"lockfileHash": "e3b0c442",
|
||||
"browserHash": "5526a657",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
3
.vite/deps/package.json
Normal file
3
.vite/deps/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
105
EMAILJS_SETUP.md
105
EMAILJS_SETUP.md
@@ -1,105 +0,0 @@
|
||||
# Configuration EmailJS pour le Portfolio
|
||||
|
||||
Ce guide vous explique comment configurer EmailJS pour que le formulaire de contact de votre portfolio fonctionne réellement.
|
||||
|
||||
## 1. Créer un compte EmailJS
|
||||
|
||||
1. Allez sur [EmailJS.com](https://www.emailjs.com/)
|
||||
2. Créez un compte gratuit (jusqu'à 200 emails/mois)
|
||||
3. Connectez-vous à votre dashboard
|
||||
|
||||
## 2. Configurer le service email
|
||||
|
||||
1. Dans votre dashboard EmailJS, allez dans **Email Services**
|
||||
2. Cliquez sur **Add New Service**
|
||||
3. Choisissez votre fournisseur email (Gmail, Outlook, etc.)
|
||||
4. Suivez les instructions pour connecter votre compte email
|
||||
5. Notez votre **Service ID** (ex: `service_xyz123`)
|
||||
|
||||
## 3. Créer un template d'email
|
||||
|
||||
1. Allez dans **Email Templates**
|
||||
2. Cliquez sur **Create New Template**
|
||||
3. Configurez votre template avec ces variables :
|
||||
- `{{from_name}}` - Nom de l'expéditeur
|
||||
- `{{from_email}}` - Email de l'expéditeur
|
||||
- `{{subject}}` - Sujet du message
|
||||
- `{{message}}` - Corps du message
|
||||
|
||||
Exemple de template :
|
||||
```
|
||||
Nouveau message depuis votre portfolio
|
||||
|
||||
De: {{from_name}} ({{from_email}})
|
||||
Sujet: {{subject}}
|
||||
|
||||
Message:
|
||||
{{message}}
|
||||
|
||||
---
|
||||
Ce message a été envoyé depuis votre formulaire de contact.
|
||||
```
|
||||
|
||||
4. Notez votre **Template ID** (ex: `template_abc456`)
|
||||
|
||||
## 4. Obtenir votre clé publique
|
||||
|
||||
1. Allez dans **Account** > **General**
|
||||
2. Trouvez votre **Public Key** (ex: `user_def789`)
|
||||
|
||||
## 5. Configurer les variables d'environnement
|
||||
|
||||
Créez un fichier `.env` à la racine de votre projet :
|
||||
|
||||
```env
|
||||
VITE_EMAILJS_SERVICE_ID=your_service_id_here
|
||||
VITE_EMAILJS_TEMPLATE_ID=your_template_id_here
|
||||
VITE_EMAILJS_PUBLIC_KEY=your_public_key_here
|
||||
```
|
||||
|
||||
## 6. Sécurité et limitations
|
||||
|
||||
### Version gratuite d'EmailJS :
|
||||
- 200 emails par mois maximum
|
||||
- Pas de limitation de domaine
|
||||
- Support communautaire
|
||||
|
||||
### Pour la production :
|
||||
- Considérez un plan payant pour plus d'emails
|
||||
- Ajoutez une validation côté serveur si nécessaire
|
||||
- Implémentez un captcha pour éviter le spam
|
||||
|
||||
## 7. Test du formulaire
|
||||
|
||||
1. Remplissez les variables d'environnement
|
||||
2. Redémarrez votre serveur de développement
|
||||
3. Testez le formulaire de contact
|
||||
4. Vérifiez la réception de l'email
|
||||
|
||||
## 8. Dépannage
|
||||
|
||||
### Erreurs communes :
|
||||
|
||||
**"Invalid template ID"**
|
||||
- Vérifiez que le Template ID est correct
|
||||
- Assurez-vous que le template est publié
|
||||
|
||||
**"Forbidden"**
|
||||
- Vérifiez votre Public Key
|
||||
- Assurez-vous que le service est actif
|
||||
|
||||
**Email non reçu**
|
||||
- Vérifiez vos spams
|
||||
- Vérifiez la configuration du service email
|
||||
- Testez depuis le dashboard EmailJS
|
||||
|
||||
### Fallback automatique
|
||||
Si EmailJS ne fonctionne pas, le système bascule automatiquement vers `mailto:` pour ouvrir le client email par défaut de l'utilisateur.
|
||||
|
||||
## 9. Fichiers modifiés
|
||||
|
||||
- `src/services/emailService.ts` - Service d'envoi d'emails
|
||||
- `src/components/Contact.tsx` - Intégration du service
|
||||
- `src/styles/components/_contact.scss` - Styles des messages d'erreur
|
||||
|
||||
Le formulaire de contact est maintenant prêt à fonctionner avec une vraie logique d'envoi d'emails !
|
||||
4
backend/.gitignore
vendored
Normal file
4
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.env
|
||||
.env.example
|
||||
.env.local
|
||||
1769
backend/package-lock.json
generated
Normal file
1769
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
backend/package.json
Normal file
28
backend/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "xeewy-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend API for xeewy.be",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"dev": "nodemon src/index.ts",
|
||||
"build": "tsc"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.1",
|
||||
"mysql2": "^3.11.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.8.6",
|
||||
"nodemon": "^3.1.7",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
17
backend/src/config/db.ts
Normal file
17
backend/src/config/db.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
port: Number(process.env.DB_PORT) || 3306,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0
|
||||
});
|
||||
|
||||
export default pool;
|
||||
32
backend/src/index.ts
Normal file
32
backend/src/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import pool from './config/db';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Basic health check
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date() });
|
||||
});
|
||||
|
||||
// Database connection test
|
||||
app.get('/api/test-db', async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT 1 + 1 AS solution');
|
||||
res.json({ status: 'connected', result: rows });
|
||||
} catch (error: any) {
|
||||
console.error('Database connection error:', error);
|
||||
res.status(500).json({ status: 'error', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server running on port ${port}`);
|
||||
});
|
||||
18
backend/tsconfig.json
Normal file
18
backend/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2016",
|
||||
"module": "commonjs",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
0
.gitignore → frontend/.gitignore
vendored
0
.gitignore → frontend/.gitignore
vendored
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
72
frontend/src/App.tsx
Normal file
72
frontend/src/App.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom';
|
||||
import { LanguageProvider } from './contexts/LanguageContext';
|
||||
import Header from './components/Header';
|
||||
import Footer from './components/Footer';
|
||||
import Home from './components/Home';
|
||||
import TravelMate from './components/TravelMate';
|
||||
import Policies from './components/Policies';
|
||||
import ScrollToTop from './components/ScrollToTop';
|
||||
import './styles/main.scss';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/fr" replace />} />
|
||||
<Route path="/:lang/*" element={
|
||||
<LanguageProvider>
|
||||
<AppContent />
|
||||
</LanguageProvider>
|
||||
} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
function AppContent() {
|
||||
const [darkMode, setDarkMode] = useState(false);
|
||||
const useParamsLang = useParams();
|
||||
|
||||
// Sync URL language with context if needed, or validate it
|
||||
useEffect(() => {
|
||||
// Optional: Check if useParamsLang.lang is valid, else redirect
|
||||
}, [useParamsLang]);
|
||||
|
||||
useEffect(() => {
|
||||
const savedTheme = localStorage.getItem('darkMode');
|
||||
if (savedTheme) {
|
||||
setDarkMode(JSON.parse(savedTheme));
|
||||
} else {
|
||||
setDarkMode(window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('darkMode', JSON.stringify(darkMode));
|
||||
document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light');
|
||||
}, [darkMode]);
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
setDarkMode(!darkMode);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ScrollToTop />
|
||||
<div className={`app ${darkMode ? 'dark' : 'light'}`} style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
||||
<Header darkMode={darkMode} toggleDarkMode={toggleDarkMode} />
|
||||
<main style={{ flex: 1 }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="travelmate" element={<TravelMate />} />
|
||||
<Route path="travelmate/policies" element={<Policies />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
Before Width: | Height: | Size: 694 KiB After Width: | Height: | Size: 694 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
@@ -27,7 +27,7 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
|
||||
const handleNavigation = (href: string) => {
|
||||
setIsMenuOpen(false);
|
||||
|
||||
if (location.pathname === '/') {
|
||||
if (location.pathname === `/${language}` || location.pathname === `/${language}/`) {
|
||||
// Already on home, just scroll
|
||||
const element = document.querySelector(href);
|
||||
if (element) {
|
||||
@@ -35,7 +35,7 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
|
||||
}
|
||||
} else {
|
||||
// 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
|
||||
setTimeout(() => {
|
||||
const element = document.querySelector(href);
|
||||
@@ -63,7 +63,7 @@ const Header = ({ darkMode, toggleDarkMode }: HeaderProps) => {
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Link to="/" onClick={(e) => {
|
||||
<Link to={`/${language}`} onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleNavigation('#hero');
|
||||
}}>
|
||||
@@ -4,7 +4,7 @@ import { Link } from 'react-router-dom';
|
||||
import { ArrowLeft, ExternalLink, Camera, MapPin, Bell } from 'lucide-react';
|
||||
|
||||
const Policies = () => {
|
||||
const { t } = useLanguage();
|
||||
const { t, language } = useLanguage();
|
||||
|
||||
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="container" style={{ maxWidth: '800px', margin: '0 auto', padding: '0 20px' }}>
|
||||
<Link
|
||||
to="/travelmate"
|
||||
to={`/${language}/travelmate`}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
@@ -4,7 +4,7 @@ import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const Projects = () => {
|
||||
const { t } = useLanguage();
|
||||
const { t, language } = useLanguage();
|
||||
const projects = [
|
||||
{
|
||||
id: 1,
|
||||
@@ -184,7 +184,7 @@ const Projects = () => {
|
||||
|
||||
{project.links.demo !== "#" && (
|
||||
project.links.demo.startsWith('/') ? (
|
||||
<Link to={project.links.demo} style={{ textDecoration: 'none' }}>
|
||||
<Link to={`/${language}${project.links.demo}`} style={{ textDecoration: 'none' }}>
|
||||
<motion.div
|
||||
className="project-link primary"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
@@ -64,7 +64,7 @@ const FeatureCard = ({ title, icon: Icon, description }: { title: string, icon:
|
||||
);
|
||||
|
||||
const TravelMate = () => {
|
||||
const { t } = useLanguage();
|
||||
const { t, language } = useLanguage();
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
@@ -248,7 +248,7 @@ const TravelMate = () => {
|
||||
{t('policies.title')}
|
||||
</h3>
|
||||
<Link
|
||||
to="/travelmate/policies"
|
||||
to={`/${language}/travelmate/policies`}
|
||||
className="btn btn-secondary"
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
@@ -2,7 +2,7 @@
|
||||
// CONTEXTE DE LANGUE
|
||||
// ========================
|
||||
|
||||
import React, { createContext, useContext, useState } from 'react';
|
||||
import React, { createContext, useContext } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
// Types pour les langues supportées
|
||||
@@ -169,7 +169,7 @@ const translations = {
|
||||
'travelmate.tech.api': 'APIs & Outils',
|
||||
'travelmate.tech.api.1': 'Google Maps API / Mapbox',
|
||||
'travelmate.tech.api.2': 'Stripe (tbc) pour les paiements',
|
||||
'travelmate.tech.api.3': 'CI/CD avec GitHub Actions',
|
||||
'travelmate.tech.api.3': 'CI/CD avec Gitea Actions',
|
||||
'travelmate.viewCode': 'Voir le code',
|
||||
|
||||
'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.2': 'Firestore (Real-time Database)',
|
||||
'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.1': 'Google Maps API / Mapbox',
|
||||
'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.policies.link': 'View Privacy Policy',
|
||||
@@ -437,16 +438,48 @@ interface LanguageProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
import { useNavigate, useParams, useLocation } from 'react-router-dom';
|
||||
|
||||
export const LanguageProvider: React.FC<LanguageProviderProps> = ({ children }) => {
|
||||
const [language, setLanguage] = useState<Language>('fr');
|
||||
const { lang } = useParams<{ lang: string }>();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Validate language, default to 'fr' if undefined or invalid (though App.tsx handles defaults)
|
||||
const currentLanguage: Language = (lang === 'en' || lang === 'fr') ? lang : 'fr';
|
||||
|
||||
const setLanguage = (newLang: Language) => {
|
||||
if (newLang === currentLanguage) return;
|
||||
|
||||
// Replace the language segment in the URL
|
||||
// Assumes the first segment is the language
|
||||
const currentPath = location.pathname;
|
||||
const segments = currentPath.split('/');
|
||||
// segments[0] is empty string (before first slash)
|
||||
// segments[1] is the language
|
||||
if (segments[1] === 'fr' || segments[1] === 'en') {
|
||||
segments[1] = newLang;
|
||||
} else {
|
||||
// Should not happen if routing is correct, but safe fallback
|
||||
segments.splice(1, 0, newLang);
|
||||
}
|
||||
const newPath = segments.join('/');
|
||||
|
||||
navigate(newPath + location.search + location.hash);
|
||||
};
|
||||
|
||||
// Update HTML lang attribute
|
||||
React.useEffect(() => {
|
||||
document.documentElement.lang = currentLanguage;
|
||||
}, [currentLanguage]);
|
||||
|
||||
// Fonction de traduction
|
||||
const t = (key: string): string => {
|
||||
return translations[language][key as keyof typeof translations['fr']] || key;
|
||||
return translations[currentLanguage][key as keyof typeof translations['fr']] || key;
|
||||
};
|
||||
|
||||
return (
|
||||
<LanguageContext.Provider value={{ language, setLanguage, t }}>
|
||||
<LanguageContext.Provider value={{ language: currentLanguage, setLanguage, t }}>
|
||||
{children}
|
||||
</LanguageContext.Provider>
|
||||
);
|
||||
53
src/App.tsx
53
src/App.tsx
@@ -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;
|
||||
Reference in New Issue
Block a user