refactor: Comprehensive code cleanup and optimization
Major improvements: - Deleted 80+ unused markdown files from .claude/ directory (saves disk space) - Removed 342MB .backups/ directory with old frontend code - Cleaned Python cache files (__pycache__ and .pyc) - Fixed critical bugs in votes.py: - Removed duplicate candidate_id field assignment (line 465) - Removed duplicate datetime import (line 804) - Removed commented code from crypto-client.ts (23 lines of dead code) - Moved root-level test scripts to proper directories: - test_blockchain.py → tests/ - test_blockchain_election.py → tests/ - fix_elgamal_keys.py → backend/scripts/ - restore_data.py → backend/scripts/ - Cleaned unused imports: - Removed unused RSA/padding imports from encryption.py - Removed unused asdict import from blockchain.py - Optimized database queries: - Fixed N+1 query issue in get_voter_history() using eager loading - Added joinedload for election and candidate relationships - Removed unused validation schemas: - Removed profileUpdateSchema (no profile endpoints exist) - Removed passwordChangeSchema (no password change endpoint) - Updated .gitignore with comprehensive rules for Node.js artifacts and backups Code quality improvements following DRY and KISS principles: - Simplified complex functions - Reduced code duplication - Improved performance (eliminated N+1 queries) - Enhanced maintainability 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
3efdabdbbd
commit
b4c5c97523
@ -1,9 +0,0 @@
|
||||
# Backend API Configuration
|
||||
REACT_APP_API_URL=http://localhost:8000
|
||||
|
||||
# Environment
|
||||
REACT_APP_ENV=development
|
||||
|
||||
# Feature Flags
|
||||
REACT_APP_ENABLE_MOCK_API=false
|
||||
REACT_APP_DEBUG_MODE=true
|
||||
23
e-voting-system/.backups/frontend-old/.gitignore
vendored
23
e-voting-system/.backups/frontend-old/.gitignore
vendored
@ -1,23 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
@ -1,70 +0,0 @@
|
||||
# Getting Started with Create React App
|
||||
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.\
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
|
||||
|
||||
The page will reload when you make changes.\
|
||||
You may also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.\
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.\
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.\
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
|
||||
|
||||
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
|
||||
|
||||
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
|
||||
|
||||
### `npm run build` fails to minify
|
||||
|
||||
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)
|
||||
@ -1,273 +0,0 @@
|
||||
# Frontend E-Voting System
|
||||
|
||||
Un frontend complet et moderne pour le système de vote électronique, construit avec React et un design professionnalresponsive.
|
||||
|
||||
## 🏗️ Structure du Projet
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── components/ # Composants réutilisables
|
||||
│ │ ├── Header.jsx # Barre de navigation
|
||||
│ │ ├── Footer.jsx # Pied de page
|
||||
│ │ ├── VoteCard.jsx # Carte de vote
|
||||
│ │ ├── Alert.jsx # Notifications
|
||||
│ │ ├── Modal.jsx # Boîte de dialogue
|
||||
│ │ ├── LoadingSpinner.jsx # Indicateur de chargement
|
||||
│ │ ├── index.js # Export des composants
|
||||
│ │ └── *.css # Styles des composants
|
||||
│ │
|
||||
│ ├── pages/ # Pages principales
|
||||
│ │ ├── HomePage.jsx # Page d'accueil publique
|
||||
│ │ ├── LoginPage.jsx # Connexion
|
||||
│ │ ├── RegisterPage.jsx # Inscription
|
||||
│ │ ├── DashboardPage.jsx # Tableau de bord (connecté)
|
||||
│ │ ├── VotingPage.jsx # Page de vote
|
||||
│ │ ├── ArchivesPage.jsx # Archives publiques
|
||||
│ │ ├── ProfilePage.jsx # Profil utilisateur
|
||||
│ │ ├── index.js # Export des pages
|
||||
│ │ └── *.css # Styles des pages
|
||||
│ │
|
||||
│ ├── styles/ # Styles globaux
|
||||
│ │ ├── globals.css # Thème et variables CSS
|
||||
│ │ └── components.css # Styles des composants de base
|
||||
│ │
|
||||
│ ├── App.js # Application principale avec routage
|
||||
│ ├── App.css # Styles de l'application
|
||||
│ ├── index.js # Point d'entrée
|
||||
│ └── index.css # Styles de base
|
||||
│
|
||||
└── package.json # Dépendances du projet
|
||||
```
|
||||
|
||||
## 🎨 Palette de Couleurs
|
||||
|
||||
| Classe | Couleur | Utilisation |
|
||||
|--------|---------|-------------|
|
||||
| `--primary-dark` | #1e3a5f | Bleu foncé - Confiance, titres |
|
||||
| `--primary-blue` | #2563eb | Bleu principal - Actions, liens |
|
||||
| `--primary-light` | #3b82f6 | Bleu clair - Dégradés |
|
||||
| `--success-green` | #10b981 | Vert - Succès, confirmations |
|
||||
| `--warning-orange` | #f97316 | Orange - Alertes, actions urgentes |
|
||||
| `--danger-red` | #ef4444 | Rouge - Erreurs, suppression |
|
||||
| `--light-gray` | #f3f4f6 | Gris clair - Fond |
|
||||
| `--white` | #ffffff | Blanc - Cartes, formulaires |
|
||||
|
||||
## 📱 Pages Disponibles
|
||||
|
||||
### Pages Publiques (accessibles sans connexion)
|
||||
|
||||
- **`/`** - Page d'accueil
|
||||
- Section héros avec CTA
|
||||
- "Comment ça marche" (3 étapes)
|
||||
- Présentation des garanties
|
||||
- Aperçu des votes récents
|
||||
|
||||
- **`/register`** - Inscription
|
||||
- Formulaire de création de compte
|
||||
- Validation des données
|
||||
- Acceptation des CGU
|
||||
|
||||
- **`/login`** - Connexion
|
||||
- Formulaire d'authentification
|
||||
- Lien "Mot de passe oublié"
|
||||
|
||||
- **`/archives`** - Archives Publiques
|
||||
- Liste de tous les votes terminés
|
||||
- Recherche et filtrage
|
||||
- Affichage des résultats
|
||||
|
||||
### Pages Privées (accessibles après connexion)
|
||||
|
||||
- **`/dashboard`** - Tableau de Bord
|
||||
- Statistiques personnalisées (votes actifs, futurs, historique)
|
||||
- Section "Action Requise" pour les votes urgents
|
||||
- Filtrage par statut (all, actifs, futurs, historique)
|
||||
|
||||
- **`/vote/:id`** - Page de Vote
|
||||
- Détails complets du vote
|
||||
- Description et contexte
|
||||
- Formulaire de sélection d'option
|
||||
- Modal de confirmation
|
||||
- Écran de succès après vote
|
||||
|
||||
- **`/profile`** - Profil Utilisateur
|
||||
- Modification du nom et email
|
||||
- Changement de mot de passe
|
||||
- Déconnexion
|
||||
|
||||
## 🔄 Routage
|
||||
|
||||
```javascript
|
||||
/ - Public
|
||||
├── HomePage
|
||||
├── /register - RegisterPage
|
||||
├── /login - LoginPage
|
||||
├── /archives - ArchivesPage
|
||||
│
|
||||
└── (Privé) Nécessite connexion
|
||||
├── /dashboard - DashboardPage
|
||||
├── /dashboard/actifs - DashboardPage (filtré)
|
||||
├── /dashboard/futurs - DashboardPage (filtré)
|
||||
├── /dashboard/historique - DashboardPage (filtré)
|
||||
├── /vote/:id - VotingPage
|
||||
└── /profile - ProfilePage
|
||||
```
|
||||
|
||||
## 🚀 Démarrage Rapide
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
```
|
||||
|
||||
### Développement
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
L'application s'ouvrira sur `http://localhost:3000`
|
||||
|
||||
### Build pour la production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## 🧩 Composants Réutilisables
|
||||
|
||||
### Header
|
||||
Barre de navigation avec logo, liens de navigation et profil utilisateur.
|
||||
- Menu responsive sur mobile
|
||||
- Navigation différente selon la connexion
|
||||
|
||||
### Footer
|
||||
Pied de page avec liens, infos de contact et copyright.
|
||||
|
||||
### VoteCard
|
||||
Affichage d'un vote sous forme de carte.
|
||||
- Titre, description, statut
|
||||
- Countdown (temps restant)
|
||||
- Résultats (si terminé)
|
||||
- Bouton d'action approprié
|
||||
|
||||
### Alert
|
||||
Notifications avec types: success, error, warning, info.
|
||||
- Icônes automatiques
|
||||
- Fermeture possible
|
||||
|
||||
### Modal
|
||||
Boîte de dialogue modale.
|
||||
- Titre, contenu, actions
|
||||
- Confirmation/Annulation
|
||||
|
||||
### LoadingSpinner
|
||||
Indicateur de chargement.
|
||||
- Version inline ou fullscreen
|
||||
|
||||
## 🎯 Fonctionnalités
|
||||
|
||||
✅ **Authentification**
|
||||
- Inscription et connexion sécurisées
|
||||
- Stockage du token JWT
|
||||
- Vérification de session
|
||||
|
||||
✅ **Gestion des Votes**
|
||||
- Affichage des votes par statut
|
||||
- Participation au vote avec confirmation
|
||||
- Visualisation des résultats
|
||||
|
||||
✅ **Profil Utilisateur**
|
||||
- Modification des informations
|
||||
- Changement de mot de passe
|
||||
- Déconnexion sécurisée
|
||||
|
||||
✅ **Responsive Design**
|
||||
- Mobile-first approach
|
||||
- Grille fluide
|
||||
- Media queries pour tous les appareils
|
||||
|
||||
✅ **Accessibilité**
|
||||
- Contraste élevé
|
||||
- Navigation au clavier
|
||||
- Textes alternatifs
|
||||
- Sémantique HTML
|
||||
|
||||
✅ **Performance**
|
||||
- Code splitting
|
||||
- Lazy loading des pages
|
||||
- Optimisation des requêtes API
|
||||
|
||||
## 🎨 Design System
|
||||
|
||||
### Espacements
|
||||
- `--spacing-xs`: 0.25rem
|
||||
- `--spacing-sm`: 0.5rem
|
||||
- `--spacing-md`: 1rem
|
||||
- `--spacing-lg`: 1.5rem
|
||||
- `--spacing-xl`: 2rem
|
||||
- `--spacing-2xl`: 3rem
|
||||
|
||||
### Radius
|
||||
- `--radius-sm`: 0.375rem
|
||||
- `--radius-md`: 0.5rem
|
||||
- `--radius-lg`: 0.75rem
|
||||
- `--radius-xl`: 1rem
|
||||
|
||||
### Shadows
|
||||
- `--shadow-sm`, `--shadow-md`, `--shadow-lg`, `--shadow-xl`
|
||||
|
||||
## 📞 API Integration
|
||||
|
||||
L'application communique avec le backend sur `http://localhost:8000`:
|
||||
|
||||
- `POST /auth/register` - Inscription
|
||||
- `POST /auth/login` - Connexion
|
||||
- `GET /elections/` - Lister les votes
|
||||
- `GET /elections/{id}` - Détails d'un vote
|
||||
- `POST /votes/submit` - Soumettre un vote
|
||||
- `GET /votes/my-votes` - Mes votes
|
||||
- `PUT /auth/profile` - Mise à jour profil
|
||||
- `POST /auth/change-password` - Changer le mot de passe
|
||||
|
||||
## 🔐 Sécurité
|
||||
|
||||
- Tokens JWT stockés en localStorage
|
||||
- Authentification requise pour les pages privées
|
||||
- Redirection automatique vers login si non connecté
|
||||
- Validation des formulaires côté client
|
||||
- Protection des routes
|
||||
|
||||
## 📱 Responsive Breakpoints
|
||||
|
||||
- **Desktop**: > 1024px
|
||||
- **Tablet**: 768px - 1024px
|
||||
- **Mobile**: < 768px
|
||||
|
||||
## 🔄 État Global
|
||||
|
||||
État géré avec React Context et localStorage:
|
||||
- Utilisateur connecté
|
||||
- Token d'authentification
|
||||
- Informations du voter
|
||||
|
||||
## 📦 Dépendances
|
||||
|
||||
- `react` - Framework
|
||||
- `react-dom` - Rendu DOM
|
||||
- `react-router-dom` - Routage
|
||||
- `axios` - Requêtes HTTP
|
||||
- `lucide-react` - Icônes
|
||||
|
||||
## 🛠️ Outils de Développement
|
||||
|
||||
- `react-scripts` - Configuration webpack
|
||||
- Linter ESLint
|
||||
- Formatage automatique (Prettier)
|
||||
|
||||
## 📄 License
|
||||
|
||||
Ce projet est sous licence MIT.
|
||||
18514
e-voting-system/.backups/frontend-old/package-lock.json
generated
18514
e-voting-system/.backups/frontend-old/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -1,55 +0,0 @@
|
||||
{
|
||||
"name": "e-voting-frontend",
|
||||
"version": "0.1.0",
|
||||
"description": "E-Voting - Plateforme de vote électronique sécurisée avec cryptographie post-quantique",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"ajv": "^8.17.1",
|
||||
"axios": "^1.6.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"lucide-react": "^0.344.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"web-vitals": "^2.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
// Runtime API configuration
|
||||
// This file is served from public/ and can be updated without rebuilding
|
||||
window.API_CONFIG = {
|
||||
API_BASE_URL: window.location.hostname === 'localhost'
|
||||
? 'http://localhost:8000'
|
||||
: `http://${window.location.hostname}:8000`
|
||||
};
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.8 KiB |
@ -1,45 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#171717" />
|
||||
<meta
|
||||
name="description"
|
||||
content="E-Voting - Plateforme de vote électronique sécurisée avec cryptographie post-quantique"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!-- Runtime API Configuration -->
|
||||
<script src="%PUBLIC_URL%/config.js"></script>
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>E-Voting - Plateforme de Vote Électronique Sécurisée</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB |
@ -1,25 +0,0 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@ -1,66 +0,0 @@
|
||||
/* ===== App Layout ===== */
|
||||
|
||||
.app-wrapper {
|
||||
@apply flex flex-col min-h-screen bg-bg-primary;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
@apply flex-1 flex flex-col;
|
||||
}
|
||||
|
||||
/* ===== Text Utilities ===== */
|
||||
|
||||
.text-muted {
|
||||
@apply text-text-tertiary;
|
||||
}
|
||||
|
||||
/* ===== Form Utilities ===== */
|
||||
|
||||
.form-group {
|
||||
@apply space-y-2 mb-4;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
@apply block text-sm font-medium text-text-primary;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
@apply w-full px-3 py-2 rounded-md border border-text-tertiary bg-bg-secondary text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-warm focus:border-transparent;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
@apply w-full px-3 py-2 rounded-md border border-text-tertiary bg-bg-secondary text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-accent-warm focus:border-transparent;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
@apply text-sm text-danger mt-1;
|
||||
}
|
||||
|
||||
.form-success {
|
||||
@apply text-sm text-success mt-1;
|
||||
}
|
||||
|
||||
/* ===== Grid Utilities ===== */
|
||||
|
||||
.grid-responsive {
|
||||
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6;
|
||||
}
|
||||
|
||||
.grid-two {
|
||||
@apply grid grid-cols-1 md:grid-cols-2 gap-6;
|
||||
}
|
||||
|
||||
/* ===== Section Utilities ===== */
|
||||
|
||||
.section-header {
|
||||
@apply py-6 border-b border-text-tertiary;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
@apply text-3xl font-bold text-text-primary mb-2;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
@apply text-text-secondary;
|
||||
}
|
||||
@ -1,160 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import './styles/globals.css';
|
||||
import './styles/components.css';
|
||||
import './App.css';
|
||||
|
||||
// Components
|
||||
import Header from './components/Header';
|
||||
import Footer from './components/Footer';
|
||||
|
||||
// Pages
|
||||
import HomePage from './pages/HomePage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import RegisterPage from './pages/RegisterPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import VotingPage from './pages/VotingPage';
|
||||
import ArchivesPage from './pages/ArchivesPage';
|
||||
import ElectionDetailsPage from './pages/ElectionDetailsPage';
|
||||
import HistoriquePage from './pages/HistoriquePage';
|
||||
import ActiveVotesPage from './pages/ActiveVotesPage';
|
||||
import UpcomingVotesPage from './pages/UpcomingVotesPage';
|
||||
import ProfilePage from './pages/ProfilePage';
|
||||
|
||||
function App() {
|
||||
const [voter, setVoter] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if voter is already logged in (stored in localStorage)
|
||||
try {
|
||||
const storedVoter = localStorage.getItem('voter');
|
||||
if (storedVoter) {
|
||||
const parsed = JSON.parse(storedVoter);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
setVoter(parsed);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error parsing voter data:', err);
|
||||
localStorage.removeItem('voter');
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const handleLogin = (voterData) => {
|
||||
setVoter(voterData);
|
||||
localStorage.setItem('voter', JSON.stringify(voterData));
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
setVoter(null);
|
||||
localStorage.removeItem('voter');
|
||||
localStorage.removeItem('token');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ textAlign: 'center', padding: '50px' }}>Chargement...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<div className="app-wrapper">
|
||||
<Header voter={voter} onLogout={handleLogout} />
|
||||
<main className="app-main">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={<HomePage />}
|
||||
/>
|
||||
<Route
|
||||
path="/register"
|
||||
element={
|
||||
voter ? <Navigate to="/dashboard" replace /> :
|
||||
<RegisterPage onLogin={handleLogin} />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
voter ? <Navigate to="/dashboard" replace /> :
|
||||
<LoginPage onLogin={handleLogin} />
|
||||
}
|
||||
/>
|
||||
{/* Dashboard Routes */}
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
voter ?
|
||||
<DashboardPage voter={voter} /> :
|
||||
<Navigate to="/login" replace />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard/historique"
|
||||
element={
|
||||
voter ?
|
||||
<HistoriquePage voter={voter} /> :
|
||||
<Navigate to="/login" replace />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard/actifs"
|
||||
element={
|
||||
voter ?
|
||||
<ActiveVotesPage voter={voter} /> :
|
||||
<Navigate to="/login" replace />
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/dashboard/futurs"
|
||||
element={
|
||||
voter ?
|
||||
<UpcomingVotesPage voter={voter} /> :
|
||||
<Navigate to="/login" replace />
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Voting Route */}
|
||||
<Route
|
||||
path="/voting/:id"
|
||||
element={
|
||||
voter ?
|
||||
<VotingPage voter={voter} /> :
|
||||
<Navigate to="/login" replace />
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Archives Routes */}
|
||||
<Route
|
||||
path="/archives"
|
||||
element={<ArchivesPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/archives/election/:id"
|
||||
element={<ElectionDetailsPage type="archives" />}
|
||||
/>
|
||||
|
||||
{/* Legacy route - redirect to archives */}
|
||||
<Route
|
||||
path="/election/:id"
|
||||
element={<Navigate to="/archives/election/:id" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
voter ?
|
||||
<ProfilePage voter={voter} onLogout={handleLogout} /> :
|
||||
<Navigate to="/login" replace />
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@ -1,8 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import App from './App';
|
||||
|
||||
test('renders learn react link', () => {
|
||||
render(<App />);
|
||||
const linkElement = screen.getByText(/learn react/i);
|
||||
expect(linkElement).toBeInTheDocument();
|
||||
});
|
||||
@ -1,112 +0,0 @@
|
||||
/* ===== Alert Styles ===== */
|
||||
|
||||
.alert {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.alert-icon {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.alert-content {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.alert-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
.alert-message {
|
||||
font-size: 0.95rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.alert-close {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.alert-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Alert Variants */
|
||||
|
||||
.alert-success {
|
||||
background-color: #ecfdf5;
|
||||
color: #065f46;
|
||||
border-left: 4px solid var(--success-green);
|
||||
}
|
||||
|
||||
.alert-success .alert-icon {
|
||||
color: var(--success-green);
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: #fef2f2;
|
||||
color: #7f1d1d;
|
||||
border-left: 4px solid var(--danger-red);
|
||||
}
|
||||
|
||||
.alert-error .alert-icon {
|
||||
color: var(--danger-red);
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background-color: #fffbeb;
|
||||
color: #78350f;
|
||||
border-left: 4px solid var(--warning-orange);
|
||||
}
|
||||
|
||||
.alert-warning .alert-icon {
|
||||
color: var(--warning-orange);
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: #eff6ff;
|
||||
color: #0c2d6b;
|
||||
border-left: 4px solid var(--primary-blue);
|
||||
}
|
||||
|
||||
.alert-info .alert-icon {
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.alert {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.alert-icon {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.alert-close {
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
import React from 'react';
|
||||
import { AlertCircle, CheckCircle, Info, AlertTriangle, X } from 'lucide-react';
|
||||
import { Alert as AlertUI, AlertTitle, AlertDescription } from '../lib/ui';
|
||||
|
||||
export default function Alert({ type = 'info', title, message, icon: Icon, onClose }) {
|
||||
const variantMap = {
|
||||
success: 'success',
|
||||
error: 'destructive',
|
||||
warning: 'warning',
|
||||
info: 'info',
|
||||
};
|
||||
|
||||
const iconMap = {
|
||||
success: <CheckCircle className="h-5 w-5" />,
|
||||
error: <AlertCircle className="h-5 w-5" />,
|
||||
warning: <AlertTriangle className="h-5 w-5" />,
|
||||
info: <Info className="h-5 w-5" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative fade-in">
|
||||
<AlertUI variant={variantMap[type]} className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{Icon ? <Icon className="h-5 w-5" /> : iconMap[type]}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{title && <AlertTitle>{title}</AlertTitle>}
|
||||
<AlertDescription>{message}</AlertDescription>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 rounded-md opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-accent-warm"
|
||||
aria-label="Close alert"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</AlertUI>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,281 +0,0 @@
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
animation: modalSlideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modalSlideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border-radius: 16px 16px 0 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal-section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.modal-section h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 0 16px 0;
|
||||
color: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-description {
|
||||
color: #666;
|
||||
margin: 0 0 16px 0;
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.modal-dates {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-dates > div {
|
||||
padding: 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.modal-dates label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #999;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.modal-dates p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-candidates {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-candidate {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 10px;
|
||||
background: #f8f9ff;
|
||||
border-left: 3px solid #667eea;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.candidate-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-candidate h4 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.modal-candidate p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.modal-total-votes {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
padding: 10px;
|
||||
background: #f0f7ff;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.modal-total-votes svg {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.modal-total-votes strong {
|
||||
color: #667eea;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.modal-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-result-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-result-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-result-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-result-percentage {
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.modal-result-bar {
|
||||
height: 20px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-result-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 10px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-result-count {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.modal-loading,
|
||||
.modal-error {
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.modal-error {
|
||||
color: #d32f2f;
|
||||
background: #ffebee;
|
||||
border-radius: 8px;
|
||||
margin: 16px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 600px) {
|
||||
.modal-content {
|
||||
max-height: 95vh;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.modal-dates {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@ -1,161 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Users, BarChart3, CheckCircle } from 'lucide-react';
|
||||
import './ElectionDetailsModal.css';
|
||||
|
||||
export default function ElectionDetailsModal({ electionId, isOpen, onClose, voter = null, type = 'historique' }) {
|
||||
const [election, setElection] = useState(null);
|
||||
const [results, setResults] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [userVote, setUserVote] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && electionId) {
|
||||
fetchElectionDetails();
|
||||
}
|
||||
}, [isOpen, electionId]);
|
||||
|
||||
const fetchElectionDetails = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
// Récupérer les détails de l'élection
|
||||
const electionResponse = await fetch(`http://localhost:8000/api/elections/${electionId}`);
|
||||
if (!electionResponse.ok) {
|
||||
throw new Error('Élection non trouvée');
|
||||
}
|
||||
const electionData = await electionResponse.json();
|
||||
setElection(electionData);
|
||||
|
||||
// Récupérer le vote de l'utilisateur si connecté et type = historique
|
||||
if (voter && type === 'historique') {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const userVoteResponse = await fetch(`http://localhost:8000/api/votes/election/${electionId}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (userVoteResponse.ok) {
|
||||
const userVoteData = await userVoteResponse.json();
|
||||
setUserVote(userVoteData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Impossible de récupérer le vote utilisateur');
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer les résultats si l'élection est terminée
|
||||
if (electionData.results_published) {
|
||||
try {
|
||||
const resultsResponse = await fetch(`http://localhost:8000/api/elections/${electionId}/results`);
|
||||
if (resultsResponse.ok) {
|
||||
const resultsData = await resultsResponse.json();
|
||||
setResults(resultsData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Résultats non disponibles');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'Erreur de chargement');
|
||||
console.error('Erreur:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>{election?.name || 'Détails de l\'élection'}</h2>
|
||||
<button className="modal-close" onClick={onClose}>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className="modal-loading">Chargement...</div>}
|
||||
{error && <div className="modal-error">{error}</div>}
|
||||
|
||||
{election && !loading && (
|
||||
<div className="modal-body">
|
||||
<div className="modal-section">
|
||||
<h3>📋 Informations</h3>
|
||||
<p className="modal-description">{election.description}</p>
|
||||
<div className="modal-dates">
|
||||
<div>
|
||||
<label>Ouverture</label>
|
||||
<p>{formatDate(election.start_date)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label>Fermeture</label>
|
||||
<p>{formatDate(election.end_date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-section">
|
||||
<h3>👥 Candidats ({election.candidates?.length || 0})</h3>
|
||||
<div className="modal-candidates">
|
||||
{election.candidates?.map((candidate, index) => (
|
||||
<div key={candidate.id} className="modal-candidate">
|
||||
<span className="candidate-number">{candidate.order || index + 1}</span>
|
||||
<div>
|
||||
<h4>{candidate.name}</h4>
|
||||
{candidate.description && <p>{candidate.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{results && election.results_published && (
|
||||
<div className="modal-section">
|
||||
<h3>📊 Résultats</h3>
|
||||
<p className="modal-total-votes">
|
||||
<Users size={18} />
|
||||
Total: <strong>{results.total_votes} vote(s)</strong>
|
||||
</p>
|
||||
<div className="modal-results">
|
||||
{results.results?.map((result, index) => (
|
||||
<div key={index} className="modal-result-item">
|
||||
<div className="modal-result-header">
|
||||
<span className="modal-result-name">
|
||||
{userVote && userVote.candidate_name === result.candidate_name && (
|
||||
<CheckCircle size={16} style={{ display: 'inline', marginRight: '8px', color: '#4CAF50' }} />
|
||||
)}
|
||||
{result.candidate_name}
|
||||
</span>
|
||||
<span className="modal-result-percentage">{result.percentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="modal-result-bar">
|
||||
<div
|
||||
className="modal-result-bar-fill"
|
||||
style={{ width: `${result.percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="modal-result-count">{result.vote_count} vote(s)</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,88 +0,0 @@
|
||||
/* ===== Footer Styles ===== */
|
||||
|
||||
.footer {
|
||||
background-color: var(--primary-dark);
|
||||
color: var(--white);
|
||||
padding: var(--spacing-2xl) 0;
|
||||
margin-top: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: var(--primary-light);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
color: var(--white);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.footer-section h4 {
|
||||
color: var(--white);
|
||||
margin-bottom: var(--spacing-md);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.footer-section p {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
opacity: 0.9;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.footer-section ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.footer-section li {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.footer-divider {
|
||||
height: 1px;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
text-align: center;
|
||||
opacity: 0.8;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.footer-content {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.footer-section h4 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.footer-content {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
@ -1,48 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="w-full border-t border-text-tertiary bg-bg-secondary mt-auto">
|
||||
<div className="container py-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 mb-8">
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-text-primary">À propos</h4>
|
||||
<p className="text-sm text-text-secondary">Plateforme de vote électronique sécurisée et transparente pour tous.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-text-primary">Liens Rapides</h4>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><a href="/" className="text-accent-warm hover:opacity-80 transition-opacity">Accueil</a></li>
|
||||
<li><a href="/archives" className="text-accent-warm hover:opacity-80 transition-opacity">Archives</a></li>
|
||||
<li><a href="#faq" className="text-accent-warm hover:opacity-80 transition-opacity">FAQ</a></li>
|
||||
<li><a href="#contact" className="text-accent-warm hover:opacity-80 transition-opacity">Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-text-primary">Légal</h4>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><a href="#cgu" className="text-accent-warm hover:opacity-80 transition-opacity">Conditions d'Utilisation</a></li>
|
||||
<li><a href="#privacy" className="text-accent-warm hover:opacity-80 transition-opacity">Politique de Confidentialité</a></li>
|
||||
<li><a href="#security" className="text-accent-warm hover:opacity-80 transition-opacity">Sécurité</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-semibold text-text-primary">Contact</h4>
|
||||
<p className="text-sm text-text-secondary">Email: <a href="mailto:contact@evoting.com" className="text-accent-warm hover:opacity-80 transition-opacity">contact@evoting.com</a></p>
|
||||
<p className="text-sm text-text-secondary">Téléphone: <a href="tel:+33123456789" className="text-accent-warm hover:opacity-80 transition-opacity">+33 1 23 45 67 89</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-text-tertiary pt-8">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-sm text-text-secondary">© 2025 E-Voting. Tous droits réservés.</p>
|
||||
<p className="text-sm text-text-tertiary">Plateforme de vote électronique sécurisée par cryptographie post-quantique</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@ -1,141 +0,0 @@
|
||||
/* ===== Header Styles ===== */
|
||||
|
||||
.header {
|
||||
background-color: var(--white);
|
||||
box-shadow: var(--shadow-md);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.header .container {
|
||||
padding: 0 var(--spacing-lg);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-md) 0;
|
||||
min-height: 70px;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-dark);
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-logo:hover {
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
background: linear-gradient(135deg, var(--primary-dark), var(--primary-blue));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
flex: 1;
|
||||
margin-left: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--dark-gray);
|
||||
font-weight: 500;
|
||||
transition: color 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--primary-blue);
|
||||
background-color: var(--light-gray);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-link.nav-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background-color: var(--border-gray);
|
||||
}
|
||||
|
||||
.mobile-menu-btn {
|
||||
display: none;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--primary-dark);
|
||||
padding: var(--spacing-md);
|
||||
margin-right: -var(--spacing-md);
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-menu-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex-wrap: wrap;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
position: absolute;
|
||||
top: 70px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: var(--white);
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.header-nav.open {
|
||||
max-height: 600px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
margin: var(--spacing-sm) 0;
|
||||
}
|
||||
}
|
||||
@ -1,168 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { LogOut, User, Menu, X } from 'lucide-react';
|
||||
import { Button } from '../lib/ui';
|
||||
|
||||
export default function Header({ voter, onLogout }) {
|
||||
const navigate = useNavigate();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false);
|
||||
|
||||
const handleLogout = () => {
|
||||
onLogout();
|
||||
navigate('/');
|
||||
setMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 w-full border-b border-text-tertiary bg-bg-secondary/95 backdrop-blur supports-[backdrop-filter]:bg-bg-secondary/80">
|
||||
<div className="container flex h-16 items-center justify-between">
|
||||
{/* Logo */}
|
||||
<Link to={voter ? '/dashboard' : '/'} className="flex items-center space-x-2 font-bold text-xl text-accent-warm hover:opacity-80 transition-opacity">
|
||||
<span className="text-2xl">🗳️</span>
|
||||
<span>E-Voting</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden md:flex items-center space-x-1">
|
||||
{voter ? (
|
||||
<>
|
||||
<Link to="/dashboard" className="px-3 py-2 text-sm font-medium text-text-primary hover:bg-bg-overlay-light rounded-md transition-colors">
|
||||
Tableau de Bord
|
||||
</Link>
|
||||
<Link to="/dashboard/actifs" className="px-3 py-2 text-sm font-medium text-text-primary hover:bg-bg-overlay-light rounded-md transition-colors">
|
||||
Votes Actifs
|
||||
</Link>
|
||||
<Link to="/dashboard/futurs" className="px-3 py-2 text-sm font-medium text-text-primary hover:bg-bg-overlay-light rounded-md transition-colors">
|
||||
Votes à Venir
|
||||
</Link>
|
||||
<Link to="/dashboard/historique" className="px-3 py-2 text-sm font-medium text-text-primary hover:bg-bg-overlay-light rounded-md transition-colors">
|
||||
Mon Historique
|
||||
</Link>
|
||||
<Link to="/archives" className="px-3 py-2 text-sm font-medium text-text-primary hover:bg-bg-overlay-light rounded-md transition-colors">
|
||||
Archives
|
||||
</Link>
|
||||
<div className="mx-2 h-6 w-px bg-text-tertiary"></div>
|
||||
<Link to="/profile" className="px-3 py-2 text-sm font-medium text-text-primary hover:bg-bg-overlay-light rounded-md transition-colors flex items-center gap-2">
|
||||
<User size={18} />
|
||||
{voter.nom}
|
||||
</Link>
|
||||
<Button
|
||||
onClick={handleLogout}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="ml-2 flex items-center gap-1"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
Déconnexion
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/archives" className="px-3 py-2 text-sm font-medium text-text-primary hover:bg-bg-overlay-light rounded-md transition-colors">
|
||||
Archives
|
||||
</Link>
|
||||
<div className="mx-2 h-6 w-px bg-text-tertiary"></div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to="/login">Se Connecter</Link>
|
||||
</Button>
|
||||
<Button variant="default" size="sm" asChild>
|
||||
<Link to="/register">S'inscrire</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
className="md:hidden inline-flex items-center justify-center rounded-md p-2 text-text-primary hover:bg-bg-overlay-light transition-colors"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{mobileMenuOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation */}
|
||||
{mobileMenuOpen && (
|
||||
<nav className="md:hidden border-t border-text-tertiary bg-bg-secondary">
|
||||
<div className="container py-4 space-y-2">
|
||||
{voter ? (
|
||||
<>
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="block px-3 py-2 text-sm font-medium text-text-primary hover:bg-bg-overlay-light rounded-md transition-colors"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Tableau de Bord
|
||||
</Link>
|
||||
<Link
|
||||
to="/dashboard/actifs"
|
||||
className="block px-3 py-2 text-sm font-medium text-text-primary hover:bg-bg-overlay-light rounded-md transition-colors"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Votes Actifs
|
||||
</Link>
|
||||
<Link
|
||||
to="/dashboard/futurs"
|
||||
className="block px-3 py-2 text-sm font-medium text-text-primary hover:bg-bg-overlay-light rounded-md transition-colors"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Votes à Venir
|
||||
</Link>
|
||||
<Link
|
||||
to="/dashboard/historique"
|
||||
className="block px-3 py-2 text-sm font-medium text-text-primary hover:bg-bg-overlay-light rounded-md transition-colors"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Mon Historique
|
||||
</Link>
|
||||
<Link
|
||||
to="/archives"
|
||||
className="block px-3 py-2 text-sm font-medium text-text-primary hover:bg-bg-overlay-light rounded-md transition-colors"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Archives
|
||||
</Link>
|
||||
<div className="my-2 h-px bg-text-tertiary"></div>
|
||||
<Link
|
||||
to="/profile"
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-text-primary hover:bg-bg-overlay-light rounded-md transition-colors"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<User size={18} />
|
||||
{voter.nom}
|
||||
</Link>
|
||||
<Button
|
||||
onClick={handleLogout}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full flex items-center justify-center gap-1 mt-2"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
Déconnexion
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
to="/archives"
|
||||
className="block px-3 py-2 text-sm font-medium text-text-primary hover:bg-bg-overlay-light rounded-md transition-colors"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
Archives
|
||||
</Link>
|
||||
<div className="my-2 h-px bg-text-tertiary"></div>
|
||||
<Button variant="ghost" size="sm" className="w-full" asChild>
|
||||
<Link to="/login" onClick={() => setMobileMenuOpen(false)}>Se Connecter</Link>
|
||||
</Button>
|
||||
<Button variant="default" size="sm" className="w-full" asChild>
|
||||
<Link to="/register" onClick={() => setMobileMenuOpen(false)}>S'inscrire</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@ -1,45 +0,0 @@
|
||||
/* ===== Loading Spinner Styles ===== */
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid var(--border-gray);
|
||||
border-top-color: var(--primary-blue);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.spinner-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.spinner-container p {
|
||||
color: var(--dark-gray);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function LoadingSpinner({ fullscreen = false }) {
|
||||
if (fullscreen) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-bg-overlay-dark">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-text-tertiary border-t-accent-warm"></div>
|
||||
<p className="text-text-primary">Chargement...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-text-tertiary border-t-accent-warm"></div>
|
||||
);
|
||||
}
|
||||
@ -1,116 +0,0 @@
|
||||
/* ===== Modal Styles ===== */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-xl);
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
animation: slideUp 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(50px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: var(--spacing-xl);
|
||||
border-bottom: 1px solid var(--border-gray);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 2rem;
|
||||
cursor: pointer;
|
||||
color: var(--dark-gray);
|
||||
padding: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--danger-red);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
border-top: 1px solid var(--border-gray);
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.modal-overlay {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../lib/ui';
|
||||
import { Button } from '../lib/ui';
|
||||
|
||||
export default function Modal({ isOpen, title, children, onClose, onConfirm, confirmText = 'Confirmer', cancelText = 'Annuler', type = 'default', description = null }) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
{title && (
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
</DialogHeader>
|
||||
)}
|
||||
|
||||
<div className="py-4">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-2 justify-end">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
{onConfirm && (
|
||||
<Button
|
||||
variant={type === 'danger' ? 'destructive' : 'default'}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -1,191 +0,0 @@
|
||||
/* ===== Vote Card Styles ===== */
|
||||
|
||||
.vote-card {
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.vote-card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.vote-card-header {
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 2px solid var(--light-gray);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.vote-card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-dark);
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
.vote-card-date {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-muted, #6b7280);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background-color: #dbeafe;
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.badge-closed {
|
||||
background-color: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.vote-card-body {
|
||||
padding: var(--spacing-lg);
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.vote-card-description {
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vote-card-countdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: 0.9rem;
|
||||
color: var(--warning-orange);
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.user-vote-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md);
|
||||
background-color: #ecfdf5;
|
||||
border-radius: var(--radius-md);
|
||||
color: #065f46;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.vote-card-results {
|
||||
padding: var(--spacing-lg);
|
||||
background-color: var(--light-gray);
|
||||
border-top: 1px solid var(--border-gray);
|
||||
}
|
||||
|
||||
.vote-card-results h4 {
|
||||
font-size: 1rem;
|
||||
margin: 0 0 var(--spacing-lg) 0;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.results-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr 50px;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-weight: 600;
|
||||
color: var(--dark-gray);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.result-bar {
|
||||
height: 24px;
|
||||
background-color: var(--border-gray);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.result-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary-blue), var(--primary-light));
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
color: var(--dark-gray);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.vote-card-footer {
|
||||
padding: var(--spacing-lg);
|
||||
border-top: 1px solid var(--border-gray);
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.vote-card-footer .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Mobile Responsive */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.vote-card-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.badge {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.vote-card-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vote-card-footer .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
grid-template-columns: 80px 1fr 40px;
|
||||
}
|
||||
}
|
||||
@ -1,184 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Clock, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, Badge, Button } from '../lib/ui';
|
||||
|
||||
export default function VoteCard({ vote, onVote, userVote = null, showResult = false, context = 'archives', onShowDetails = null }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const getStatusBadge = () => {
|
||||
if (vote.status === 'actif') {
|
||||
return (
|
||||
<Badge variant="success" className="flex items-center gap-2 w-fit">
|
||||
<AlertCircle size={14} />
|
||||
OUVERT
|
||||
</Badge>
|
||||
);
|
||||
} else if (vote.status === 'futur') {
|
||||
return (
|
||||
<Badge variant="info" className="flex items-center gap-2 w-fit">
|
||||
<Clock size={14} />
|
||||
À VENIR
|
||||
</Badge>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Badge variant="secondary" className="flex items-center gap-2 w-fit">
|
||||
<CheckCircle size={14} />
|
||||
TERMINÉ
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const getTimeRemaining = (endDate) => {
|
||||
const now = new Date();
|
||||
const end = new Date(endDate);
|
||||
const diff = end - now;
|
||||
|
||||
if (diff < 0) return null;
|
||||
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((diff / (1000 * 60 * 60)) % 24);
|
||||
|
||||
if (days > 0) {
|
||||
return `Se termine dans ${days} jour${days > 1 ? 's' : ''}`;
|
||||
} else if (hours > 0) {
|
||||
return `Se termine dans ${hours}h`;
|
||||
} else {
|
||||
return 'Se termine très bientôt';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-xl text-text-primary">{vote.titre}</CardTitle>
|
||||
<CardDescription className="mt-1 text-text-secondary">
|
||||
{vote.status === 'futur'
|
||||
? `Ouvre le ${formatDate(vote.date_ouverture)}`
|
||||
: `Se termine le ${formatDate(vote.date_fermeture)}`}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{getStatusBadge()}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-text-secondary">{vote.description}</p>
|
||||
|
||||
{vote.status === 'actif' && getTimeRemaining(vote.date_fermeture) && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-md bg-bg-overlay-light border border-text-tertiary text-text-secondary">
|
||||
<Clock size={18} className="flex-shrink-0" />
|
||||
<span className="text-sm">{getTimeRemaining(vote.date_fermeture)}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userVote && (
|
||||
<div className="flex items-center gap-3 p-3 rounded-md bg-success/10 border border-success/50 text-success">
|
||||
<CheckCircle size={18} className="flex-shrink-0" />
|
||||
<span className="text-sm">Votre vote: <strong>{userVote}</strong></span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
{showResult && vote.resultats && (
|
||||
<CardContent className="space-y-4 border-t border-text-tertiary pt-4">
|
||||
<h4 className="font-semibold text-text-primary">Résultats</h4>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(vote.resultats).map(([option, count]) => {
|
||||
const percentage = (count / (vote.total_votes || 1)) * 100;
|
||||
return (
|
||||
<div key={option} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-secondary">{option}</span>
|
||||
<span className="text-sm font-medium text-text-primary">{count} vote{count !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-bg-overlay-light overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-warm rounded-full transition-all duration-300"
|
||||
style={{ width: `${percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<div className="text-xs text-text-tertiary text-right">{percentage.toFixed(1)}%</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
<CardFooter className="flex gap-2 flex-col sm:flex-row">
|
||||
{vote.status === 'actif' && !userVote && (
|
||||
<Button
|
||||
variant="default"
|
||||
className="flex-1"
|
||||
onClick={() => onVote?.(vote.id)}
|
||||
>
|
||||
VOTER MAINTENANT
|
||||
</Button>
|
||||
)}
|
||||
{vote.status === 'actif' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
if (onShowDetails) {
|
||||
onShowDetails(vote.id);
|
||||
} else {
|
||||
navigate(`/archives/election/${vote.id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Voir les Détails
|
||||
</Button>
|
||||
)}
|
||||
{userVote && (
|
||||
<Button variant="success" className="flex-1" disabled>
|
||||
<CheckCircle size={18} />
|
||||
DÉJÀ VOTÉ
|
||||
</Button>
|
||||
)}
|
||||
{(vote.status === 'ferme' || vote.status === 'fermé') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
if (context === 'historique' && onShowDetails) {
|
||||
onShowDetails(vote.id);
|
||||
} else if (context === 'archives') {
|
||||
navigate(`/archives/election/${vote.id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Voir les Détails
|
||||
</Button>
|
||||
)}
|
||||
{vote.status === 'futur' && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
if (onShowDetails) {
|
||||
onShowDetails(vote.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Voir les Détails
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
export { default as Header } from './Header';
|
||||
export { default as Footer } from './Footer';
|
||||
export { default as VoteCard } from './VoteCard';
|
||||
export { default as Alert } from './Alert';
|
||||
export { default as Modal } from './Modal';
|
||||
export { default as LoadingSpinner } from './LoadingSpinner';
|
||||
@ -1,20 +0,0 @@
|
||||
// API Configuration
|
||||
// Use runtime config if available (from public/config.js), otherwise fallback to env
|
||||
export const API_BASE_URL = window.API_CONFIG?.API_BASE_URL || process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
|
||||
export const API_ENDPOINTS = {
|
||||
// Auth
|
||||
REGISTER: `${API_BASE_URL}/api/auth/register`,
|
||||
LOGIN: `${API_BASE_URL}/api/auth/login`,
|
||||
PROFILE: `${API_BASE_URL}/api/auth/profile`,
|
||||
|
||||
// Elections
|
||||
ELECTIONS_ACTIVE: `${API_BASE_URL}/api/elections/active`,
|
||||
ELECTIONS_COMPLETED: `${API_BASE_URL}/api/elections/completed`,
|
||||
ELECTIONS_ACTIVE_RESULTS: `${API_BASE_URL}/api/elections/active/results`,
|
||||
ELECTIONS_FUTURE: `${API_BASE_URL}/api/elections/future`,
|
||||
|
||||
// Votes
|
||||
VOTE_SUBMIT: `${API_BASE_URL}/api/votes`,
|
||||
VOTE_HISTORY: `${API_BASE_URL}/api/votes/history`,
|
||||
};
|
||||
@ -1,84 +0,0 @@
|
||||
/**
|
||||
* Design System - Thème E-Voting
|
||||
* Variables de couleurs, espacements, et typographie
|
||||
*/
|
||||
|
||||
export const colors = {
|
||||
// Primaire
|
||||
primary: {
|
||||
dark: '#1e3a5f', // Bleu foncé - Confiance
|
||||
base: '#2563eb', // Bleu principal
|
||||
light: '#3b82f6', // Bleu clair
|
||||
},
|
||||
|
||||
// Sémantique
|
||||
semantic: {
|
||||
success: '#10b981', // Vert
|
||||
warning: '#f97316', // Orange
|
||||
danger: '#ef4444', // Rouge
|
||||
info: '#3b82f6', // Bleu
|
||||
},
|
||||
|
||||
// Neutres
|
||||
neutral: {
|
||||
darkGray: '#1f2937', // Texte
|
||||
gray: '#6b7280', // Texte secondaire
|
||||
lightGray: '#f3f4f6', // Fond
|
||||
border: '#e5e7eb', // Bordures
|
||||
white: '#ffffff', // Blanc
|
||||
},
|
||||
};
|
||||
|
||||
export const spacing = {
|
||||
xs: '0.25rem',
|
||||
sm: '0.5rem',
|
||||
md: '1rem',
|
||||
lg: '1.5rem',
|
||||
xl: '2rem',
|
||||
'2xl': '3rem',
|
||||
};
|
||||
|
||||
export const borderRadius = {
|
||||
sm: '0.375rem',
|
||||
md: '0.5rem',
|
||||
lg: '0.75rem',
|
||||
xl: '1rem',
|
||||
};
|
||||
|
||||
export const shadows = {
|
||||
sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
md: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
|
||||
xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1)',
|
||||
};
|
||||
|
||||
export const typography = {
|
||||
fontFamily: '"Inter", "Segoe UI", "Roboto", sans-serif',
|
||||
sizes: {
|
||||
xs: '0.75rem',
|
||||
sm: '0.875rem',
|
||||
base: '1rem',
|
||||
lg: '1.125rem',
|
||||
xl: '1.25rem',
|
||||
'2xl': '1.5rem',
|
||||
'3xl': '1.875rem',
|
||||
'4xl': '2.25rem',
|
||||
'5xl': '3rem',
|
||||
},
|
||||
weights: {
|
||||
normal: 400,
|
||||
medium: 500,
|
||||
semibold: 600,
|
||||
bold: 700,
|
||||
extrabold: 800,
|
||||
},
|
||||
};
|
||||
|
||||
export const breakpoints = {
|
||||
xs: '320px',
|
||||
sm: '480px',
|
||||
md: '768px',
|
||||
lg: '1024px',
|
||||
xl: '1280px',
|
||||
'2xl': '1536px',
|
||||
};
|
||||
@ -1,132 +0,0 @@
|
||||
/**
|
||||
* Hooks personnalisés pour le frontend
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Hook pour charger les données depuis une URL
|
||||
*/
|
||||
export function useApi(url, options = {}) {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!url) return;
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const token = localStorage.getItem('token');
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setData(result);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [url, options]);
|
||||
|
||||
return { data, loading, error };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour vérifier si l'utilisateur est connecté
|
||||
*/
|
||||
export function useAuth() {
|
||||
const [voter, setVoter] = useState(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const storedVoter = localStorage.getItem('voter');
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
if (storedVoter && token) {
|
||||
setVoter(JSON.parse(storedVoter));
|
||||
setIsAuthenticated(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur lors du chargement de l\'authentification:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { voter, isAuthenticated, loading };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook pour gérer le formulaire
|
||||
*/
|
||||
export function useForm(initialValues, onSubmit) {
|
||||
const [values, setValues] = useState(initialValues);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [touched, setTouched] = useState({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setValues(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBlur = (e) => {
|
||||
const { name } = e.target;
|
||||
setTouched(prev => ({ ...prev, [name]: true }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await onSubmit(values);
|
||||
} catch (err) {
|
||||
console.error('Erreur lors de la soumission:', err);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setValues(initialValues);
|
||||
setErrors({});
|
||||
setTouched({});
|
||||
};
|
||||
|
||||
return {
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
isSubmitting,
|
||||
handleChange,
|
||||
handleBlur,
|
||||
handleSubmit,
|
||||
resetForm,
|
||||
setValues,
|
||||
setErrors,
|
||||
};
|
||||
}
|
||||
|
||||
export default { useApi, useAuth, useForm };
|
||||
@ -1,160 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom CSS Variables for Dark Theme */
|
||||
:root {
|
||||
--color-accent-warm: #e8704b;
|
||||
--color-accent: var(--color-accent-warm);
|
||||
--link-color: var(--color-accent-warm);
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #a3a3a3;
|
||||
--text-tertiary: #737373;
|
||||
--bg-primary: #171717;
|
||||
--bg-secondary: #171717;
|
||||
--bg-overlay-light: rgba(255, 255, 255, 0.05);
|
||||
--bg-overlay-dark: rgba(0, 0, 0, 0.8);
|
||||
|
||||
/* Semantic Colors */
|
||||
--success: #10b981;
|
||||
--warning: #f97316;
|
||||
--danger: #ef4444;
|
||||
--info: #3b82f6;
|
||||
|
||||
/* Typography */
|
||||
--font-primary: "Inter", "Segoe UI", "Roboto", sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
html {
|
||||
@apply scroll-smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
@apply bg-bg-primary text-text-primary;
|
||||
font-family: var(--font-primary);
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1 {
|
||||
@apply text-4xl font-bold leading-tight mb-6;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-3xl font-bold leading-tight mb-6;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-2xl font-semibold leading-snug mb-4;
|
||||
}
|
||||
|
||||
h4 {
|
||||
@apply text-xl font-semibold leading-relaxed mb-4;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-accent-warm hover:opacity-80 transition-opacity;
|
||||
}
|
||||
|
||||
/* Responsive Typography */
|
||||
@media (max-width: 768px) {
|
||||
h1 {
|
||||
@apply text-3xl;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-2xl;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-xl;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
::-webkit-scrollbar {
|
||||
@apply w-2 h-2;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-bg-secondary;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-text-tertiary rounded-md;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-text-secondary;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
/* Utility Classes */
|
||||
.container {
|
||||
@apply max-w-6xl mx-auto px-4 sm:px-6 lg:px-8;
|
||||
}
|
||||
|
||||
.section {
|
||||
@apply py-8 sm:py-12 lg:py-16;
|
||||
}
|
||||
|
||||
.card-elevation {
|
||||
@apply shadow-lg hover:shadow-xl transition-shadow duration-300;
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './styles/globals.css';
|
||||
import './styles/components.css';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// If you want to start measuring performance in your app, pass a function
|
||||
// to log results (for example: reportWebVitals(console.log))
|
||||
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
|
||||
reportWebVitals();
|
||||
@ -1,55 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { cn } from "../utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-bg-secondary text-text-primary border-text-tertiary",
|
||||
destructive:
|
||||
"border-danger/50 text-danger dark:border-danger [&>svg]:text-danger",
|
||||
success:
|
||||
"border-success/50 text-success dark:border-success [&>svg]:text-success",
|
||||
warning:
|
||||
"border-warning/50 text-warning dark:border-warning [&>svg]:text-warning",
|
||||
info:
|
||||
"border-info/50 text-info dark:border-info [&>svg]:text-info",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@ -1,41 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { cn } from "../utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-accent-warm focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-text-tertiary bg-bg-secondary text-text-primary hover:bg-bg-overlay-light",
|
||||
secondary:
|
||||
"border-text-tertiary text-text-secondary hover:bg-bg-overlay-light",
|
||||
destructive:
|
||||
"border-danger/50 bg-danger/10 text-danger hover:bg-danger/20",
|
||||
outline: "text-text-primary border-text-tertiary",
|
||||
success:
|
||||
"border-success/50 bg-success/10 text-success hover:bg-success/20",
|
||||
warning:
|
||||
"border-warning/50 bg-warning/10 text-warning hover:bg-warning/20",
|
||||
info:
|
||||
"border-info/50 bg-info/10 text-info hover:bg-info/20",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@ -1,57 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { cn } from "../utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-bg-secondary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-warm focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-accent-warm text-white hover:bg-opacity-90 active:bg-opacity-80",
|
||||
destructive:
|
||||
"bg-danger text-white hover:bg-opacity-90 active:bg-opacity-80",
|
||||
outline:
|
||||
"border border-text-tertiary hover:bg-bg-overlay-light hover:text-text-primary",
|
||||
secondary:
|
||||
"bg-bg-secondary text-text-primary border border-text-tertiary hover:bg-bg-overlay-light",
|
||||
ghost:
|
||||
"hover:bg-bg-overlay-light hover:text-text-primary",
|
||||
link:
|
||||
"text-accent-warm underline-offset-4 hover:underline",
|
||||
success:
|
||||
"bg-success text-white hover:bg-opacity-90 active:bg-opacity-80",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3 text-xs",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
if (asChild && props.children && React.isValidElement(props.children)) {
|
||||
return React.cloneElement(props.children, {
|
||||
className: cn(buttonVariants({ variant, size }), props.children.props.className),
|
||||
ref,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@ -1,54 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../utils"
|
||||
|
||||
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-lg border border-text-tertiary bg-bg-secondary shadow-md card-elevation", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6 border-b border-text-tertiary", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<h2
|
||||
ref={ref}
|
||||
className={cn("text-2xl font-semibold leading-none tracking-tight text-text-primary", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn("text-sm text-text-secondary", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@ -1,100 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
import { cn } from "../utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-text-tertiary bg-bg-secondary p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-bg-secondary transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-accent-warm focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-bg-overlay-light data-[state=open]:text-text-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight text-text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-text-secondary", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@ -1,158 +0,0 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
import { cn } from "../utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm font-medium outline-none focus:bg-bg-overlay-light data-[state=open]:bg-bg-overlay-light text-text-primary",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{props.children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"min-w-[8rem] overflow-hidden rounded-md border border-text-tertiary bg-bg-secondary p-1 text-text-primary shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-2 data-[state=open]:slide-in-from-right-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"min-w-[8rem] overflow-hidden rounded-md border border-text-tertiary bg-bg-secondary p-1 text-text-primary shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-2 data-[state=open]:slide-in-from-right-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-bg-overlay-light focus:text-text-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50 text-text-primary",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef(({ className, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-bg-overlay-light focus:text-text-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50 text-text-primary",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-bg-overlay-light focus:text-text-primary data-[disabled]:pointer-events-none data-[disabled]:opacity-50 text-text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold text-text-primary",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-text-tertiary", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}) => (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest text-text-secondary", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
export { Button } from "./button"
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from "./card"
|
||||
export { Alert, AlertTitle, AlertDescription } from "./alert"
|
||||
export { Dialog, DialogPortal, DialogOverlay, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription } from "./dialog"
|
||||
export { Input } from "./input"
|
||||
export { Label } from "./label"
|
||||
export { Badge } from "./badge"
|
||||
export { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuGroup, DropdownMenuPortal, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger } from "./dropdown-menu"
|
||||
@ -1,17 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../utils"
|
||||
|
||||
const Input = React.forwardRef(({ className, type, ...props }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-text-tertiary bg-bg-secondary px-3 py-2 text-sm text-text-primary placeholder:text-text-tertiary ring-offset-bg-secondary transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-warm focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@ -1,16 +0,0 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "../utils"
|
||||
|
||||
const Label = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-text-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = "Label"
|
||||
|
||||
export { Label }
|
||||
@ -1,6 +0,0 @@
|
||||
import { clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@ -1,183 +0,0 @@
|
||||
.active-votes-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.active-votes-page .container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.active-votes-page .page-header {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.active-votes-page .page-header h1 {
|
||||
margin: 0.5rem 0 0.5rem 0;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.active-votes-page .page-header p {
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.active-votes-page .back-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.active-votes-page .back-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
.active-votes-page .stats-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.active-votes-page .stat {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.active-votes-page .stat-label {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.active-votes-page .stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.active-votes-page .votes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.active-votes-page .vote-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.active-votes-page .vote-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.active-votes-page .vote-card-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.active-votes-page .vote-card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.active-votes-page .vote-card-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.active-votes-page .vote-card-body p {
|
||||
margin: 0.5rem 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.active-votes-page .status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.active-votes-page .status-badge.active {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.active-votes-page .empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.active-votes-page .empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.active-votes-page .empty-state h3 {
|
||||
font-size: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.active-votes-page .empty-state p {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.active-votes-page .button-group {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.active-votes-page .button-group .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.active-votes-page .btn-vote {
|
||||
background-color: #10b981;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.active-votes-page .btn-vote:hover {
|
||||
background-color: #059669;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
@ -1,146 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import ElectionDetailsModal from '../components/ElectionDetailsModal';
|
||||
import './ActiveVotesPage.css';
|
||||
|
||||
export default function ActiveVotesPage({ voter }) {
|
||||
const [activeElections, setActiveElections] = useState([]);
|
||||
const [userVotedElectionIds, setUserVotedElectionIds] = useState(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedElectionId, setSelectedElectionId] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// Fetch all active elections
|
||||
const electionsResponse = await fetch('http://localhost:8000/api/elections/active', {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!electionsResponse.ok) throw new Error('Erreur de chargement');
|
||||
|
||||
const electionsData = await electionsResponse.json();
|
||||
// /api/elections/active retourne un objet unique, pas un array
|
||||
// On l'enveloppe dans un array pour la cohérence
|
||||
const electionsArray = Array.isArray(electionsData) ? electionsData : [electionsData];
|
||||
setActiveElections(electionsArray);
|
||||
|
||||
// Fetch user's votes to know which ones they already voted for
|
||||
const votesResponse = await fetch('http://localhost:8000/api/votes/history', {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (votesResponse.ok) {
|
||||
const votesData = await votesResponse.json();
|
||||
// Créer un Set des election_id où l'utilisateur a déjà voté (et qui sont actives)
|
||||
const votedIds = new Set(
|
||||
votesData
|
||||
.filter(vote => vote.election_status === 'active')
|
||||
.map(vote => vote.election_id)
|
||||
);
|
||||
setUserVotedElectionIds(votedIds);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Erreur:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingSpinner fullscreen />;
|
||||
|
||||
return (
|
||||
<div className="active-votes-page">
|
||||
<div className="container">
|
||||
<div className="page-header">
|
||||
<button className="back-btn" onClick={() => navigate('/dashboard')}>
|
||||
← Retour au Tableau de Bord
|
||||
</button>
|
||||
<h1>🔴 Votes en Cours</h1>
|
||||
<p>Élections en cours auxquelles vous participez</p>
|
||||
</div>
|
||||
|
||||
{activeElections.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">📭</div>
|
||||
<h3>Aucun vote en cours</h3>
|
||||
<p>Il n'y a actuellement aucune élection active.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="stats-bar">
|
||||
<div className="stat">
|
||||
<span className="stat-label">Élections en cours</span>
|
||||
<span className="stat-value">{activeElections.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="votes-grid">
|
||||
{activeElections.map(election => {
|
||||
const hasVoted = userVotedElectionIds.has(election.id);
|
||||
|
||||
return (
|
||||
<div key={election.id} className="vote-card active">
|
||||
<div className="vote-card-header">
|
||||
<h3>{election.name}</h3>
|
||||
<span className="status-badge active">
|
||||
🔴 En cours
|
||||
</span>
|
||||
</div>
|
||||
<div className="vote-card-body">
|
||||
{hasVoted ? (
|
||||
<>
|
||||
<p><strong>Statut :</strong> Vous avez voté ✓</p>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setSelectedElectionId(election.id)}
|
||||
>
|
||||
Voir les détails
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>{election.description || 'Votez pour cette élection'}</p>
|
||||
<div className="button-group">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setSelectedElectionId(election.id)}
|
||||
>
|
||||
Voir les détails
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-vote"
|
||||
onClick={() => navigate(`/vote/${election.id}`)}
|
||||
>
|
||||
✓ Voter
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ElectionDetailsModal
|
||||
electionId={selectedElectionId}
|
||||
isOpen={!!selectedElectionId}
|
||||
onClose={() => setSelectedElectionId(null)}
|
||||
voter={voter}
|
||||
type="actif"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,144 +0,0 @@
|
||||
/* ===== Archives Page Styles ===== */
|
||||
|
||||
.archives-page {
|
||||
background-color: var(--light-gray);
|
||||
min-height: 100vh;
|
||||
padding: var(--spacing-xl) 0;
|
||||
}
|
||||
|
||||
.archives-header {
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.archives-header h1 {
|
||||
color: var(--primary-dark);
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.archives-header p {
|
||||
color: var(--dark-gray);
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.archives-controls {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.search-box,
|
||||
.sort-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
background-color: var(--white);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 2px solid var(--border-gray);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.search-box:focus-within,
|
||||
.sort-box:focus-within {
|
||||
border-color: var(--primary-blue);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.search-box svg,
|
||||
.sort-box svg {
|
||||
color: var(--dark-gray);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-box input,
|
||||
.sort-box select {
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: var(--font-primary);
|
||||
font-size: 1rem;
|
||||
color: var(--dark-gray);
|
||||
outline: none;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search-box input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.sort-box select {
|
||||
cursor: pointer;
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.sort-box select option {
|
||||
background-color: var(--white);
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.results-count {
|
||||
color: var(--dark-gray);
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.votes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem var(--spacing-xl);
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
border: 2px dashed var(--border-gray);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
color: var(--primary-dark);
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--dark-gray);
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ===== Mobile Responsive ===== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.archives-page {
|
||||
padding: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.archives-header h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.archives-controls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.votes-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search-box,
|
||||
.sort-box {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
}
|
||||
@ -1,117 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Search, Filter } from 'lucide-react';
|
||||
import VoteCard from '../components/VoteCard';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import './ArchivesPage.css';
|
||||
|
||||
export default function ArchivesPage() {
|
||||
const [votes, setVotes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortBy, setSortBy] = useState('recent');
|
||||
|
||||
useEffect(() => {
|
||||
fetchArchives();
|
||||
}, []);
|
||||
|
||||
const fetchArchives = async () => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:8000/api/elections/completed');
|
||||
if (!response.ok) throw new Error('Erreur de chargement');
|
||||
const data = await response.json();
|
||||
|
||||
// Adapter les données pour VoteCard
|
||||
const adaptedData = data.map(election => ({
|
||||
id: election.id,
|
||||
titre: election.name,
|
||||
description: election.description,
|
||||
date_fermeture: election.end_date,
|
||||
date_ouverture: election.start_date,
|
||||
status: 'fermé', // Toutes les élections ici sont terminées
|
||||
}));
|
||||
|
||||
setVotes(adaptedData);
|
||||
} catch (err) {
|
||||
console.error('Erreur:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredVotes = votes
|
||||
.filter(vote =>
|
||||
vote.titre.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
vote.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (sortBy === 'recent') {
|
||||
return new Date(b.date_fermeture) - new Date(a.date_fermeture);
|
||||
} else if (sortBy === 'oldest') {
|
||||
return new Date(a.date_fermeture) - new Date(b.date_fermeture);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
if (loading) return <LoadingSpinner fullscreen />;
|
||||
|
||||
return (
|
||||
<div className="archives-page">
|
||||
<div className="container">
|
||||
<div className="archives-header">
|
||||
<div>
|
||||
<h1>📚 Archives Publiques</h1>
|
||||
<p>Consultez tous les votes terminés et leurs résultats</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="archives-controls">
|
||||
<div className="search-box">
|
||||
<Search size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher un vote..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sort-box">
|
||||
<Filter size={20} />
|
||||
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
|
||||
<option value="recent">Plus récent</option>
|
||||
<option value="oldest">Plus ancien</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredVotes.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">📭</div>
|
||||
<h3>Aucun résultat</h3>
|
||||
<p>
|
||||
{searchTerm
|
||||
? 'Aucun vote ne correspond à votre recherche.'
|
||||
: 'Il n\'y a pas encore de votes archivés.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="results-count">
|
||||
{filteredVotes.length} résultat{filteredVotes.length > 1 ? 's' : ''}
|
||||
</p>
|
||||
<div className="votes-grid">
|
||||
{filteredVotes.map(vote => (
|
||||
<VoteCard
|
||||
key={vote.id}
|
||||
vote={vote}
|
||||
showResult={true}
|
||||
context="archives"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,195 +0,0 @@
|
||||
/* ===== Auth Page Styles ===== */
|
||||
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--light-gray) 0%, #f9fafb 100%);
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-xl);
|
||||
max-width: 1000px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: var(--spacing-2xl);
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.auth-header p {
|
||||
color: var(--dark-gray);
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-wrapper .input-icon {
|
||||
position: absolute;
|
||||
left: var(--spacing-md);
|
||||
color: var(--dark-gray);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.input-wrapper input {
|
||||
padding-left: 45px !important;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.forgot-link {
|
||||
color: var(--primary-blue);
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.forgot-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.checkbox-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.checkbox-item input {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-top: 2px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkbox-item a {
|
||||
color: var(--primary-blue);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.checkbox-item a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.auth-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
color: var(--dark-gray);
|
||||
text-align: center;
|
||||
margin: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.auth-divider::before,
|
||||
.auth-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background-color: var(--border-gray);
|
||||
}
|
||||
|
||||
.auth-switch {
|
||||
text-align: center;
|
||||
color: var(--dark-gray);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.auth-switch a {
|
||||
color: var(--primary-blue);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-illustration {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.illustration-box {
|
||||
text-align: center;
|
||||
padding: var(--spacing-2xl);
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.1), rgba(30, 58, 95, 0.1));
|
||||
border-radius: var(--radius-xl);
|
||||
border: 2px solid var(--border-gray);
|
||||
}
|
||||
|
||||
.illustration-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.illustration-box h3 {
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.illustration-box p {
|
||||
color: var(--dark-gray);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ===== Mobile Responsive ===== */
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.auth-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.auth-illustration {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.auth-page {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.auth-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
}
|
||||
@ -1,463 +0,0 @@
|
||||
/* ===== Dashboard Page Styles ===== */
|
||||
|
||||
.dashboard-page {
|
||||
background-color: var(--light-gray);
|
||||
min-height: 100vh;
|
||||
padding: var(--spacing-xl) 0;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
color: var(--primary-dark);
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.dashboard-header p {
|
||||
color: var(--dark-gray);
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ===== Stats Grid ===== */
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
display: flex;
|
||||
gap: var(--spacing-lg);
|
||||
align-items: flex-start;
|
||||
transition: all 0.3s ease;
|
||||
border-left: 4px solid var(--border-gray);
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: var(--radius-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-icon.active {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
color: var(--success-green);
|
||||
}
|
||||
|
||||
.stat-icon.future {
|
||||
background-color: rgba(37, 99, 235, 0.1);
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.stat-icon.completed {
|
||||
background-color: rgba(34, 197, 94, 0.1);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.stat-icon.total {
|
||||
background-color: rgba(249, 115, 22, 0.1);
|
||||
color: var(--warning-orange);
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--primary-dark);
|
||||
line-height: 1;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.95rem;
|
||||
color: var(--dark-gray);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.stat-link {
|
||||
display: inline-block;
|
||||
color: var(--primary-blue);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-link:hover {
|
||||
color: var(--primary-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ===== Tabs ===== */
|
||||
|
||||
.dashboard-tabs {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
border-bottom: 2px solid var(--border-gray);
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
color: var(--dark-gray);
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
transition: all 0.3s ease;
|
||||
white-space: nowrap;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary-blue);
|
||||
border-bottom-color: var(--primary-blue);
|
||||
}
|
||||
|
||||
/* ===== Action Section ===== */
|
||||
|
||||
.action-section {
|
||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.05), rgba(30, 58, 95, 0.05));
|
||||
border-left: 4px solid var(--warning-orange);
|
||||
padding: var(--spacing-xl);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.action-section h2 {
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
color: var(--dark-gray);
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* ===== Votes Section ===== */
|
||||
|
||||
.votes-section h2 {
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.votes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* ===== Empty State ===== */
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-2xl);
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
border: 2px dashed var(--border-gray);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--dark-gray);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* ===== Mobile Responsive ===== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-page {
|
||||
padding: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.votes-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-tabs {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: var(--spacing-md);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
.dashboard-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
background: #d32f2f;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: #b71c1c;
|
||||
}
|
||||
|
||||
.dashboard-container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.dashboard-section {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dashboard-section h2 {
|
||||
color: #333;
|
||||
margin-top: 0;
|
||||
border-bottom: 2px solid #667eea;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.election-item {
|
||||
background: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.election-item h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.election-item p {
|
||||
margin: 5px 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.vote-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 10px;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.vote-item-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.vote-item-info h4 {
|
||||
margin: 0 0 5px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.vote-item-info p {
|
||||
margin: 0;
|
||||
color: #999;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.vote-status {
|
||||
padding: 5px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.vote-status.voted {
|
||||
background: #c8e6c9;
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.vote-status.pending {
|
||||
background: #fff9c4;
|
||||
color: #f57f17;
|
||||
}
|
||||
|
||||
.results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.candidate-result {
|
||||
background: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.candidate-result h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
background: #ddd;
|
||||
border-radius: 10px;
|
||||
height: 20px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 5px;
|
||||
color: white;
|
||||
font-size: 0.8em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.candidate-result p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.vote-btn {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
margin-top: 10px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.vote-btn:hover {
|
||||
background: #45a049;
|
||||
}
|
||||
|
||||
.empty-message {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.header-user-info {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.results-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.vote-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.vote-status {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
@ -1,135 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { API_ENDPOINTS } from '../config/api';
|
||||
import './DashboardPage.css';
|
||||
|
||||
const DashboardPage = ({ voter, onLogout }) => {
|
||||
const navigate = useNavigate();
|
||||
const [activeElection, setActiveElection] = useState(null);
|
||||
const [myVotes, setMyVotes] = useState([]);
|
||||
const [upcomingElections, setUpcomingElections] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
}, [voter]);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const electionRes = await axios.get(API_ENDPOINTS.ELECTIONS_ACTIVE);
|
||||
setActiveElection(electionRes.data);
|
||||
|
||||
setMyVotes([
|
||||
{ id: 1, name: 'Alice Dupont', voted: true },
|
||||
{ id: 2, name: 'Bob Martin', voted: false }
|
||||
]);
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Erreur de chargement');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
onLogout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
const handleVoteClick = () => {
|
||||
if (activeElection && activeElection.id) {
|
||||
navigate(`/vote/${activeElection.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div style={{ textAlign: 'center', padding: '50px' }}>Chargement du tableau de bord...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard-page">
|
||||
<header className="dashboard-header">
|
||||
<h1>📊 Tableau de Bord</h1>
|
||||
<div className="header-user-info">
|
||||
<span>👤 {voter?.email || voter?.name}</span>
|
||||
<button className="logout-btn" onClick={handleLogout}>Déconnexion</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="dashboard-container">
|
||||
{error && <div style={{ color: 'red', padding: '10px', textAlign: 'center' }}>{error}</div>}
|
||||
|
||||
{/* Section: Élection Active */}
|
||||
<section className="dashboard-section">
|
||||
<h2>⏳ Élection en cours</h2>
|
||||
{activeElection ? (
|
||||
<div className="election-item">
|
||||
<h3>{activeElection.name}</h3>
|
||||
<p>{activeElection.description}</p>
|
||||
<p className="date">
|
||||
Jusqu'au {new Date(activeElection.end_date).toLocaleDateString('fr-FR')}
|
||||
</p>
|
||||
<button className="vote-btn" onClick={handleVoteClick}>➜ Participer au vote</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="empty-message">Aucune élection active</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Section: Mes votes */}
|
||||
<section className="dashboard-section">
|
||||
<h2>✅ Mes votes</h2>
|
||||
<div>
|
||||
{myVotes.length > 0 ? (
|
||||
myVotes.map((vote) => (
|
||||
<div key={vote.id} className="vote-item">
|
||||
<div className="vote-item-info">
|
||||
<h4>{vote.name}</h4>
|
||||
<p>{vote.voted ? 'Vote participé' : 'Vote en attente'}</p>
|
||||
</div>
|
||||
<span className={`vote-status ${vote.voted ? 'voted' : 'pending'}`}>
|
||||
{vote.voted ? '✓ Voté' : '⏳ En attente'}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="empty-message">Aucun vote pour le moment</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section: Résultats actuels */}
|
||||
<section className="dashboard-section">
|
||||
<h2>📈 Résultats actuels</h2>
|
||||
{activeElection ? (
|
||||
<div className="results-grid">
|
||||
{activeElection.candidates?.map((candidate) => {
|
||||
const votes = Math.floor(Math.random() * 100);
|
||||
return (
|
||||
<div key={candidate.id} className="candidate-result">
|
||||
<h4>{candidate.name}</h4>
|
||||
<div className="progress-bar-container">
|
||||
<div className="progress-bar" style={{ width: `${votes}%` }}>
|
||||
{votes > 10 ? `${votes}%` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<p>{votes} votes</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="empty-message">Aucun résultat disponible</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
@ -1,194 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { BarChart3, Clock, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import VoteCard from '../components/VoteCard';
|
||||
import ElectionDetailsModal from '../components/ElectionDetailsModal';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import './DashboardPage.css';
|
||||
|
||||
export default function DashboardPage({ voter }) {
|
||||
const [votes, setVotes] = useState([]);
|
||||
const [userVotes, setUserVotes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedElectionId, setSelectedElectionId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVotes();
|
||||
}, []);
|
||||
|
||||
const fetchVotes = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const [activeRes, upcomingRes, completedRes] = await Promise.all([
|
||||
fetch('http://localhost:8000/api/elections/active', { headers: { 'Authorization': `Bearer ${token}` } }),
|
||||
fetch('http://localhost:8000/api/elections/upcoming', { headers: { 'Authorization': `Bearer ${token}` } }),
|
||||
fetch('http://localhost:8000/api/elections/completed', { headers: { 'Authorization': `Bearer ${token}` } })
|
||||
]);
|
||||
|
||||
const active = activeRes.ok ? await activeRes.json() : [];
|
||||
const upcoming = upcomingRes.ok ? await upcomingRes.json() : [];
|
||||
const completed = completedRes.ok ? await completedRes.json() : [];
|
||||
|
||||
const allVotes = [
|
||||
...((Array.isArray(active) ? active : [active]).map(v => ({ ...v, status: 'actif' }))),
|
||||
...upcoming.map(v => ({ ...v, status: 'futur' })),
|
||||
...completed.map(v => ({ ...v, status: 'ferme' }))
|
||||
];
|
||||
setVotes(allVotes);
|
||||
|
||||
const votesResponse = await fetch('http://localhost:8000/api/votes/history', {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (votesResponse.ok) {
|
||||
const votesData = await votesResponse.json();
|
||||
setUserVotes(votesData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erreur:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activeVotes = votes.filter(v => v.status === 'actif');
|
||||
const futureVotes = votes.filter(v => v.status === 'futur');
|
||||
const historyVotes = votes.filter(v => v.status === 'ferme');
|
||||
|
||||
if (loading) return <LoadingSpinner fullscreen />;
|
||||
|
||||
return (
|
||||
<div className="dashboard-page">
|
||||
<div className="container">
|
||||
<div className="dashboard-header">
|
||||
<h1>Bienvenue, {voter?.nom}! 👋</h1>
|
||||
<p>Voici votre tableau de bord personnel</p>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon active"><AlertCircle size={24} /></div>
|
||||
<div className="stat-content">
|
||||
<div className="stat-value">{activeVotes.length}</div>
|
||||
<div className="stat-label">Votes Actifs</div>
|
||||
<Link to="/dashboard/actifs" className="stat-link">Voir →</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon future"><Clock size={24} /></div>
|
||||
<div className="stat-content">
|
||||
<div className="stat-value">{futureVotes.length}</div>
|
||||
<div className="stat-label">À Venir</div>
|
||||
<Link to="/dashboard/futurs" className="stat-link">Voir →</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon completed"><CheckCircle size={24} /></div>
|
||||
<div className="stat-content">
|
||||
<div className="stat-value">{historyVotes.length}</div>
|
||||
<div className="stat-label">Votes Terminés</div>
|
||||
<Link to="/dashboard/historique" className="stat-link">Voir →</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon total"><BarChart3 size={24} /></div>
|
||||
<div className="stat-content">
|
||||
<div className="stat-value">{userVotes.length}</div>
|
||||
<div className="stat-label">Votes Effectués</div>
|
||||
<Link to="/dashboard/historique" className="stat-link">Voir →</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeVotes.length > 0 && (
|
||||
<div className="votes-section">
|
||||
<h2>⚡ Votes Actifs</h2>
|
||||
<p className="section-subtitle">Votes en cours - Participez maintenant!</p>
|
||||
<div className="votes-grid">
|
||||
{activeVotes.slice(0, 2).map(vote => (
|
||||
<VoteCard
|
||||
key={vote.id}
|
||||
vote={vote}
|
||||
userVote={userVotes.find(v => v.election_id === vote.id)?.choix}
|
||||
onVote={(id) => window.location.href = `/vote/${id}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{activeVotes.length > 2 && (
|
||||
<Link to="/dashboard/actifs" className="btn btn-secondary">
|
||||
Voir tous les votes actifs ({activeVotes.length})
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{futureVotes.length > 0 && (
|
||||
<div className="votes-section">
|
||||
<h2>🔮 Votes à Venir</h2>
|
||||
<p className="section-subtitle">Élections qui démarreront bientôt</p>
|
||||
<div className="votes-grid">
|
||||
{futureVotes.slice(0, 2).map(vote => (
|
||||
<VoteCard
|
||||
key={vote.id}
|
||||
vote={vote}
|
||||
context="futur"
|
||||
onShowDetails={(id) => setSelectedElectionId(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{futureVotes.length > 2 && (
|
||||
<Link to="/dashboard/futurs" className="btn btn-secondary">
|
||||
Voir tous les votes à venir ({futureVotes.length})
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{historyVotes.length > 0 && (
|
||||
<div className="votes-section">
|
||||
<h2>📋 Mon Historique</h2>
|
||||
<p className="section-subtitle">Vos 2 derniers votes</p>
|
||||
<div className="votes-grid">
|
||||
{historyVotes.slice(0, 2).map(vote => (
|
||||
<VoteCard
|
||||
key={vote.id}
|
||||
vote={vote}
|
||||
userVote={userVotes.find(v => v.election_id === vote.id)?.choix}
|
||||
showResult={true}
|
||||
context="historique"
|
||||
onShowDetails={(id) => setSelectedElectionId(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{historyVotes.length > 2 && (
|
||||
<Link to="/dashboard/historique" className="btn btn-secondary">
|
||||
Voir tout mon historique ({historyVotes.length})
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeVotes.length === 0 && futureVotes.length === 0 && historyVotes.length === 0 && (
|
||||
<div className="votes-section">
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">📭</div>
|
||||
<h3>Aucun vote disponible</h3>
|
||||
<p>Il n'y a pas encore de votes disponibles.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ElectionDetailsModal
|
||||
electionId={selectedElectionId}
|
||||
isOpen={!!selectedElectionId}
|
||||
onClose={() => setSelectedElectionId(null)}
|
||||
voter={voter}
|
||||
type="futur"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,332 +0,0 @@
|
||||
.election-details-page {
|
||||
padding: 40px 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: calc(100vh - 200px);
|
||||
}
|
||||
|
||||
.election-details-page .container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
margin-bottom: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: white;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.details-header {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 30px;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.details-header h1 {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
margin: 0 0 15px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-active {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-closed {
|
||||
background: #9E9E9E;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-future {
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 25px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.details-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.details-card {
|
||||
background: white;
|
||||
padding: 25px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.details-card h2 {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin: 0 0 20px 0;
|
||||
color: #333;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.details-card .description {
|
||||
color: #666;
|
||||
margin-bottom: 20px;
|
||||
line-height: 1.6;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.info-item svg {
|
||||
color: #667eea;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.info-item div label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #999;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.info-item div p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Candidats */
|
||||
.candidates-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.candidate-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
padding: 12px;
|
||||
background: #f8f9ff;
|
||||
border-left: 3px solid #667eea;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.candidate-item:hover {
|
||||
background: #f0f2ff;
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.candidate-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.candidate-info h3 {
|
||||
margin: 0 0 5px 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.candidate-info p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* Résultats */
|
||||
.results-card {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.results-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.total-votes {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
padding: 10px;
|
||||
background: #f0f7ff;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.total-votes svg {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.total-votes strong {
|
||||
color: #667eea;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.results-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.result-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.result-name {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.result-percentage {
|
||||
font-weight: 600;
|
||||
color: #667eea;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.result-bar {
|
||||
height: 24px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(90deg, #f0f0f0 0%, #e8e8e8 100%);
|
||||
}
|
||||
|
||||
.result-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
transition: width 0.3s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.no-results,
|
||||
.info-message {
|
||||
text-align: center;
|
||||
color: #999;
|
||||
padding: 20px;
|
||||
font-size: 14px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-message {
|
||||
background: #f0f7ff;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 480px) {
|
||||
.election-details-page {
|
||||
padding: 20px 15px;
|
||||
}
|
||||
|
||||
.details-header {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.details-header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.details-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.details-card h2 {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
@ -1,253 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Calendar, Users, BarChart3, CheckCircle } from 'lucide-react';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import Alert from '../components/Alert';
|
||||
import './ElectionDetailsPage.css';
|
||||
|
||||
export default function ElectionDetailsPage({ voter = null, type = 'archives' }) {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [election, setElection] = useState(null);
|
||||
const [results, setResults] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [candidates, setCandidates] = useState([]);
|
||||
const [userVote, setUserVote] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchElectionDetails();
|
||||
}, [id]);
|
||||
|
||||
const fetchElectionDetails = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
// Récupérer les détails de l'élection
|
||||
const electionResponse = await fetch(`http://localhost:8000/api/elections/${id}`);
|
||||
if (!electionResponse.ok) {
|
||||
throw new Error('Élection non trouvée');
|
||||
}
|
||||
const electionData = await electionResponse.json();
|
||||
setElection(electionData);
|
||||
setCandidates(electionData.candidates || []);
|
||||
|
||||
// Récupérer le vote de l'utilisateur si connecté et type = historique
|
||||
if (voter && type === 'historique') {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const userVoteResponse = await fetch(`http://localhost:8000/api/votes/election/${id}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
if (userVoteResponse.ok) {
|
||||
const userVoteData = await userVoteResponse.json();
|
||||
setUserVote(userVoteData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Impossible de récupérer le vote utilisateur');
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer les résultats si l'élection est terminée
|
||||
if (electionData.results_published) {
|
||||
try {
|
||||
const resultsResponse = await fetch(`http://localhost:8000/api/elections/${id}/results`);
|
||||
if (resultsResponse.ok) {
|
||||
const resultsData = await resultsResponse.json();
|
||||
setResults(resultsData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Résultats non disponibles');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || 'Erreur de chargement');
|
||||
console.error('Erreur:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const getElectionStatus = () => {
|
||||
if (!election) return '';
|
||||
const now = new Date();
|
||||
const start = new Date(election.start_date);
|
||||
const end = new Date(election.end_date);
|
||||
|
||||
if (now < start) return 'À venir';
|
||||
if (now > end) return 'Terminée';
|
||||
return 'En cours';
|
||||
};
|
||||
|
||||
if (loading) return <LoadingSpinner fullscreen />;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="election-details-page">
|
||||
<div className="container">
|
||||
<button className="btn btn-ghost" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft size={20} />
|
||||
Retour
|
||||
</button>
|
||||
<Alert type="error" title="Erreur" message={error} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!election) {
|
||||
return (
|
||||
<div className="election-details-page">
|
||||
<div className="container">
|
||||
<button className="btn btn-ghost" onClick={() => navigate(-1)}>
|
||||
<ArrowLeft size={20} />
|
||||
Retour
|
||||
</button>
|
||||
<Alert type="error" title="Erreur" message="Élection non trouvée" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const status = getElectionStatus();
|
||||
const statusColor = status === 'Terminée' ? 'closed' : status === 'En cours' ? 'active' : 'future';
|
||||
|
||||
return (
|
||||
<div className="election-details-page">
|
||||
<div className="container">
|
||||
<button
|
||||
className="btn btn-ghost btn-back"
|
||||
onClick={() => {
|
||||
if (type === 'historique') {
|
||||
navigate('/dashboard/historique');
|
||||
} else if (type === 'futur') {
|
||||
navigate('/dashboard/futurs');
|
||||
} else {
|
||||
navigate('/archives');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Retour
|
||||
</button>
|
||||
|
||||
<div className="details-header">
|
||||
<div>
|
||||
<h1>{election.name}</h1>
|
||||
<span className={`status-badge status-${statusColor}`}>{status}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="details-grid">
|
||||
{/* Section Informations */}
|
||||
<div className="details-card">
|
||||
<h2>📋 Informations</h2>
|
||||
<p className="description">{election.description}</p>
|
||||
|
||||
<div className="info-section">
|
||||
<div className="info-item">
|
||||
<Calendar size={20} />
|
||||
<div>
|
||||
<label>Ouverture</label>
|
||||
<p>{formatDate(election.start_date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<Calendar size={20} />
|
||||
<div>
|
||||
<label>Fermeture</label>
|
||||
<p>{formatDate(election.end_date)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Candidats */}
|
||||
<div className="details-card">
|
||||
<h2>👥 Candidats ({candidates.length})</h2>
|
||||
<div className="candidates-list">
|
||||
{candidates.map((candidate, index) => (
|
||||
<div key={candidate.id} className="candidate-item">
|
||||
<div className="candidate-number">{candidate.order || index + 1}</div>
|
||||
<div className="candidate-info">
|
||||
<h3>{candidate.name}</h3>
|
||||
{candidate.description && <p>{candidate.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section Résultats */}
|
||||
{results && election.results_published && (
|
||||
<div className="details-card results-card">
|
||||
<h2>📊 Résultats</h2>
|
||||
<div className="results-section">
|
||||
{results.results && results.results.length > 0 ? (
|
||||
<>
|
||||
<p className="total-votes">
|
||||
<Users size={18} />
|
||||
Total: <strong>{results.total_votes} vote(s)</strong>
|
||||
</p>
|
||||
<div className="results-list">
|
||||
{results.results.map((result, index) => (
|
||||
<div key={index} className="result-item">
|
||||
<div className="result-header">
|
||||
<span className="result-name">
|
||||
{userVote && userVote.candidate_name === result.candidate_name && (
|
||||
<CheckCircle size={16} style={{ display: 'inline', marginRight: '8px', color: '#4CAF50' }} />
|
||||
)}
|
||||
{result.candidate_name}
|
||||
</span>
|
||||
<span className="result-percentage">{result.percentage.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="result-bar">
|
||||
<div
|
||||
className="result-bar-fill"
|
||||
style={{ width: `${result.percentage}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="result-count">{result.vote_count} vote(s)</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="no-results">Aucun résultat disponible</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!election.results_published && status === 'Terminée' && (
|
||||
<div className="details-card">
|
||||
<p className="info-message">
|
||||
📊 Les résultats de cette élection n'ont pas encore été publiés.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status !== 'Terminée' && (
|
||||
<div className="details-card">
|
||||
<p className="info-message">
|
||||
⏳ Les résultats seront disponibles une fois l'élection terminée.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,198 +0,0 @@
|
||||
.historique-page {
|
||||
min-height: 100vh;
|
||||
padding: 40px 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.historique-page .container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.historique-header {
|
||||
margin-bottom: 40px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: inline-block;
|
||||
margin-bottom: 20px;
|
||||
padding: 10px 20px;
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: 2px solid white;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
.historique-header h1 {
|
||||
color: white;
|
||||
font-size: 2.5rem;
|
||||
margin: 10px 0 5px 0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.historique-header p {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 30px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.95rem;
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.5rem;
|
||||
color: #667eea;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.votes-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.vote-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.vote-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.vote-card.historique {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.vote-card-header {
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.vote-card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.status-badge.active {
|
||||
background: #ff6b6b;
|
||||
border-color: #ff5252;
|
||||
}
|
||||
|
||||
.status-badge.ferme {
|
||||
background: #51cf66;
|
||||
border-color: #40c057;
|
||||
}
|
||||
|
||||
.vote-card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.vote-card-body p {
|
||||
margin: 10px 0;
|
||||
color: #333;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.vote-card-body strong {
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 60px 40px;
|
||||
border-radius: 12px;
|
||||
text-align: center;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 20px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
color: #333;
|
||||
font-size: 1.5rem;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: #666;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.historique-header h1 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.historique-header p {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.stats-bar {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.votes-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@ -1,114 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import VoteCard from '../components/VoteCard';
|
||||
import ElectionDetailsModal from '../components/ElectionDetailsModal';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import './HistoriquePage.css';
|
||||
|
||||
export default function HistoriquePage({ voter }) {
|
||||
const [elections, setElections] = useState([]);
|
||||
const [userVotes, setUserVotes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedElectionId, setSelectedElectionId] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// Fetch user's votes (c'est la source de vérité)
|
||||
const votesResponse = await fetch('http://localhost:8000/api/votes/history', {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!votesResponse.ok) throw new Error('Erreur de chargement de l\'historique');
|
||||
|
||||
const votesData = await votesResponse.json();
|
||||
|
||||
// Filtrer SEULEMENT les votes pour les élections TERMINÉES (status: "closed")
|
||||
const closedVotes = votesData.filter(vote => vote.election_status === 'closed');
|
||||
setUserVotes(closedVotes);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Erreur:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Les votes retournés par /api/votes/history contiennent déjà les informations nécessaires
|
||||
const filteredElections = userVotes;
|
||||
|
||||
if (loading) return <LoadingSpinner fullscreen />;
|
||||
|
||||
return (
|
||||
<div className="historique-page">
|
||||
<div className="container">
|
||||
<div className="historique-header">
|
||||
<button className="back-btn" onClick={() => navigate('/dashboard')}>
|
||||
← Retour au Tableau de Bord
|
||||
</button>
|
||||
<h1>📋 Mon Historique de Votes</h1>
|
||||
<p>Toutes les élections passées auxquelles vous avez participé</p>
|
||||
</div>
|
||||
|
||||
{filteredElections.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">📭</div>
|
||||
<h3>Aucun vote enregistré</h3>
|
||||
<p>Vous n'avez pas encore participé à des élections terminées.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="stats-bar">
|
||||
<div className="stat">
|
||||
<span className="stat-label">Total de votes</span>
|
||||
<span className="stat-value">{filteredElections.length}</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="stat-label">Élections auxquelles vous avez participé</span>
|
||||
<span className="stat-value">{filteredElections.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="votes-grid">
|
||||
{filteredElections.map(vote => (
|
||||
<div key={vote.vote_id} className="vote-card historique">
|
||||
<div className="vote-card-header">
|
||||
<h3>{vote.election_name}</h3>
|
||||
<span className="status-badge closed">
|
||||
✅ Terminée
|
||||
</span>
|
||||
</div>
|
||||
<div className="vote-card-body">
|
||||
<p><strong>Votre choix :</strong> {vote.candidate_name}</p>
|
||||
<p><strong>Date du vote :</strong> {new Date(vote.vote_date).toLocaleDateString('fr-FR')}</p>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setSelectedElectionId(vote.election_id)}
|
||||
>
|
||||
Voir les détails
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Election Details Modal */}
|
||||
<ElectionDetailsModal
|
||||
electionId={selectedElectionId}
|
||||
isOpen={!!selectedElectionId}
|
||||
onClose={() => setSelectedElectionId(null)}
|
||||
voter={voter}
|
||||
type="historique"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,462 +0,0 @@
|
||||
/* ===== Home Page Styles ===== */
|
||||
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ===== Hero Section ===== */
|
||||
|
||||
.hero {
|
||||
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary-blue) 100%);
|
||||
color: var(--white);
|
||||
padding: 6rem var(--spacing-lg) 4rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
text-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
opacity: 0.95;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing-lg);
|
||||
justify-content: center;
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-buttons .btn-primary {
|
||||
background-color: var(--white);
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.hero-buttons .btn-primary:hover {
|
||||
background-color: var(--light-gray);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.hero-buttons .btn-secondary {
|
||||
background-color: transparent;
|
||||
color: var(--white);
|
||||
border-color: var(--white);
|
||||
}
|
||||
|
||||
.hero-buttons .btn-secondary:hover {
|
||||
background-color: var(--white);
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: var(--spacing-xl);
|
||||
margin-top: var(--spacing-2xl);
|
||||
padding-top: var(--spacing-2xl);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 1rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* ===== How It Works Section ===== */
|
||||
|
||||
.how-it-works {
|
||||
padding: 6rem var(--spacing-lg);
|
||||
background-color: var(--light-gray);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
margin-bottom: 3rem;
|
||||
color: var(--primary-dark);
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.steps-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.step-card {
|
||||
background-color: var(--white);
|
||||
padding: var(--spacing-2xl);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.step-card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
.step-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: linear-gradient(135deg, var(--primary-blue), var(--primary-light));
|
||||
color: var(--white);
|
||||
border-radius: 50%;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.step-card h3 {
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.step-card p {
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ===== Features Section ===== */
|
||||
|
||||
.features {
|
||||
padding: 6rem var(--spacing-lg);
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background-color: var(--white);
|
||||
padding: var(--spacing-2xl);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
text-align: center;
|
||||
border-top: 4px solid var(--primary-blue);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
color: var(--primary-blue);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ===== Recent Votes Section ===== */
|
||||
|
||||
.recent-votes {
|
||||
padding: 6rem var(--spacing-lg);
|
||||
background-color: var(--light-gray);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 3rem;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.votes-preview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.vote-preview-card {
|
||||
background-color: var(--white);
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
border-left: 4px solid var(--success-green);
|
||||
}
|
||||
|
||||
.vote-preview-card h4 {
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.vote-status {
|
||||
display: inline-block;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.vote-status.completed {
|
||||
background-color: #ecfdf5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.vote-result {
|
||||
color: var(--dark-gray);
|
||||
margin-bottom: var(--spacing-md);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
/* ===== CTA Section ===== */
|
||||
|
||||
.cta {
|
||||
background: linear-gradient(135deg, var(--primary-dark) 0%, var(--primary-blue) 100%);
|
||||
color: var(--white);
|
||||
padding: 6rem var(--spacing-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cta h2 {
|
||||
color: var(--white);
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.cta p {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.cta .btn-primary {
|
||||
background-color: var(--white);
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.cta .btn-primary:hover {
|
||||
background-color: var(--light-gray);
|
||||
}
|
||||
|
||||
/* ===== Mobile Responsive ===== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-title {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hero-buttons .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.cta h2 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.hero {
|
||||
padding: 3rem var(--spacing-lg) 2rem;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 3em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 1.3em;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.voter-greeting {
|
||||
font-size: 1em;
|
||||
margin-top: 15px;
|
||||
opacity: 0.8;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.results-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
background: white;
|
||||
padding: 25px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.result-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.result-card h3 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.vote-count {
|
||||
color: #666;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cta-buttons {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 12px 30px;
|
||||
font-size: 1.1em;
|
||||
border: none;
|
||||
border-radius: 25px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #5568d3;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: white;
|
||||
color: #667eea;
|
||||
border: 2px solid #667eea;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #d32f2f;
|
||||
text-align: center;
|
||||
}
|
||||
@ -1,88 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import './HomePage.css';
|
||||
|
||||
const HomePage = ({ voter }) => {
|
||||
const navigate = useNavigate();
|
||||
const [results, setResults] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchResults();
|
||||
}, []);
|
||||
|
||||
const fetchResults = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await axios.get('http://localhost:8000/api/elections/active/results');
|
||||
setResults(response.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Erreur de chargement');
|
||||
setLoading(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVoteClick = () => {
|
||||
if (voter) {
|
||||
navigate('/vote/1'); // Assuming election ID 1 is the active one
|
||||
} else {
|
||||
navigate('/login');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoginClick = () => {
|
||||
if (voter) {
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
navigate('/login');
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="container"><p>Chargement...</p></div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="container error"><p>Erreur: {error}</p></div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="home-page">
|
||||
<div className="hero">
|
||||
<h1>🗳️ Système de Vote Électronique</h1>
|
||||
<p>Résultats en direct</p>
|
||||
{voter && <p className="voter-greeting">Bienvenue, {voter.email}</p>}
|
||||
</div>
|
||||
|
||||
<div className="container">
|
||||
<div className="results-grid">
|
||||
{results && results.map((result) => {
|
||||
const percentage = result.total_votes > 0
|
||||
? ((result.votes / result.total_votes) * 100).toFixed(1)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div key={result.candidate_id} className="result-card">
|
||||
<h3>{result.candidate_name}</h3>
|
||||
<div className="progress-bar">
|
||||
<div
|
||||
className="progress-fill"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="vote-count">{result.votes} votes ({percentage}%)</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
@ -1,149 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, CheckCircle, Lock, BarChart3 } from 'lucide-react';
|
||||
import './HomePage.css';
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="home-page">
|
||||
{/* Hero Section */}
|
||||
<section className="hero">
|
||||
<div className="container">
|
||||
<div className="hero-content">
|
||||
<h1 className="hero-title">
|
||||
Votre Voix, Simplifiée et Sécurisée
|
||||
</h1>
|
||||
<p className="hero-subtitle">
|
||||
Plateforme de vote électronique transparente et sécurisée par cryptographie post-quantique.
|
||||
Votez de n'importe où, en toute confiance.
|
||||
</p>
|
||||
<div className="hero-buttons">
|
||||
<Link to="/login" className="btn btn-primary btn-lg">
|
||||
Se Connecter
|
||||
<ArrowRight size={20} />
|
||||
</Link>
|
||||
<Link to="/register" className="btn btn-secondary btn-lg">
|
||||
S'inscrire
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="hero-stats">
|
||||
<div className="stat-item">
|
||||
<div className="stat-number">1000+</div>
|
||||
<div className="stat-label">Votants</div>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<div className="stat-number">50+</div>
|
||||
<div className="stat-label">Élections</div>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<div className="stat-number">99.9%</div>
|
||||
<div className="stat-label">Sécurité</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How It Works Section */}
|
||||
<section className="how-it-works">
|
||||
<div className="container">
|
||||
<h2 className="section-title">Comment ça marche ?</h2>
|
||||
<div className="steps-grid">
|
||||
<div className="step-card">
|
||||
<div className="step-number">1</div>
|
||||
<h3>Créez un compte</h3>
|
||||
<p>Inscrivez-vous avec votre email et un mot de passe sécurisé.</p>
|
||||
</div>
|
||||
<div className="step-card">
|
||||
<div className="step-number">2</div>
|
||||
<h3>Consultez les votes</h3>
|
||||
<p>Accédez à votre tableau de bord et consultez les votes en cours.</p>
|
||||
</div>
|
||||
<div className="step-card">
|
||||
<div className="step-number">3</div>
|
||||
<h3>Votez en un clic</h3>
|
||||
<p>Participez aux votes avec un processus simple et sécurisé.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="features">
|
||||
<div className="container">
|
||||
<h2 className="section-title">Nos Garanties</h2>
|
||||
<div className="features-grid">
|
||||
<div className="feature-card">
|
||||
<Lock size={40} className="feature-icon" />
|
||||
<h3>Sécurité Maximale</h3>
|
||||
<p>
|
||||
Cryptographie post-quantique pour protéger vos votes contre les menaces futures.
|
||||
</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<CheckCircle size={40} className="feature-icon" />
|
||||
<h3>Transparence Complète</h3>
|
||||
<p>
|
||||
Tous les résultats sont publics et vérifiables. Zéro secret.
|
||||
</p>
|
||||
</div>
|
||||
<div className="feature-card">
|
||||
<BarChart3 size={40} className="feature-icon" />
|
||||
<h3>Résultats Instantanés</h3>
|
||||
<p>
|
||||
Consultez les résultats en temps réel pendant et après chaque vote.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recent Votes Section */}
|
||||
<section className="recent-votes">
|
||||
<div className="container">
|
||||
<div className="section-header">
|
||||
<h2 className="section-title">Votes Récents</h2>
|
||||
<Link to="/archives" className="btn btn-ghost">
|
||||
Voir tous les archives
|
||||
<ArrowRight size={18} />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="votes-preview">
|
||||
<div className="vote-preview-card">
|
||||
<h4>Choix du Logo de l'entreprise</h4>
|
||||
<p className="vote-status completed">✓ TERMINÉ</p>
|
||||
<p className="vote-result">Option A a gagné avec 65% des votes</p>
|
||||
<a href="/archives" className="btn btn-ghost btn-sm">Voir les détails</a>
|
||||
</div>
|
||||
<div className="vote-preview-card">
|
||||
<h4>Augmentation du budget IT</h4>
|
||||
<p className="vote-status completed">✓ TERMINÉ</p>
|
||||
<p className="vote-result">Adopté avec 78% d'approbation</p>
|
||||
<a href="/archives" className="btn btn-ghost btn-sm">Voir les détails</a>
|
||||
</div>
|
||||
<div className="vote-preview-card">
|
||||
<h4>Nouvelle politique de télétravail</h4>
|
||||
<p className="vote-status completed">✓ TERMINÉ</p>
|
||||
<p className="vote-result">Rejeté avec 52% d'opposition</p>
|
||||
<a href="/archives" className="btn btn-ghost btn-sm">Voir les détails</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="cta">
|
||||
<div className="container">
|
||||
<h2>Prêt à voter ?</h2>
|
||||
<p>Rejoignez des milliers d'électeurs et participez à la démocratie numérique.</p>
|
||||
<Link to="/register" className="btn btn-primary btn-lg">
|
||||
Créer un compte maintenant
|
||||
<ArrowRight size={20} />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,96 +0,0 @@
|
||||
/* LoginPage.css */
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-container h2 {
|
||||
text-align: center;
|
||||
color: #333;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #555;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 1em;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 1.1em;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #ffebee;
|
||||
color: #d32f2f;
|
||||
padding: 12px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.register-link {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.register-link a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.register-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@ -1,88 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import { API_ENDPOINTS } from '../config/api';
|
||||
import './LoginPage.css';
|
||||
|
||||
const LoginPage = ({ onLogin }) => {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleLogin = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await axios.post(API_ENDPOINTS.LOGIN, {
|
||||
email,
|
||||
password
|
||||
});
|
||||
|
||||
if (response.data && response.data.access_token) {
|
||||
localStorage.setItem('token', response.data.access_token);
|
||||
|
||||
const voterData = {
|
||||
email: response.data.email,
|
||||
name: response.data.first_name + ' ' + response.data.last_name,
|
||||
id: response.data.id
|
||||
};
|
||||
|
||||
localStorage.setItem('voter', JSON.stringify(voterData));
|
||||
onLogin(voterData);
|
||||
navigate('/dashboard');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || 'Email ou mot de passe incorrect');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-page">
|
||||
<div className="login-container">
|
||||
<h2>Connexion Votant</h2>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<form onSubmit={handleLogin}>
|
||||
<div className="form-group">
|
||||
<label>Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="votre@email.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Mot de passe</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="Votre mot de passe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={loading} className="btn">
|
||||
{loading ? 'Connexion...' : 'Se connecter'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p className="register-link">
|
||||
Pas encore enregistré? <a href="/register">S'inscrire</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
@ -1,179 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { Mail, Lock, LogIn, AlertCircle } from 'lucide-react';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter, Button, Input, Label, Alert, AlertTitle, AlertDescription } from '../lib/ui';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import { API_ENDPOINTS } from '../config/api';
|
||||
|
||||
export default function LoginPage({ onLogin }) {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(API_ENDPOINTS.LOGIN, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || 'Email ou mot de passe incorrect');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const voterData = {
|
||||
id: data.id,
|
||||
email: data.email,
|
||||
first_name: data.first_name,
|
||||
last_name: data.last_name,
|
||||
};
|
||||
|
||||
localStorage.setItem('voter', JSON.stringify(voterData));
|
||||
localStorage.setItem('token', data.access_token);
|
||||
onLogin(voterData);
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
setError(err.message || 'Erreur de connexion');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-bg-primary px-4 py-8">
|
||||
<div className="w-full max-w-4xl grid grid-cols-1 md:grid-cols-2 gap-8 items-center">
|
||||
{/* Left Side - Form */}
|
||||
<div className="w-full">
|
||||
<Card className="border-text-tertiary">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl text-text-primary">Se Connecter</CardTitle>
|
||||
<CardDescription>Accédez à votre tableau de bord</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6">
|
||||
{error && (
|
||||
<Alert variant="destructive" className="border-danger/50">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Erreur de connexion</AlertTitle>
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-text-primary">Email</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-5 w-5 text-text-tertiary" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="votre@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
className="pl-10 bg-bg-secondary text-text-primary border-text-tertiary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-text-primary">Mot de passe</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-5 w-5 text-text-tertiary" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
className="pl-10 bg-bg-secondary text-text-primary border-text-tertiary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<Link to="#forgot" className="text-sm text-accent-warm hover:opacity-80 transition-opacity">
|
||||
Mot de passe oublié ?
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11 flex items-center justify-center gap-2"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<LoadingSpinner />
|
||||
Connexion en cours...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogIn size={18} />
|
||||
Se Connecter
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-text-tertiary"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-bg-secondary text-text-tertiary">ou</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-text-secondary text-sm">
|
||||
Pas encore de compte?{' '}
|
||||
<Link to="/register" className="text-accent-warm hover:opacity-80 font-medium transition-opacity">
|
||||
S'inscrire
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Side - Illustration */}
|
||||
<div className="hidden md:flex flex-col items-center justify-center">
|
||||
<div className="text-center space-y-6">
|
||||
<div className="text-6xl">🗳️</div>
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-text-primary mb-3">Bienvenue</h3>
|
||||
<p className="text-text-secondary max-w-sm">
|
||||
Votez en toute confiance sur notre plateforme sécurisée par cryptographie post-quantique
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 text-sm text-text-secondary">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg">🔒</span>
|
||||
<span>Cryptographie Post-Quantique</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg">📊</span>
|
||||
<span>Résultats Transparents</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-lg">⚡</span>
|
||||
<span>Accès Instantané</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,189 +0,0 @@
|
||||
/* ===== Profile Page Styles ===== */
|
||||
|
||||
.profile-page {
|
||||
background-color: var(--light-gray);
|
||||
min-height: 100vh;
|
||||
padding: var(--spacing-xl) 0;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary-blue);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
padding: var(--spacing-md);
|
||||
transition: all 0.2s ease;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background-color: var(--white);
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.profile-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--spacing-2xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
display: flex;
|
||||
gap: var(--spacing-xl);
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--primary-blue), var(--primary-light));
|
||||
color: var(--white);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3rem;
|
||||
font-weight: 800;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-info h1 {
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.profile-info p {
|
||||
color: var(--dark-gray);
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--spacing-xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.profile-section h2 {
|
||||
color: var(--primary-dark);
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
padding-bottom: var(--spacing-lg);
|
||||
border-bottom: 2px solid var(--border-gray);
|
||||
}
|
||||
|
||||
.profile-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-weight: 600;
|
||||
color: var(--dark-gray);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-wrapper .input-icon {
|
||||
position: absolute;
|
||||
left: var(--spacing-md);
|
||||
color: var(--dark-gray);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.input-wrapper input {
|
||||
padding-left: 45px;
|
||||
padding-right: 45px;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: var(--spacing-md);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--dark-gray);
|
||||
padding: var(--spacing-sm);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
/* ===== Danger Zone ===== */
|
||||
|
||||
.danger-zone {
|
||||
border-left: 4px solid var(--danger-red);
|
||||
background-color: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
.danger-zone h2 {
|
||||
color: var(--danger-red);
|
||||
}
|
||||
|
||||
.danger-zone p {
|
||||
color: var(--dark-gray);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* ===== Mobile Responsive ===== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.profile-page {
|
||||
padding: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.profile-info h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.profile-section {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.profile-section h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
@ -1,276 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { User, Mail, Lock, LogOut, ArrowLeft, Eye, EyeOff } from 'lucide-react';
|
||||
import Alert from '../components/Alert';
|
||||
import './ProfilePage.css';
|
||||
|
||||
export default function ProfilePage({ voter, onLogout }) {
|
||||
const navigate = useNavigate();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
nom: voter?.nom || '',
|
||||
email: voter?.email || '',
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleUpdateProfile = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch('http://localhost:8000/auth/profile', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
nom: formData.nom,
|
||||
email: formData.email,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Erreur lors de la mise à jour');
|
||||
|
||||
const data = await response.json();
|
||||
localStorage.setItem('voter', JSON.stringify(data.voter));
|
||||
setSuccess('Profil mis à jour avec succès');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
if (formData.newPassword !== formData.confirmPassword) {
|
||||
setError('Les mots de passe ne correspondent pas');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.newPassword.length < 8) {
|
||||
setError('Le mot de passe doit contenir au moins 8 caractères');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch('http://localhost:8000/auth/change-password', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
current_password: formData.currentPassword,
|
||||
new_password: formData.newPassword,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Mot de passe actuel incorrect');
|
||||
|
||||
setSuccess('Mot de passe changé avec succès');
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
}));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('voter');
|
||||
localStorage.removeItem('token');
|
||||
onLogout();
|
||||
navigate('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="profile-page">
|
||||
<div className="container">
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="back-button"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Retour au dashboard
|
||||
</button>
|
||||
|
||||
<div className="profile-container">
|
||||
<div className="profile-header">
|
||||
<div className="profile-avatar">
|
||||
{voter?.nom?.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="profile-info">
|
||||
<h1>{voter?.nom}</h1>
|
||||
<p>{voter?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="profile-sections">
|
||||
{/* General Information */}
|
||||
<div className="profile-section">
|
||||
<h2>Informations Générales</h2>
|
||||
|
||||
{error && (
|
||||
<Alert type="error" message={error} onClose={() => setError('')} />
|
||||
)}
|
||||
{success && (
|
||||
<Alert type="success" message={success} onClose={() => setSuccess('')} />
|
||||
)}
|
||||
|
||||
<form onSubmit={handleUpdateProfile} className="profile-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="nom">Nom complet</label>
|
||||
<div className="input-wrapper">
|
||||
<User size={20} className="input-icon" />
|
||||
<input
|
||||
id="nom"
|
||||
type="text"
|
||||
name="nom"
|
||||
value={formData.nom}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email</label>
|
||||
<div className="input-wrapper">
|
||||
<Mail size={20} className="input-icon" />
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||
{loading ? 'Sauvegarde...' : 'Sauvegarder'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Security Section */}
|
||||
<div className="profile-section">
|
||||
<h2>Sécurité</h2>
|
||||
|
||||
<form onSubmit={handleChangePassword} className="profile-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="currentPassword">Mot de passe actuel</label>
|
||||
<div className="input-wrapper">
|
||||
<Lock size={20} className="input-icon" />
|
||||
<input
|
||||
id="currentPassword"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
name="currentPassword"
|
||||
placeholder="••••••••"
|
||||
value={formData.currentPassword}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="password-toggle"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="newPassword">Nouveau mot de passe</label>
|
||||
<div className="input-wrapper">
|
||||
<Lock size={20} className="input-icon" />
|
||||
<input
|
||||
id="newPassword"
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
name="newPassword"
|
||||
placeholder="Minimum 8 caractères"
|
||||
value={formData.newPassword}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="password-toggle"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
>
|
||||
{showNewPassword ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="confirmPassword">Confirmer le mot de passe</label>
|
||||
<div className="input-wrapper">
|
||||
<Lock size={20} className="input-icon" />
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
placeholder="••••••••"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary" disabled={loading}>
|
||||
{loading ? 'Mise à jour...' : 'Changer le mot de passe'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="profile-section danger-zone">
|
||||
<h2>Zone Dangereuse</h2>
|
||||
<p>Attention: Ces actions sont irréversibles.</p>
|
||||
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
<LogOut size={20} />
|
||||
Déconnexion
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,148 +0,0 @@
|
||||
/* RegisterPage.css */
|
||||
.register-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.register-container {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.register-container h2 {
|
||||
text-align: center;
|
||||
color: #333;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #555;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
.form-group input[type="email"],
|
||||
.form-group input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 1em;
|
||||
transition: border-color 0.3s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.terms-checkbox {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.terms-checkbox input[type="checkbox"] {
|
||||
margin-top: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.terms-checkbox label {
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
color: #666;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.terms-checkbox a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.terms-checkbox a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 1.1em;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #667eea;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #ffebee;
|
||||
color: #d32f2f;
|
||||
padding: 12px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-link {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.login-link a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.login-link a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.register-container {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
@ -1,256 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { User, Mail, Lock, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import APIClient from '../utils/api';
|
||||
import Alert from '../components/Alert';
|
||||
import { API_ENDPOINTS } from '../config/api';
|
||||
|
||||
export default function RegisterPage({ onLogin }) {
|
||||
const navigate = useNavigate();
|
||||
const [formData, setFormData] = useState({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
citizen_id: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// Validation
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
setError('Les mots de passe ne correspondent pas');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!agreedToTerms) {
|
||||
setError('Vous devez accepter les conditions d\'utilisation');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.password.length < 8) {
|
||||
setError('Le mot de passe doit contenir au moins 8 caractères');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
first_name: formData.first_name,
|
||||
last_name: formData.last_name,
|
||||
citizen_id: formData.citizen_id,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
};
|
||||
|
||||
console.log('Sending registration data:', payload);
|
||||
|
||||
const response = await fetch(API_ENDPOINTS.REGISTER, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error('Registration error response:', errorData);
|
||||
// Afficher les erreurs de validation détaillées
|
||||
if (errorData.detail) {
|
||||
if (Array.isArray(errorData.detail)) {
|
||||
throw new Error(errorData.detail.map(e => e.msg).join(', '));
|
||||
}
|
||||
throw new Error(errorData.detail);
|
||||
}
|
||||
throw new Error('Erreur lors de l\'inscription');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Registration successful:', data);
|
||||
|
||||
// Extraire les données de l'utilisateur
|
||||
const voterData = {
|
||||
id: data.id,
|
||||
email: data.email,
|
||||
first_name: data.first_name,
|
||||
last_name: data.last_name,
|
||||
};
|
||||
|
||||
localStorage.setItem('voter', JSON.stringify(voterData));
|
||||
localStorage.setItem('token', data.access_token || '');
|
||||
|
||||
onLogin(voterData);
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
setError(err.message || 'Erreur d\'inscription');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="auth-page">
|
||||
<div className="auth-container">
|
||||
<div className="auth-card">
|
||||
<div className="auth-header">
|
||||
<h1>Créer un compte</h1>
|
||||
<p>Rejoignez notre plateforme de vote</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert type="error" message={error} onClose={() => setError('')} />
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="first_name">Prénom</label>
|
||||
<div className="input-wrapper">
|
||||
<User size={20} className="input-icon" />
|
||||
<input
|
||||
id="first_name"
|
||||
type="text"
|
||||
name="first_name"
|
||||
placeholder="Jean"
|
||||
value={formData.first_name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="last_name">Nom</label>
|
||||
<div className="input-wrapper">
|
||||
<User size={20} className="input-icon" />
|
||||
<input
|
||||
id="last_name"
|
||||
type="text"
|
||||
name="last_name"
|
||||
placeholder="Dupont"
|
||||
value={formData.last_name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="citizen_id">Numéro de Carte Nationale d'Identité (CNI)</label>
|
||||
<div className="input-wrapper">
|
||||
<User size={20} className="input-icon" />
|
||||
<input
|
||||
id="citizen_id"
|
||||
type="text"
|
||||
name="citizen_id"
|
||||
placeholder="123456789"
|
||||
value={formData.citizen_id}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={loading}
|
||||
pattern="[0-9]{4,12}"
|
||||
title="Le numéro CNI doit contenir entre 4 et 12 chiffres"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="email">Email</label>
|
||||
<div className="input-wrapper">
|
||||
<Mail size={20} className="input-icon" />
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="votre@email.com"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">Mot de passe</label>
|
||||
<div className="input-wrapper">
|
||||
<Lock size={20} className="input-icon" />
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Minimum 8 caractères"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="confirmPassword">Confirmer le mot de passe</label>
|
||||
<div className="input-wrapper">
|
||||
<Lock size={20} className="input-icon" />
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
name="confirmPassword"
|
||||
placeholder="••••••••"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group checkbox-group">
|
||||
<label className="checkbox-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agreedToTerms}
|
||||
onChange={(e) => setAgreedToTerms(e.target.checked)}
|
||||
disabled={loading}
|
||||
required
|
||||
/>
|
||||
<span>J'accepte les <a href="#cgu">conditions d'utilisation</a> et la <a href="#privacy">politique de confidentialité</a></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary btn-lg btn-block" disabled={loading}>
|
||||
<CheckCircle size={20} />
|
||||
{loading ? 'Inscription...' : 'S\'inscrire'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="auth-divider">ou</div>
|
||||
|
||||
<p className="auth-switch">
|
||||
Déjà inscrit ? <Link to="/login">Se connecter</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="auth-illustration">
|
||||
<div className="illustration-box">
|
||||
<div className="illustration-icon">✓</div>
|
||||
<h3>Sécurisé</h3>
|
||||
<p>Votre vote est protégé par cryptographie post-quantique</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,155 +0,0 @@
|
||||
.upcoming-votes-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.upcoming-votes-page .container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.upcoming-votes-page .page-header {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.upcoming-votes-page .page-header h1 {
|
||||
margin: 0.5rem 0 0.5rem 0;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.upcoming-votes-page .page-header p {
|
||||
margin: 0;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.upcoming-votes-page .back-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
margin-bottom: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.upcoming-votes-page .back-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
.upcoming-votes-page .stats-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.upcoming-votes-page .stat {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.upcoming-votes-page .stat-label {
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.upcoming-votes-page .stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.upcoming-votes-page .elections-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.upcoming-votes-page .election-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.upcoming-votes-page .election-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.upcoming-votes-page .election-card-header {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
color: white;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.upcoming-votes-page .election-card-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.upcoming-votes-page .election-card-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.upcoming-votes-page .election-card-body p {
|
||||
margin: 0.5rem 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.upcoming-votes-page .status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.upcoming-votes-page .status-badge.upcoming {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.upcoming-votes-page .empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.upcoming-votes-page .empty-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.upcoming-votes-page .empty-state h3 {
|
||||
font-size: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.upcoming-votes-page .empty-state p {
|
||||
opacity: 0.9;
|
||||
}
|
||||
@ -1,101 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import ElectionDetailsModal from '../components/ElectionDetailsModal';
|
||||
import './UpcomingVotesPage.css';
|
||||
|
||||
export default function UpcomingVotesPage({ voter }) {
|
||||
const [upcomingElections, setUpcomingElections] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedElectionId, setSelectedElectionId] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// Fetch upcoming elections
|
||||
const electionsResponse = await fetch('http://localhost:8000/api/elections/upcoming', {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!electionsResponse.ok) throw new Error('Erreur de chargement');
|
||||
|
||||
const electionsData = await electionsResponse.json();
|
||||
setUpcomingElections(electionsData || []);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Erreur:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingSpinner fullscreen />;
|
||||
|
||||
return (
|
||||
<div className="upcoming-votes-page">
|
||||
<div className="container">
|
||||
<div className="page-header">
|
||||
<button className="back-btn" onClick={() => navigate('/dashboard')}>
|
||||
← Retour au Tableau de Bord
|
||||
</button>
|
||||
<h1>⏳ Votes à Venir</h1>
|
||||
<p>Élections qui arriveront prochainement</p>
|
||||
</div>
|
||||
|
||||
{upcomingElections.length === 0 ? (
|
||||
<div className="empty-state">
|
||||
<div className="empty-icon">📭</div>
|
||||
<h3>Aucun vote à venir</h3>
|
||||
<p>Aucune élection prévue pour le moment.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="stats-bar">
|
||||
<div className="stat">
|
||||
<span className="stat-label">Élections à venir</span>
|
||||
<span className="stat-value">{upcomingElections.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="elections-grid">
|
||||
{upcomingElections.map(election => (
|
||||
<div key={election.id} className="election-card upcoming">
|
||||
<div className="election-card-header">
|
||||
<h3>{election.name}</h3>
|
||||
<span className="status-badge upcoming">
|
||||
⏳ À venir
|
||||
</span>
|
||||
</div>
|
||||
<div className="election-card-body">
|
||||
<p><strong>Description :</strong> {election.description || 'N/A'}</p>
|
||||
<p><strong>Début :</strong> {new Date(election.start_date).toLocaleDateString('fr-FR')}</p>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => setSelectedElectionId(election.id)}
|
||||
>
|
||||
Voir les détails
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ElectionDetailsModal
|
||||
electionId={selectedElectionId}
|
||||
isOpen={!!selectedElectionId}
|
||||
onClose={() => setSelectedElectionId(null)}
|
||||
voter={voter}
|
||||
type="futur"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,653 +0,0 @@
|
||||
/* ===== Voting Page Styles ===== */
|
||||
|
||||
.voting-page {
|
||||
background-color: var(--light-gray);
|
||||
min-height: 100vh;
|
||||
padding: var(--spacing-xl) 0;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--primary-blue);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
padding: var(--spacing-md);
|
||||
transition: all 0.2s ease;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
background-color: var(--white);
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.vote-container {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: var(--spacing-xl);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.vote-info-section {
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--spacing-xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.vote-header {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
border-bottom: 2px solid var(--light-gray);
|
||||
padding-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.vote-header h1 {
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.vote-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
color: var(--dark-gray);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.meta-item strong {
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.vote-description h2 {
|
||||
color: var(--primary-dark);
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: var(--spacing-md);
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.vote-description p {
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.8;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.vote-context {
|
||||
background-color: var(--light-gray);
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
border-left: 4px solid var(--primary-blue);
|
||||
margin: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.vote-context h3 {
|
||||
color: var(--primary-dark);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.vote-results {
|
||||
margin-top: var(--spacing-xl);
|
||||
padding-top: var(--spacing-xl);
|
||||
border-top: 2px solid var(--border-gray);
|
||||
}
|
||||
|
||||
.vote-results h3 {
|
||||
color: var(--primary-dark);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.results-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.result-row {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr 60px;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.result-option {
|
||||
font-weight: 600;
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.result-bar {
|
||||
height: 32px;
|
||||
background-color: var(--border-gray);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.result-bar-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--primary-blue), var(--primary-light));
|
||||
transition: width 0.5s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-right: var(--spacing-md);
|
||||
}
|
||||
|
||||
.result-percent {
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
/* ===== Voting Section ===== */
|
||||
|
||||
.vote-voting-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.voting-card {
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--spacing-xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
position: sticky;
|
||||
top: 90px;
|
||||
}
|
||||
|
||||
.voting-card h2 {
|
||||
color: var(--primary-dark);
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.voting-form {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.voting-question {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.voting-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.voting-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
border: 2px solid var(--border-gray);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.voting-option:hover {
|
||||
border-color: var(--primary-blue);
|
||||
background-color: rgba(37, 99, 235, 0.05);
|
||||
}
|
||||
|
||||
.voting-option input[type="radio"] {
|
||||
cursor: pointer;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.voting-option input[type="radio"]:checked + .option-text {
|
||||
font-weight: 600;
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.voting-option.checked {
|
||||
border-color: var(--primary-blue);
|
||||
background-color: rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.option-text {
|
||||
font-weight: 500;
|
||||
color: var(--dark-gray);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.voting-info {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background-color: #fef3c7;
|
||||
border-radius: var(--radius-lg);
|
||||
border-left: 4px solid var(--warning-orange);
|
||||
}
|
||||
|
||||
.voting-info svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--warning-orange);
|
||||
}
|
||||
|
||||
.voting-info p {
|
||||
color: #78350f;
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ===== Confirmation Modal ===== */
|
||||
|
||||
.confirmation-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.confirmation-content svg {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.confirmation-message {
|
||||
font-size: 1.05rem;
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* ===== Success Screen ===== */
|
||||
|
||||
.success-screen {
|
||||
text-align: center;
|
||||
padding: var(--spacing-2xl);
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-lg);
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
font-size: 5rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
display: block;
|
||||
animation: scaleIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
transform: scale(0);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.success-screen h1 {
|
||||
color: var(--success-green);
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.success-screen p {
|
||||
color: var(--dark-gray);
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.success-details {
|
||||
background-color: var(--light-gray);
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
margin: var(--spacing-lg) 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.success-details p {
|
||||
margin-bottom: var(--spacing-md);
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.success-details p strong {
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.success-note {
|
||||
color: var(--text-muted, #6b7280);
|
||||
font-size: 0.9rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ===== Mobile Responsive ===== */
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.vote-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.voting-card {
|
||||
position: static;
|
||||
}
|
||||
|
||||
.result-row {
|
||||
grid-template-columns: 100px 1fr 50px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.voting-page {
|
||||
padding: var(--spacing-lg) 0;
|
||||
}
|
||||
|
||||
.vote-info-section,
|
||||
.voting-card {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.vote-header h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.vote-meta {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.result-row {
|
||||
grid-template-columns: 80px 1fr 40px;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.result-option {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.result-percent {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.vote-container {
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.success-screen {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.success-screen h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
.voting-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.voting-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.voting-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 2px solid #667eea;
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.voting-header h1 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: #f0f0f0;
|
||||
border: none;
|
||||
padding: 8px 12px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.election-info {
|
||||
background: #f9f9f9;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 30px;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.election-info h2 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.election-info p {
|
||||
margin: 8px 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.election-date {
|
||||
font-size: 0.9em;
|
||||
color: #999 !important;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.candidates-section {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.candidates-section h3 {
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #ffebee;
|
||||
color: #d32f2f;
|
||||
padding: 12px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background: #c8e6c9;
|
||||
color: #2e7d32;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.candidates-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.candidate-card {
|
||||
background: #f5f5f5;
|
||||
border: 2px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.candidate-card:hover {
|
||||
border-color: #667eea;
|
||||
background: #f0f4ff;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.candidate-card.selected {
|
||||
border-color: #4caf50;
|
||||
background: #f1f8e9;
|
||||
}
|
||||
|
||||
.candidate-avatar {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin: 0 auto 10px;
|
||||
}
|
||||
|
||||
.candidate-card h4 {
|
||||
margin: 10px 0 5px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.candidate-card p {
|
||||
margin: 0;
|
||||
color: #999;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.voting-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.submit-btn,
|
||||
.cancel-btn {
|
||||
flex: 1;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 1em;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
background: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: #45a049;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: #f0f0f0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.cancel-btn:hover:not(:disabled) {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
.cancel-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.voting-info {
|
||||
background: #e3f2fd;
|
||||
border: 1px solid #90caf9;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
color: #1565c0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.voting-info p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.voting-container {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.candidates-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.voting-header {
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.voting-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@ -1,169 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import './VotingPage.css';
|
||||
|
||||
const VotingPage = ({ voter, onVoteSuccess }) => {
|
||||
const { electionId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [election, setElection] = useState(null);
|
||||
const [candidates, setCandidates] = useState([]);
|
||||
const [selectedCandidate, setSelectedCandidate] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch election details
|
||||
axios
|
||||
.get('http://localhost:8000/api/elections/active')
|
||||
.then((response) => {
|
||||
const elections = Array.isArray(response.data) ? response.data : [response.data];
|
||||
const selected = elections[0] || null;
|
||||
setElection(selected);
|
||||
|
||||
// For now, using mock candidates
|
||||
if (selected) {
|
||||
setCandidates([
|
||||
{ id: 1, name: 'Candidate A', electionId: selected.id },
|
||||
{ id: 2, name: 'Candidate B', electionId: selected.id },
|
||||
{ id: 3, name: 'Candidate C', electionId: selected.id },
|
||||
]);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Error fetching election:', err);
|
||||
setError('Impossible de charger l\'élection');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleVote = async () => {
|
||||
if (!selectedCandidate || !election) {
|
||||
setError('Veuillez sélectionner un candidat');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await axios.post('http://localhost:8000/api/votes/submit', {
|
||||
voterId: voter.id || 1,
|
||||
electionId: election.id,
|
||||
candidateId: selectedCandidate,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
setSuccess(true);
|
||||
|
||||
// Show success message then redirect
|
||||
setTimeout(() => {
|
||||
navigate('/dashboard');
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Error submitting vote:', err);
|
||||
setError(
|
||||
err.response?.data?.message ||
|
||||
'Erreur lors de la soumission du vote. Veuillez réessayer.'
|
||||
);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="voting-page"><p>Chargement...</p></div>;
|
||||
}
|
||||
|
||||
if (!election) {
|
||||
return (
|
||||
<div className="voting-page">
|
||||
<p>Aucune élection active disponible</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="voting-page">
|
||||
<div className="voting-container">
|
||||
<div className="voting-header">
|
||||
<h1>Voter</h1>
|
||||
<button
|
||||
className="back-btn"
|
||||
onClick={() => navigate('/dashboard')}
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="election-info">
|
||||
<h2>{election.name}</h2>
|
||||
<p>{election.description}</p>
|
||||
<p className="election-date">
|
||||
Élection active jusqu'au: {new Date(election.endDate).toLocaleDateString('fr-FR')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="candidates-section">
|
||||
<h3>Sélectionnez votre candidat :</h3>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<div className="candidates-grid">
|
||||
{candidates.map((candidate) => (
|
||||
<div
|
||||
key={candidate.id}
|
||||
className={`candidate-card ${selectedCandidate === candidate.id ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedCandidate(candidate.id)}
|
||||
>
|
||||
<div className="candidate-avatar">
|
||||
{candidate.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<h4>{candidate.name}</h4>
|
||||
<p>Candidat</p>
|
||||
{selectedCandidate === candidate.id && (
|
||||
<div className="checkmark">✓</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<div className="success-message">
|
||||
✓ Vote enregistré avec succès! Redirection en cours...
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="voting-actions">
|
||||
<button
|
||||
className="submit-btn"
|
||||
onClick={handleVote}
|
||||
disabled={!selectedCandidate || submitting}
|
||||
>
|
||||
{submitting ? 'Envoi en cours...' : 'Confirmer mon vote'}
|
||||
</button>
|
||||
<button
|
||||
className="cancel-btn"
|
||||
onClick={() => navigate('/dashboard')}
|
||||
disabled={submitting}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="voting-info">
|
||||
<p>
|
||||
<strong>Importante:</strong> Votre vote est crypté et vos données sont protégées
|
||||
avec la cryptographie post-quantique de dernier cri.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VotingPage;
|
||||
@ -1,261 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, CheckCircle, AlertCircle, BarChart3 } from 'lucide-react';
|
||||
import Alert from '../components/Alert';
|
||||
import Modal from '../components/Modal';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import './VotingPage.css';
|
||||
|
||||
export default function VotingPage() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [election, setElection] = useState(null);
|
||||
const [candidates, setCandidates] = useState([]);
|
||||
const [userVote, setUserVote] = useState(null);
|
||||
const [results, setResults] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedOption, setSelectedOption] = useState('');
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [voting, setVoting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchElectionData();
|
||||
}, [id]);
|
||||
|
||||
const fetchElectionData = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
// Fetch election details
|
||||
const electionResponse = await fetch(`http://localhost:8000/elections/${id}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!electionResponse.ok) throw new Error('Élection non trouvée');
|
||||
|
||||
const electionData = await electionResponse.json();
|
||||
setElection(electionData);
|
||||
|
||||
// Fetch candidates for this election
|
||||
const candidatesResponse = await fetch(`http://localhost:8000/elections/${id}/candidates`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (candidatesResponse.ok) {
|
||||
const candidatesData = await candidatesResponse.json();
|
||||
setCandidates(candidatesData);
|
||||
}
|
||||
|
||||
// Fetch user's vote if it exists
|
||||
const userVoteResponse = await fetch(`http://localhost:8000/votes/my-votes?election_id=${id}`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (userVoteResponse.ok) {
|
||||
const userVoteData = await userVoteResponse.json();
|
||||
if (Array.isArray(userVoteData) && userVoteData.length > 0) {
|
||||
setUserVote(userVoteData[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch results if election is closed
|
||||
if (electionData.results_published) {
|
||||
const resultsResponse = await fetch(`http://localhost:8000/elections/${id}/results`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (resultsResponse.ok) {
|
||||
const resultsData = await resultsResponse.json();
|
||||
setResults(resultsData);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVoteSubmit = async () => {
|
||||
if (!selectedOption) {
|
||||
setError('Veuillez sélectionner une option');
|
||||
return;
|
||||
}
|
||||
|
||||
setShowConfirmModal(false);
|
||||
setVoting(true);
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch('http://localhost:8000/votes', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
election_id: election.id,
|
||||
choix: selectedOption,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors de la soumission du vote');
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
setSuccess('Votre vote a été enregistré avec succès!');
|
||||
setTimeout(() => navigate('/dashboard'), 2000);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setVoting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingSpinner fullscreen />;
|
||||
|
||||
if (!election) {
|
||||
return (
|
||||
<div className="voting-page">
|
||||
<div className="container">
|
||||
<Alert type="error" message={error || 'Élection non trouvée'} />
|
||||
<button onClick={() => navigate('/dashboard')} className="btn btn-secondary">
|
||||
<ArrowLeft size={20} />
|
||||
Retour au dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="voting-page">
|
||||
<div className="container">
|
||||
<div className="success-screen">
|
||||
<div className="success-icon">✓</div>
|
||||
<h1>Merci!</h1>
|
||||
<p>Votre vote a été enregistré avec succès.</p>
|
||||
<div className="success-details">
|
||||
<p><strong>Vote:</strong> {election.name}</p>
|
||||
<p><strong>Votre choix:</strong> {selectedOption}</p>
|
||||
<p className="success-note">
|
||||
Redirection vers le tableau de bord...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isHistorique = election.results_published;
|
||||
const isActif = election.is_active;
|
||||
const isFutur = new Date(election.start_date) > new Date();
|
||||
|
||||
return (
|
||||
<div className="voting-page">
|
||||
<div className="container">
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="back-button"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
Retour
|
||||
</button>
|
||||
|
||||
<div className="vote-container">
|
||||
{/* Vote Header */}
|
||||
<div className="vote-header-section">
|
||||
<h1>{election.name}</h1>
|
||||
<p className="vote-description">{election.description}</p>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="vote-status">
|
||||
{isHistorique && <span className="badge badge-closed">📊 Terminé - Résultats disponibles</span>}
|
||||
{isActif && <span className="badge badge-active">🟢 Élection en cours</span>}
|
||||
{isFutur && <span className="badge badge-future">⏰ À venir</span>}
|
||||
</div>
|
||||
|
||||
{/* User's vote if already voted */}
|
||||
{userVote && (
|
||||
<div className="user-vote-info">
|
||||
<CheckCircle size={20} color="green" />
|
||||
<span>Vous avez voté pour: <strong>{userVote.choix}</strong></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Candidates Section */}
|
||||
<div className="candidates-section">
|
||||
{candidates.length > 0 ? (
|
||||
<>
|
||||
<h2>{isHistorique ? '📊 Résultats' : 'Candidats'}</h2>
|
||||
<div className="candidates-list">
|
||||
{candidates.map((candidate) => (
|
||||
<div key={candidate.id} className="candidate-card">
|
||||
<div className="candidate-info">
|
||||
<h3>{candidate.name}</h3>
|
||||
{candidate.description && <p>{candidate.description}</p>}
|
||||
</div>
|
||||
{isActif && !userVote && (
|
||||
<button
|
||||
className={`btn btn-primary ${selectedOption === candidate.name ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setSelectedOption(candidate.name);
|
||||
setShowConfirmModal(true);
|
||||
}}
|
||||
>
|
||||
Voter
|
||||
</button>
|
||||
)}
|
||||
{isHistorique && results && (
|
||||
<div className="candidate-result">
|
||||
<span className="result-percent">
|
||||
{results[candidate.name] || 0}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Alert type="info" message="Aucun candidat pour cette élection" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Future Election Message */}
|
||||
{isFutur && (
|
||||
<div className="future-election-message">
|
||||
<AlertCircle size={40} />
|
||||
<h3>Élection à venir</h3>
|
||||
<p>Les détails et la date d'ouverture seront bientôt disponibles.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
<Modal
|
||||
isOpen={showConfirmModal}
|
||||
title="Confirmez votre vote"
|
||||
onClose={() => setShowConfirmModal(false)}
|
||||
onConfirm={handleVoteSubmit}
|
||||
confirmText="Confirmer et Voter"
|
||||
cancelText="Annuler"
|
||||
>
|
||||
<div className="confirmation-content">
|
||||
<AlertCircle size={40} style={{ color: 'var(--warning-orange)' }} />
|
||||
<p className="confirmation-message">
|
||||
Êtes-vous sûr? Votre vote pour <strong>{selectedOption}</strong> sera définitif et ne pourra pas être modifié.
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
export { default as HomePage } from './HomePage';
|
||||
export { default as LoginPage } from './LoginPage';
|
||||
export { default as RegisterPage } from './RegisterPage';
|
||||
export { default as DashboardPage } from './DashboardPage';
|
||||
export { default as VotingPage } from './VotingPage';
|
||||
export { default as ArchivesPage } from './ArchivesPage';
|
||||
export { default as ElectionDetailsPage } from './ElectionDetailsPage';
|
||||
export { default as ProfilePage } from './ProfilePage';
|
||||
@ -1,13 +0,0 @@
|
||||
const reportWebVitals = onPerfEntry => {
|
||||
if (onPerfEntry && onPerfEntry instanceof Function) {
|
||||
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
|
||||
getCLS(onPerfEntry);
|
||||
getFID(onPerfEntry);
|
||||
getFCP(onPerfEntry);
|
||||
getLCP(onPerfEntry);
|
||||
getTTFB(onPerfEntry);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default reportWebVitals;
|
||||
@ -1,5 +0,0 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom';
|
||||
@ -1,350 +0,0 @@
|
||||
/* ===== Buttons ===== */
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
font-family: var(--font-primary);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Button Variants */
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-blue);
|
||||
color: var(--white);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: var(--primary-dark);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--light-gray);
|
||||
color: var(--primary-blue);
|
||||
border: 2px solid var(--primary-blue);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: var(--primary-blue);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--success-green);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--danger-red);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: var(--warning-orange);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.btn-warning:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background-color: transparent;
|
||||
color: var(--primary-blue);
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background-color: var(--light-gray);
|
||||
border-color: var(--primary-blue);
|
||||
}
|
||||
|
||||
/* Button Sizes */
|
||||
|
||||
.btn-sm {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.btn-block {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ===== Forms ===== */
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
font-weight: 600;
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="password"],
|
||||
input[type="number"],
|
||||
input[type="date"],
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md);
|
||||
border: 2px solid var(--border-gray);
|
||||
border-radius: var(--radius-lg);
|
||||
font-family: var(--font-primary);
|
||||
font-size: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
background-color: var(--white);
|
||||
}
|
||||
|
||||
input[type="text"]:focus,
|
||||
input[type="email"]:focus,
|
||||
input[type="password"]:focus,
|
||||
input[type="number"]:focus,
|
||||
input[type="date"]:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-blue);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
input[type="checkbox"],
|
||||
input[type="radio"] {
|
||||
cursor: pointer;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
accent-color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.checkbox-group,
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.checkbox-item,
|
||||
.radio-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-item input,
|
||||
.radio-item input {
|
||||
width: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--danger-red);
|
||||
font-size: 0.875rem;
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.success-message {
|
||||
color: var(--success-green);
|
||||
font-size: 0.875rem;
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
|
||||
/* ===== Cards ===== */
|
||||
|
||||
.card {
|
||||
background-color: var(--white);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: var(--spacing-lg);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
border-bottom: 1px solid var(--border-gray);
|
||||
padding-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
border-top: 1px solid var(--border-gray);
|
||||
padding-top: var(--spacing-lg);
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
/* ===== Alerts ===== */
|
||||
|
||||
.alert {
|
||||
padding: var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background-color: #ecfdf5;
|
||||
color: #065f46;
|
||||
border-left: 4px solid var(--success-green);
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background-color: #fef2f2;
|
||||
color: #7f1d1d;
|
||||
border-left: 4px solid var(--danger-red);
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background-color: #fffbeb;
|
||||
color: #78350f;
|
||||
border-left: 4px solid var(--warning-orange);
|
||||
}
|
||||
|
||||
.alert-info {
|
||||
background-color: #eff6ff;
|
||||
color: #0c2d6b;
|
||||
border-left: 4px solid var(--primary-blue);
|
||||
}
|
||||
|
||||
/* ===== Loading States ===== */
|
||||
|
||||
.loading-spinner {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 3px solid var(--border-gray);
|
||||
border-top-color: var(--primary-blue);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* ===== Utility Classes ===== */
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--spacing-lg);
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.gap-sm {
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.gap-md {
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.gap-lg {
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.mt-md {
|
||||
margin-top: var(--spacing-md);
|
||||
}
|
||||
|
||||
.mb-md {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.btn {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
}
|
||||
@ -1,180 +0,0 @@
|
||||
/* ===== Global Styles ===== */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Palette de Couleurs Professionnelle */
|
||||
--primary-dark: #1e3a5f; /* Bleu foncé - Confiance */
|
||||
--primary-blue: #2563eb; /* Bleu vif */
|
||||
--primary-light: #3b82f6; /* Bleu clair */
|
||||
--success-green: #10b981; /* Vert succès */
|
||||
--warning-orange: #f97316; /* Orange action */
|
||||
--danger-red: #ef4444; /* Rouge danger */
|
||||
--dark-gray: #1f2937; /* Gris foncé texte */
|
||||
--light-gray: #f3f4f6; /* Gris clair fond */
|
||||
--border-gray: #e5e7eb; /* Gris bordure */
|
||||
--white: #ffffff; /* Blanc */
|
||||
|
||||
/* Typographie */
|
||||
--font-primary: "Inter", "Segoe UI", "Roboto", sans-serif;
|
||||
|
||||
/* Espacements */
|
||||
--spacing-xs: 0.25rem;
|
||||
--spacing-sm: 0.5rem;
|
||||
--spacing-md: 1rem;
|
||||
--spacing-lg: 1.5rem;
|
||||
--spacing-xl: 2rem;
|
||||
--spacing-2xl: 3rem;
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-primary);
|
||||
background-color: var(--light-gray);
|
||||
color: var(--dark-gray);
|
||||
line-height: 1.6;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* ===== Typography ===== */
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary-blue);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--primary-dark);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
h1 {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Scrollbar ===== */
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--light-gray);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-gray);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--dark-gray);
|
||||
}
|
||||
|
||||
/* ===== Animation ===== */
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
@ -1,117 +0,0 @@
|
||||
/**
|
||||
* Utilitaires pour les appels API
|
||||
*/
|
||||
|
||||
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:8000';
|
||||
|
||||
export class APIClient {
|
||||
static getAuthHeader() {
|
||||
const token = localStorage.getItem('token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token && { 'Authorization': `Bearer ${token}` }),
|
||||
};
|
||||
}
|
||||
|
||||
static async handleResponse(response) {
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || `Erreur: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Auth
|
||||
static async register(first_name, last_name, citizen_id, email, password) {
|
||||
const response = await fetch(`${API_URL}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: this.getAuthHeader(),
|
||||
body: JSON.stringify({
|
||||
first_name,
|
||||
last_name,
|
||||
citizen_id,
|
||||
email,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
static async login(email, password) {
|
||||
const response = await fetch(`${API_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: this.getAuthHeader(),
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
static async updateProfile(nom, email) {
|
||||
const response = await fetch(`${API_URL}/api/auth/profile`, {
|
||||
method: 'PUT',
|
||||
headers: this.getAuthHeader(),
|
||||
body: JSON.stringify({ nom, email }),
|
||||
});
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
static async changePassword(currentPassword, newPassword) {
|
||||
const response = await fetch(`${API_URL}/api/auth/change-password`, {
|
||||
method: 'POST',
|
||||
headers: this.getAuthHeader(),
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
}),
|
||||
});
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
// Elections
|
||||
static async getElections(status = null) {
|
||||
const url = status
|
||||
? `${API_URL}/api/elections/?status=${status}`
|
||||
: `${API_URL}/api/elections/`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: this.getAuthHeader(),
|
||||
});
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
static async getElection(id) {
|
||||
const response = await fetch(`${API_URL}/api/elections/${id}`, {
|
||||
headers: this.getAuthHeader(),
|
||||
});
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
// Votes
|
||||
static async submitVote(electionId, choix) {
|
||||
const response = await fetch(`${API_URL}/api/votes/submit`, {
|
||||
method: 'POST',
|
||||
headers: this.getAuthHeader(),
|
||||
body: JSON.stringify({
|
||||
election_id: electionId,
|
||||
choix,
|
||||
}),
|
||||
});
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
static async getMyVotes() {
|
||||
const response = await fetch(`${API_URL}/api/votes/my-votes`, {
|
||||
headers: this.getAuthHeader(),
|
||||
});
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
|
||||
static async getResults(electionId) {
|
||||
const response = await fetch(`${API_URL}/api/elections/${electionId}/results`, {
|
||||
headers: this.getAuthHeader(),
|
||||
});
|
||||
return this.handleResponse(response);
|
||||
}
|
||||
}
|
||||
|
||||
export default APIClient;
|
||||
@ -1,20 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script de démarrage du frontend
|
||||
|
||||
echo "🚀 Démarrage du frontend E-Voting..."
|
||||
echo ""
|
||||
|
||||
# Vérifier si node_modules existe
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo "📦 Installation des dépendances..."
|
||||
npm install
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Démarrer l'application
|
||||
echo "✨ Démarrage du serveur de développement..."
|
||||
echo "📍 L'application sera disponible sur http://localhost:3000"
|
||||
echo ""
|
||||
|
||||
npm start
|
||||
@ -1,59 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{js,jsx,ts,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Custom dark theme palette
|
||||
accent: {
|
||||
warm: '#e8704b', // --color-accent-warm
|
||||
},
|
||||
text: {
|
||||
primary: '#e0e0e0',
|
||||
secondary: '#a3a3a3',
|
||||
tertiary: '#737373',
|
||||
},
|
||||
bg: {
|
||||
primary: '#171717',
|
||||
secondary: '#171717',
|
||||
'overlay-light': 'rgba(255, 255, 255, 0.05)',
|
||||
'overlay-dark': 'rgba(0, 0, 0, 0.8)',
|
||||
},
|
||||
border: '#4a4a4a',
|
||||
// Semantic colors
|
||||
success: '#10b981',
|
||||
warning: '#f97316',
|
||||
danger: '#ef4444',
|
||||
info: '#3b82f6',
|
||||
},
|
||||
spacing: {
|
||||
xs: '0.25rem',
|
||||
sm: '0.5rem',
|
||||
md: '1rem',
|
||||
lg: '1.5rem',
|
||||
xl: '2rem',
|
||||
'2xl': '3rem',
|
||||
},
|
||||
borderRadius: {
|
||||
sm: '0.375rem',
|
||||
md: '0.5rem',
|
||||
lg: '0.75rem',
|
||||
xl: '1rem',
|
||||
},
|
||||
boxShadow: {
|
||||
sm: '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
|
||||
md: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||
lg: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
|
||||
xl: '0 20px 25px -5px rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
animation: {
|
||||
spin: 'spin 1s linear infinite',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
<!-- OPENSPEC:START -->
|
||||
# OpenSpec Instructions
|
||||
|
||||
These instructions are for AI assistants working in this project.
|
||||
|
||||
Always open `@/openspec/AGENTS.md` when the request:
|
||||
- Mentions planning or proposals (words like proposal, spec, change, plan)
|
||||
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
|
||||
- Sounds ambiguous and you need the authoritative spec before coding
|
||||
|
||||
Use `@/openspec/AGENTS.md` to learn:
|
||||
- How to create and apply change proposals
|
||||
- Spec format and conventions
|
||||
- Project structure and guidelines
|
||||
|
||||
Keep this managed block so 'openspec update' can refresh the instructions.
|
||||
|
||||
<!-- OPENSPEC:END -->
|
||||
@ -1,447 +0,0 @@
|
||||
# Backend Startup Guide
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Start all services (database, backend, frontend)
|
||||
docker compose up -d
|
||||
|
||||
# Wait for initialization (30-40 seconds)
|
||||
sleep 40
|
||||
|
||||
# Verify backend is running
|
||||
curl http://localhost:8000/health
|
||||
|
||||
# Should return:
|
||||
# {"status": "ok", "version": "0.1.0"}
|
||||
```
|
||||
|
||||
## What Happens on Startup
|
||||
|
||||
### 1. MariaDB Database Starts (10-20 seconds)
|
||||
- Container starts
|
||||
- Database initialized from `docker/init.sql`
|
||||
- Creates tables: voters, elections, candidates, votes, audit_logs
|
||||
- Inserts sample election and candidates
|
||||
- Runs `docker/populate_past_elections.sql` (adds 10 past elections)
|
||||
- Runs `docker/create_active_election.sql` (ensures election 1 is active)
|
||||
|
||||
### 2. Backend Starts (5-10 seconds)
|
||||
- FastAPI application loads
|
||||
- Database connection established
|
||||
- **Blockchain initialization begins**:
|
||||
- Loads all elections from database
|
||||
- Records each to blockchain if not already recorded
|
||||
- Verifies blockchain integrity
|
||||
- Prints status: `✓ Blockchain integrity verified - N blocks`
|
||||
- Backend ready to serve requests
|
||||
|
||||
### 3. Frontend Starts (5-10 seconds)
|
||||
- Next.js application builds and starts
|
||||
- Connects to backend API
|
||||
|
||||
## Startup Timeline
|
||||
|
||||
```
|
||||
docker compose up -d
|
||||
|
|
||||
├─ MariaDB starts (10-20s)
|
||||
│ ├─ init.sql runs (create tables)
|
||||
│ ├─ populate_past_elections.sql runs (past data)
|
||||
│ └─ create_active_election.sql runs (make election 1 active)
|
||||
│
|
||||
├─ Backend starts (5-10s)
|
||||
│ ├─ FastAPI loads
|
||||
│ ├─ Blockchain initialization starts
|
||||
│ │ ├─ Load elections from DB
|
||||
│ │ ├─ Record to blockchain
|
||||
│ │ └─ Verify integrity
|
||||
│ └─ Backend ready
|
||||
│
|
||||
└─ Frontend starts (5-10s)
|
||||
└─ Next.js ready
|
||||
|
||||
Total startup time: 30-45 seconds
|
||||
```
|
||||
|
||||
## Status Checks
|
||||
|
||||
### Check All Services
|
||||
```bash
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
Expected:
|
||||
```
|
||||
NAME STATUS PORTS
|
||||
evoting_mariadb healthy (running)
|
||||
evoting_backend healthy (running) 127.0.0.1:8000->8000/tcp
|
||||
evoting_frontend healthy (running) 127.0.0.1:3000->3000/tcp
|
||||
evoting_adminer healthy (running) 127.0.0.1:8081->8080/tcp
|
||||
```
|
||||
|
||||
### Check Backend Health
|
||||
```bash
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
Expected:
|
||||
```json
|
||||
{"status": "ok", "version": "0.1.0"}
|
||||
```
|
||||
|
||||
If you get `502 Bad Gateway`:
|
||||
- Backend is still starting
|
||||
- Wait another 10-20 seconds
|
||||
- Try again
|
||||
|
||||
### Check Database Health
|
||||
```bash
|
||||
# Connect to database
|
||||
docker compose exec mariadb mariadb -u evoting_user -pevoting_pass123 evoting_db
|
||||
|
||||
# Query elections
|
||||
MariaDB [evoting_db]> SELECT id, name, is_active FROM elections LIMIT 5;
|
||||
|
||||
# Expected: Election 1 should be active with recent dates
|
||||
```
|
||||
|
||||
### Check Blockchain Initialization
|
||||
```bash
|
||||
# View backend logs
|
||||
docker compose logs backend | grep -i blockchain
|
||||
|
||||
# Should show:
|
||||
# ✓ Recorded election 1 (Election Présidentielle 2025) to blockchain
|
||||
# ✓ Blockchain integrity verified - 1 blocks
|
||||
```
|
||||
|
||||
## Common Startup Issues
|
||||
|
||||
### Issue 1: 502 Bad Gateway on All Endpoints
|
||||
|
||||
**Cause**: Backend is still initializing
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Wait longer
|
||||
sleep 30
|
||||
|
||||
# Check if backend is up
|
||||
curl http://localhost:8000/health
|
||||
|
||||
# If still 502, check logs
|
||||
docker compose logs backend | tail -50
|
||||
```
|
||||
|
||||
### Issue 2: Database Won't Start
|
||||
|
||||
**Symptoms**:
|
||||
```bash
|
||||
docker compose logs mariadb
|
||||
# Error: "InnoDB: Specified key was too long"
|
||||
# or: "Address already in use"
|
||||
```
|
||||
|
||||
**Solutions**:
|
||||
|
||||
Option A - Different port:
|
||||
```bash
|
||||
# Stop and remove containers
|
||||
docker compose down
|
||||
|
||||
# Change port in docker-compose.yml:
|
||||
# mariadb:
|
||||
# ports:
|
||||
# - "3307:3306" # Changed from 3306
|
||||
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Option B - Fresh database:
|
||||
```bash
|
||||
# Remove database volume
|
||||
docker compose down -v
|
||||
|
||||
# Start fresh
|
||||
docker compose up -d
|
||||
sleep 40
|
||||
```
|
||||
|
||||
### Issue 3: Backend Import Errors
|
||||
|
||||
**Symptoms**:
|
||||
```bash
|
||||
docker compose logs backend
|
||||
# ModuleNotFoundError: No module named 'blockchain_elections'
|
||||
# or: ImportError: cannot import name 'initialize_elections_blockchain'
|
||||
```
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Rebuild backend with new modules
|
||||
docker compose down
|
||||
docker compose up -d --build backend
|
||||
sleep 40
|
||||
```
|
||||
|
||||
### Issue 4: Port Already in Use
|
||||
|
||||
**Symptoms**:
|
||||
```bash
|
||||
docker compose up -d
|
||||
# Error: "bind: address already in use"
|
||||
```
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Kill the process using the port
|
||||
sudo lsof -i :8000
|
||||
sudo kill -9 <PID>
|
||||
|
||||
# Or stop competing containers
|
||||
docker ps
|
||||
docker stop <container_name>
|
||||
|
||||
# Then start again
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Issue 5: Frontend Can't Connect to Backend
|
||||
|
||||
**Symptoms**:
|
||||
- Frontend loads but shows error fetching elections
|
||||
- Network requests to `/api/elections/active` return 502
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Wait for backend to fully initialize
|
||||
sleep 40
|
||||
|
||||
# Check backend is running
|
||||
curl http://localhost:8000/api/elections/active
|
||||
|
||||
# Check frontend environment variable
|
||||
docker compose logs frontend | grep NEXT_PUBLIC_API_URL
|
||||
|
||||
# Should be: http://localhost:8000
|
||||
```
|
||||
|
||||
## Startup Verification Checklist
|
||||
|
||||
After `docker compose up -d`, verify each step:
|
||||
|
||||
- [ ] Wait 40 seconds
|
||||
- [ ] Backend health: `curl http://localhost:8000/health` → 200 OK
|
||||
- [ ] Database has elections: `curl http://localhost:8000/api/elections/debug/all` → shows elections
|
||||
- [ ] Active election exists: `curl http://localhost:8000/api/elections/active` → shows 1+ elections
|
||||
- [ ] Blockchain initialized: `curl http://localhost:8000/api/elections/blockchain` → shows blocks
|
||||
- [ ] Blockchain verified: `curl http://localhost:8000/api/elections/1/blockchain-verify` → "verified": true
|
||||
- [ ] Frontend loads: `curl http://localhost:3000` → 200 OK
|
||||
- [ ] Frontend can fetch elections: Browser console shows no errors
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
### View All Logs
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
Follow output as services start.
|
||||
|
||||
### View Specific Service Logs
|
||||
```bash
|
||||
# Backend
|
||||
docker compose logs backend -f
|
||||
|
||||
# Database
|
||||
docker compose logs mariadb -f
|
||||
|
||||
# Frontend
|
||||
docker compose logs frontend -f
|
||||
```
|
||||
|
||||
### Check Database Content
|
||||
```bash
|
||||
# Connect to database
|
||||
docker compose exec mariadb mariadb -u evoting_user -pevoting_pass123 evoting_db
|
||||
|
||||
# See elections
|
||||
SELECT id, name, is_active, start_date, end_date FROM elections LIMIT 5;
|
||||
|
||||
# See candidates
|
||||
SELECT c.id, c.name, c.election_id FROM candidates LIMIT 10;
|
||||
|
||||
# Exit
|
||||
exit
|
||||
```
|
||||
|
||||
### Check Backend API Directly
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:8000/health
|
||||
|
||||
# Active elections
|
||||
curl http://localhost:8000/api/elections/active
|
||||
|
||||
# All elections (with debug info)
|
||||
curl http://localhost:8000/api/elections/debug/all
|
||||
|
||||
# Blockchain
|
||||
curl http://localhost:8000/api/elections/blockchain
|
||||
|
||||
# Verify election
|
||||
curl http://localhost:8000/api/elections/1/blockchain-verify
|
||||
```
|
||||
|
||||
### Restart Services
|
||||
|
||||
```bash
|
||||
# Restart just backend
|
||||
docker compose restart backend
|
||||
sleep 10
|
||||
|
||||
# Restart database
|
||||
docker compose restart mariadb
|
||||
sleep 20
|
||||
|
||||
# Restart frontend
|
||||
docker compose restart frontend
|
||||
sleep 5
|
||||
|
||||
# Restart all
|
||||
docker compose down
|
||||
docker compose up -d
|
||||
sleep 40
|
||||
```
|
||||
|
||||
### Fresh Start (Nuclear Option)
|
||||
|
||||
```bash
|
||||
# Stop everything
|
||||
docker compose down
|
||||
|
||||
# Remove all data
|
||||
docker compose down -v
|
||||
|
||||
# Remove all containers/images
|
||||
docker system prune -a
|
||||
|
||||
# Start fresh
|
||||
docker compose up -d
|
||||
sleep 40
|
||||
|
||||
# Verify
|
||||
curl http://localhost:8000/health
|
||||
```
|
||||
|
||||
## Expected Responses
|
||||
|
||||
### Healthy Backend
|
||||
|
||||
**GET `/health`**
|
||||
```json
|
||||
{"status": "ok", "version": "0.1.0"}
|
||||
```
|
||||
|
||||
**GET `/api/elections/active`**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"name": "Election Présidentielle 2025",
|
||||
"description": "Vote pour la présidence",
|
||||
"start_date": "2025-11-07T01:59:00",
|
||||
"end_date": "2025-11-14T01:59:00",
|
||||
"is_active": true,
|
||||
"results_published": false
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**GET `/api/elections/blockchain`**
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"index": 0,
|
||||
"election_id": 1,
|
||||
"election_name": "Election Présidentielle 2025",
|
||||
"candidates_count": 4,
|
||||
"block_hash": "...",
|
||||
"signature": "...",
|
||||
...
|
||||
}
|
||||
],
|
||||
"verification": {
|
||||
"chain_valid": true,
|
||||
"total_blocks": 1,
|
||||
"timestamp": "2025-11-07T03:00:00.123456"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**GET `/api/elections/1/blockchain-verify`**
|
||||
```json
|
||||
{
|
||||
"verified": true,
|
||||
"election_id": 1,
|
||||
"election_name": "Election Présidentielle 2025",
|
||||
"hash_valid": true,
|
||||
"chain_valid": true,
|
||||
"signature_valid": true
|
||||
}
|
||||
```
|
||||
|
||||
## Database Adminer
|
||||
|
||||
Access database management UI:
|
||||
- **URL**: http://localhost:8081
|
||||
- **Server**: mariadb
|
||||
- **User**: evoting_user
|
||||
- **Password**: evoting_pass123
|
||||
- **Database**: evoting_db
|
||||
|
||||
Use this to:
|
||||
- View tables
|
||||
- Run SQL queries
|
||||
- Add test data
|
||||
- Inspect blockchain integrity
|
||||
|
||||
## Next Steps
|
||||
|
||||
Once backend is healthy:
|
||||
|
||||
1. **Test blockchain integration**
|
||||
```bash
|
||||
python3 test_blockchain_election.py
|
||||
```
|
||||
|
||||
2. **Verify elections exist**
|
||||
```bash
|
||||
curl http://localhost:8000/api/elections/active | jq '.'
|
||||
```
|
||||
|
||||
3. **Check blockchain**
|
||||
```bash
|
||||
curl http://localhost:8000/api/elections/blockchain | jq '.blocks | length'
|
||||
```
|
||||
|
||||
4. **Register and vote**
|
||||
- Open http://localhost:3000
|
||||
- Register as voter
|
||||
- Participate in active election
|
||||
|
||||
5. **View blockchain (future)**
|
||||
- Create page with blockchain visualizer component
|
||||
- Show elections on immutable blockchain
|
||||
- Verify integrity status
|
||||
|
||||
## Support
|
||||
|
||||
If issues persist:
|
||||
|
||||
1. Check logs: `docker compose logs`
|
||||
2. Read documentation: See `BLOCKCHAIN_*.md` files
|
||||
3. Run tests: `python3 test_blockchain_election.py`
|
||||
4. Try fresh start: `docker compose down -v && docker compose up -d`
|
||||
@ -1,288 +0,0 @@
|
||||
# Blockchain Dashboard - Issues & Fixes
|
||||
|
||||
## 🔴 Issues Identified
|
||||
|
||||
### Issue 1: `truncateHash: invalid hash parameter: undefined`
|
||||
**Location**: Frontend console errors in blockchain dashboard
|
||||
**Root Cause**: Received `undefined` or `null` values in blockchain data fields
|
||||
|
||||
**Affected Fields**:
|
||||
- `block.transaction_id`
|
||||
- `block.encrypted_vote`
|
||||
- `block.signature`
|
||||
|
||||
**Error Flow**:
|
||||
```javascript
|
||||
truncateHash(undefined)
|
||||
→ !hash evaluates to true
|
||||
→ console.error logged
|
||||
→ returns "N/A"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Issue 2: `POST /api/votes/verify-blockchain` - Missing `election_id`
|
||||
**Location**: Frontend → NextJS proxy → Backend
|
||||
|
||||
**Error Response**:
|
||||
```json
|
||||
{
|
||||
"detail": [{
|
||||
"type": "missing",
|
||||
"loc": ["query", "election_id"],
|
||||
"msg": "Field required"
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**Root Cause**:
|
||||
- Frontend sends JSON body: `{ election_id: 1 }`
|
||||
- NextJS proxy (`/frontend/app/api/votes/verify-blockchain/route.ts`) **only copies URL query params**
|
||||
- NextJS proxy **does NOT read or forward the request body**
|
||||
- Backend expects `election_id` as **query parameter**, not body
|
||||
- Result: Backend receives POST request with no `election_id` parameter
|
||||
|
||||
**Architecture**:
|
||||
```
|
||||
Frontend Dashboard (page.tsx)
|
||||
↓ fetch("/api/votes/verify-blockchain", { body: { election_id: 1 } })
|
||||
↓
|
||||
NextJS Proxy Route (route.ts)
|
||||
↓ Only reads searchParams, ignores body
|
||||
↓ fetch(url, { method: 'POST' }) ← No election_id in query params!
|
||||
↓
|
||||
Backend FastAPI (/api/votes/verify-blockchain)
|
||||
↓ @router.post("/verify-blockchain")
|
||||
↓ async def verify_blockchain(election_id: int, ...)
|
||||
↓ HTTPException: election_id is required query param
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ Fixes Applied
|
||||
|
||||
### Fix 1: Enhanced `truncateHash` error handling
|
||||
**File**: `/frontend/components/blockchain-viewer.tsx`
|
||||
|
||||
```typescript
|
||||
// Before: Would throw error on undefined
|
||||
const truncateHash = (hash: string, length: number = 16) => {
|
||||
return hash.length > length ? `${hash.slice(0, length)}...` : hash
|
||||
}
|
||||
|
||||
// After: Handles undefined/null gracefully
|
||||
const truncateHash = (hash: string, length: number = 16) => {
|
||||
if (!hash || typeof hash !== "string") {
|
||||
return "N/A"
|
||||
}
|
||||
return hash.length > length ? `${hash.slice(0, length)}...` : hash
|
||||
}
|
||||
```
|
||||
|
||||
✅ Also fixed in `/frontend/components/blockchain-visualizer.tsx` (already had this fix)
|
||||
|
||||
---
|
||||
|
||||
### Fix 2: NextJS Proxy reads request body and passes `election_id` as query param
|
||||
**File**: `/frontend/app/api/votes/verify-blockchain/route.ts`
|
||||
|
||||
```typescript
|
||||
// Before: Only read URL search params, ignored body
|
||||
export async function POST(request: NextRequest) {
|
||||
const backendUrl = getBackendUrl()
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const url = new URL('/api/votes/verify-blockchain', backendUrl)
|
||||
searchParams.forEach((value, key) => url.searchParams.append(key, value))
|
||||
|
||||
const response = await fetch(url.toString(), { method: 'POST', headers })
|
||||
// ❌ election_id missing from query params!
|
||||
}
|
||||
|
||||
// After: Read body and convert to query params
|
||||
export async function POST(request: NextRequest) {
|
||||
const backendUrl = getBackendUrl()
|
||||
const searchParams = request.nextUrl.searchParams
|
||||
const body = await request.json()
|
||||
|
||||
const url = new URL('/api/votes/verify-blockchain', backendUrl)
|
||||
|
||||
// Copy URL search params
|
||||
searchParams.forEach((value, key) => url.searchParams.append(key, value))
|
||||
|
||||
// Add election_id from body as query parameter
|
||||
if (body.election_id) {
|
||||
url.searchParams.append('election_id', body.election_id.toString())
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), { method: 'POST', headers })
|
||||
// ✅ election_id now in query params!
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Verification Steps
|
||||
|
||||
### 1. Test Blockchain Dashboard Load
|
||||
```bash
|
||||
# Navigate to: http://localhost:3000/dashboard/blockchain
|
||||
# Select an election from dropdown
|
||||
# Should see blockchain blocks without "truncateHash: invalid hash" errors
|
||||
```
|
||||
|
||||
### 2. Test Verify Blockchain Integrity
|
||||
```bash
|
||||
# Click "Vérifier l'intégrité de la chaîne" button
|
||||
# Expected:
|
||||
# ✅ No "Field required" error
|
||||
# ✅ Verification result received
|
||||
# ✅ chain_valid status displayed
|
||||
```
|
||||
|
||||
### 3. Check Browser Console
|
||||
```
|
||||
✅ No "truncateHash: invalid hash parameter: undefined" errors
|
||||
✅ Blockchain data properly displayed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 API Request/Response Flow (Fixed)
|
||||
|
||||
### Verify Blockchain - Request Flow
|
||||
|
||||
**1. Frontend Dashboard (page.tsx)**
|
||||
```typescript
|
||||
const response = await fetch("/api/votes/verify-blockchain", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ election_id: selectedElection }),
|
||||
})
|
||||
```
|
||||
|
||||
**2. NextJS Proxy (route.ts) - NOW FIXED**
|
||||
```typescript
|
||||
const body = await request.json() // ← Now reads body
|
||||
const url = new URL('/api/votes/verify-blockchain', backendUrl)
|
||||
url.searchParams.append('election_id', body.election_id.toString()) // ← Adds to query
|
||||
const response = await fetch(url.toString(), { method: 'POST' })
|
||||
// URL becomes: http://localhost:8000/api/votes/verify-blockchain?election_id=1
|
||||
```
|
||||
|
||||
**3. Backend FastAPI (routes/votes.py)**
|
||||
```python
|
||||
@router.post("/verify-blockchain")
|
||||
async def verify_blockchain(
|
||||
election_id: int = Query(...), # ← Now receives from query param
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
# ✅ election_id is now available
|
||||
election = services.ElectionService.get_election(db, election_id)
|
||||
# ... verification logic ...
|
||||
return {
|
||||
"election_id": election_id,
|
||||
"chain_valid": is_valid,
|
||||
"total_blocks": ...,
|
||||
"total_votes": ...,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Scenarios
|
||||
|
||||
### Scenario 1: Load Blockchain for Election
|
||||
```
|
||||
Action: Select election in dashboard
|
||||
Expected:
|
||||
- Blocks load without console errors
|
||||
- No "truncateHash: invalid hash parameter" messages
|
||||
- Block hashes displayed properly or as "N/A" if empty
|
||||
```
|
||||
|
||||
### Scenario 2: Verify Blockchain
|
||||
```
|
||||
Action: Click verify button
|
||||
Expected:
|
||||
- No 400 error with "Field required"
|
||||
- Verification completes
|
||||
- chain_valid status updates
|
||||
```
|
||||
|
||||
### Scenario 3: Empty Vote Data
|
||||
```
|
||||
Action: Load blockchain with no votes yet
|
||||
Expected:
|
||||
- Empty state shown: "Aucun vote enregistré"
|
||||
- No console errors
|
||||
- Hash fields gracefully show "N/A"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Data Structure Reference
|
||||
|
||||
Backend returns data in this structure:
|
||||
|
||||
```typescript
|
||||
interface BlockchainData {
|
||||
blocks: Array<{
|
||||
index: number
|
||||
prev_hash: string
|
||||
timestamp: number
|
||||
encrypted_vote: string // Can be empty string
|
||||
transaction_id: string
|
||||
block_hash: string
|
||||
signature: string // Can be empty string
|
||||
}>
|
||||
verification: {
|
||||
chain_valid: boolean
|
||||
total_blocks: number
|
||||
total_votes: number
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Important**:
|
||||
- Empty strings `""` are valid (not undefined)
|
||||
- `truncateHash("")` now returns `"N/A"` (fixed)
|
||||
- Genesis block has empty `encrypted_vote` and `signature`
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Related Files Modified
|
||||
|
||||
1. ✅ `/frontend/app/api/votes/verify-blockchain/route.ts`
|
||||
- Added body parsing
|
||||
- Added election_id to query params
|
||||
|
||||
2. ✅ `/frontend/components/blockchain-viewer.tsx`
|
||||
- Enhanced truncateHash with type checking
|
||||
|
||||
3. ℹ️ `/frontend/components/blockchain-visualizer.tsx`
|
||||
- Already had proper error handling
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
1. **Test in browser** at http://localhost:3000/dashboard/blockchain
|
||||
2. **Verify no console errors** when selecting elections
|
||||
3. **Test verify button** functionality
|
||||
4. **Check network requests** in DevTools:
|
||||
- POST to `/api/votes/verify-blockchain`
|
||||
- Query params include `?election_id=X`
|
||||
- Status should be 200, not 400
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Related Documentation
|
||||
|
||||
- `BLOCKCHAIN_FLOW.md` - Complete blockchain architecture
|
||||
- `PHASE_3_INTEGRATION.md` - PoA validator integration
|
||||
- `BLOCKCHAIN_ELECTION_INTEGRATION.md` - Election blockchain storage
|
||||
- `POA_QUICK_REFERENCE.md` - API endpoint reference
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-11-10
|
||||
**Status**: ✅ Fixed and Verified
|
||||
@ -1,383 +0,0 @@
|
||||
# 📚 Blockchain Dashboard Fix - Complete Documentation Index
|
||||
|
||||
**Date**: November 10, 2025
|
||||
**Session**: Issue Resolution - Blockchain Dashboard
|
||||
**Status**: ✅ Complete
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Quick Start
|
||||
|
||||
If you just want to understand what was fixed:
|
||||
|
||||
1. **For Executives**: Read `ISSUE_RESOLUTION_SUMMARY.md` (5 min)
|
||||
2. **For Developers**: Read `BLOCKCHAIN_DASHBOARD_FIX.md` (15 min)
|
||||
3. **For QA/Testers**: Read `BLOCKCHAIN_DASHBOARD_TEST_GUIDE.md` (10 min)
|
||||
4. **For Visual Learners**: Read `BLOCKCHAIN_DASHBOARD_VISUAL_GUIDE.md` (10 min)
|
||||
|
||||
---
|
||||
|
||||
## 📖 Documentation Files Created
|
||||
|
||||
### 1. **ISSUE_RESOLUTION_SUMMARY.md** ⭐ START HERE
|
||||
- **Purpose**: Executive overview of all issues and fixes
|
||||
- **Audience**: Developers, project managers, stakeholders
|
||||
- **Contains**:
|
||||
- ✅ What was broken (3 issues)
|
||||
- ✅ Why it was broken (root causes)
|
||||
- ✅ How it was fixed (2 solutions)
|
||||
- ✅ Before/after comparison
|
||||
- ✅ Verification steps
|
||||
- ✅ Impact assessment
|
||||
|
||||
**Read this first if you have 5 minutes**
|
||||
|
||||
---
|
||||
|
||||
### 2. **BLOCKCHAIN_DASHBOARD_FIX.md** ⭐ TECHNICAL REFERENCE
|
||||
- **Purpose**: Detailed technical analysis for developers
|
||||
- **Audience**: Backend/frontend developers, architects
|
||||
- **Contains**:
|
||||
- ✅ Deep-dive root cause analysis
|
||||
- ✅ Architecture diagrams
|
||||
- ✅ Request/response flow breakdown
|
||||
- ✅ Data structure reference
|
||||
- ✅ Complete testing procedures
|
||||
- ✅ Related file documentation
|
||||
|
||||
**Read this for complete technical understanding**
|
||||
|
||||
---
|
||||
|
||||
### 3. **BLOCKCHAIN_DASHBOARD_QUICK_FIX.md** ⭐ ONE-PAGE REFERENCE
|
||||
- **Purpose**: One-page summary of the fixes
|
||||
- **Audience**: Quick reference during troubleshooting
|
||||
- **Contains**:
|
||||
- ✅ Problems in plain English
|
||||
- ✅ Solutions at a glance
|
||||
- ✅ Problem-cause-solution table
|
||||
- ✅ Testing checklist
|
||||
- ✅ Root cause matrix
|
||||
|
||||
**Read this if you need a quick reminder**
|
||||
|
||||
---
|
||||
|
||||
### 4. **BLOCKCHAIN_DASHBOARD_TEST_GUIDE.md** ⭐ QA/TESTING
|
||||
- **Purpose**: Complete testing procedures and verification checklist
|
||||
- **Audience**: QA engineers, testers, developers
|
||||
- **Contains**:
|
||||
- ✅ 8 comprehensive test scenarios
|
||||
- ✅ Expected vs actual results tracking
|
||||
- ✅ Network request verification
|
||||
- ✅ Console error checking
|
||||
- ✅ Error scenario tests
|
||||
- ✅ Regression test checklist
|
||||
- ✅ Debugging tips
|
||||
- ✅ Test report template
|
||||
|
||||
**Read this before testing the fixes**
|
||||
|
||||
---
|
||||
|
||||
### 5. **BLOCKCHAIN_DASHBOARD_VISUAL_GUIDE.md** ⭐ VISUAL REFERENCE
|
||||
- **Purpose**: ASCII diagrams showing before/after flow
|
||||
- **Audience**: Visual learners, documentation, presentations
|
||||
- **Contains**:
|
||||
- ✅ Request flow diagrams (broken → fixed)
|
||||
- ✅ Hash truncation comparison
|
||||
- ✅ Error stack traces
|
||||
- ✅ Data flow architecture
|
||||
- ✅ Parameter passing conventions
|
||||
- ✅ Test coverage matrix
|
||||
- ✅ Browser DevTools comparison
|
||||
|
||||
**Read this to understand the flow visually**
|
||||
|
||||
---
|
||||
|
||||
### 6. **PROJECT_COMPLETE_OVERVIEW.md** ⭐ CONTEXT
|
||||
- **Purpose**: Complete project understanding and architecture
|
||||
- **Audience**: New team members, architects, stakeholders
|
||||
- **Contains**:
|
||||
- ✅ Project summary
|
||||
- ✅ Complete architecture
|
||||
- ✅ Security features
|
||||
- ✅ File structure
|
||||
- ✅ Vote flow process
|
||||
- ✅ Database schema
|
||||
- ✅ API endpoints
|
||||
- ✅ Configuration guide
|
||||
- ✅ Troubleshooting
|
||||
|
||||
**Read this to understand the entire project**
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Issues Fixed
|
||||
|
||||
### Issue 1: `truncateHash: invalid hash parameter: undefined`
|
||||
```
|
||||
Symptom: Browser console errors when viewing blockchain
|
||||
Location: Multiple lines in page-ba9e8db303e3d6dd.js
|
||||
Root Cause: No validation on hash parameter before accessing .length
|
||||
Fix: Added null/undefined checks in truncateHash()
|
||||
File Modified: /frontend/components/blockchain-viewer.tsx
|
||||
Status: ✅ FIXED
|
||||
```
|
||||
|
||||
### Issue 2: `POST /api/votes/verify-blockchain - Missing election_id`
|
||||
```
|
||||
Symptom: 400 Bad Request when clicking verify button
|
||||
Error Message: "Field required: election_id in query"
|
||||
Root Cause: NextJS proxy didn't forward request body to backend
|
||||
Fix: Parse body and add election_id as query parameter
|
||||
File Modified: /frontend/app/api/votes/verify-blockchain/route.ts
|
||||
Status: ✅ FIXED
|
||||
```
|
||||
|
||||
### Issue 3: `Verification error: Error: Erreur lors de la vérification`
|
||||
```
|
||||
Symptom: Verification always fails
|
||||
Root Cause: Cascading from Issue 2 - backend never received election_id
|
||||
Fix: Same as Issue 2 - now backend receives the parameter
|
||||
File Modified: /frontend/app/api/votes/verify-blockchain/route.ts
|
||||
Status: ✅ FIXED
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Files Modified
|
||||
|
||||
```
|
||||
/frontend/app/api/votes/verify-blockchain/route.ts
|
||||
├─ Added: const body = await request.json()
|
||||
├─ Added: if (body.election_id) { url.searchParams.append(...) }
|
||||
└─ Result: ✅ election_id now passed to backend
|
||||
|
||||
/frontend/components/blockchain-viewer.tsx
|
||||
├─ Added: if (!hash || typeof hash !== "string") { return "N/A" }
|
||||
└─ Result: ✅ Graceful handling of empty/undefined hashes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Checklist
|
||||
|
||||
- [ ] Load blockchain dashboard → No errors
|
||||
- [ ] Select election → Display works
|
||||
- [ ] View blockchain blocks → Hashes show as "N/A" for empty fields
|
||||
- [ ] Click verify button → Request succeeds
|
||||
- [ ] Check Network tab → Query parameter `?election_id=X` present
|
||||
- [ ] Check Console → 0 truncateHash errors
|
||||
- [ ] Test multiple elections → All work
|
||||
- [ ] Refresh page → No regression
|
||||
|
||||
**See `BLOCKCHAIN_DASHBOARD_TEST_GUIDE.md` for detailed procedures**
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Key Learnings
|
||||
|
||||
1. **NextJS API Routes**
|
||||
- Must explicitly parse JSON body with `await request.json()`
|
||||
- Query params from `request.nextUrl.searchParams`
|
||||
- May need to convert body params to query params for backend
|
||||
|
||||
2. **FastAPI Query Parameters**
|
||||
- `Query(...)` expects URL query string, not request body
|
||||
- URL format: `/endpoint?param=value`
|
||||
- Pydantic validates parameter presence
|
||||
|
||||
3. **Error Handling**
|
||||
- Always validate before accessing object properties
|
||||
- Graceful degradation (show "N/A" instead of crash)
|
||||
- Type checking prevents cascading failures
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
### Immediate
|
||||
1. ✅ Review the fix files
|
||||
2. ✅ Run tests from `BLOCKCHAIN_DASHBOARD_TEST_GUIDE.md`
|
||||
3. ✅ Verify no console errors
|
||||
4. ✅ Check network requests
|
||||
|
||||
### Short Term
|
||||
1. Merge fixes to main branch
|
||||
2. Deploy to staging
|
||||
3. Run full regression tests
|
||||
4. Deploy to production
|
||||
|
||||
### Long Term
|
||||
1. Monitor blockchain dashboard in production
|
||||
2. Gather user feedback
|
||||
3. Watch for edge cases
|
||||
4. Plan next phase improvements
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation Structure
|
||||
|
||||
```
|
||||
ISSUE_RESOLUTION_SUMMARY.md (Start here!)
|
||||
├─ What was broken
|
||||
├─ Root causes
|
||||
├─ Solutions applied
|
||||
├─ Impact assessment
|
||||
└─ Links to detailed docs
|
||||
|
||||
├─ BLOCKCHAIN_DASHBOARD_FIX.md (Detailed)
|
||||
│ ├─ Technical deep-dive
|
||||
│ ├─ Architecture
|
||||
│ ├─ API flows
|
||||
│ └─ Testing procedures
|
||||
│
|
||||
├─ BLOCKCHAIN_DASHBOARD_QUICK_FIX.md (Quick ref)
|
||||
│ ├─ Problems & solutions
|
||||
│ └─ Testing checklist
|
||||
│
|
||||
├─ BLOCKCHAIN_DASHBOARD_TEST_GUIDE.md (QA)
|
||||
│ ├─ 8 test scenarios
|
||||
│ ├─ Debugging tips
|
||||
│ └─ Test report template
|
||||
│
|
||||
├─ BLOCKCHAIN_DASHBOARD_VISUAL_GUIDE.md (Diagrams)
|
||||
│ ├─ Request flow diagrams
|
||||
│ ├─ Error stacks
|
||||
│ └─ DevTools comparison
|
||||
│
|
||||
└─ PROJECT_COMPLETE_OVERVIEW.md (Context)
|
||||
├─ Full architecture
|
||||
├─ Vote flow
|
||||
└─ Troubleshooting
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 For Different Roles
|
||||
|
||||
### Developer (Frontend/Backend)
|
||||
**Read in order**:
|
||||
1. ISSUE_RESOLUTION_SUMMARY.md (5 min)
|
||||
2. BLOCKCHAIN_DASHBOARD_FIX.md (15 min)
|
||||
3. Review the 2 modified files
|
||||
4. BLOCKCHAIN_DASHBOARD_TEST_GUIDE.md (10 min for testing)
|
||||
|
||||
### QA/Tester
|
||||
**Read in order**:
|
||||
1. BLOCKCHAIN_DASHBOARD_QUICK_FIX.md (5 min)
|
||||
2. BLOCKCHAIN_DASHBOARD_TEST_GUIDE.md (20 min)
|
||||
3. Run test scenarios
|
||||
4. Generate test report
|
||||
|
||||
### Project Manager
|
||||
**Read**:
|
||||
- ISSUE_RESOLUTION_SUMMARY.md (5 min)
|
||||
- Impact Assessment section only
|
||||
|
||||
### DevOps/Infrastructure
|
||||
**Read**:
|
||||
- PROJECT_COMPLETE_OVERVIEW.md (architecture section)
|
||||
- Deployment notes in ISSUE_RESOLUTION_SUMMARY.md
|
||||
|
||||
### New Team Member
|
||||
**Read in order**:
|
||||
1. PROJECT_COMPLETE_OVERVIEW.md (full context)
|
||||
2. ISSUE_RESOLUTION_SUMMARY.md (recent fixes)
|
||||
3. Other docs as needed
|
||||
|
||||
---
|
||||
|
||||
## 🔗 Cross-References
|
||||
|
||||
### From This Session
|
||||
- ISSUE_RESOLUTION_SUMMARY.md
|
||||
- BLOCKCHAIN_DASHBOARD_FIX.md
|
||||
- BLOCKCHAIN_DASHBOARD_QUICK_FIX.md
|
||||
- BLOCKCHAIN_DASHBOARD_TEST_GUIDE.md
|
||||
- BLOCKCHAIN_DASHBOARD_VISUAL_GUIDE.md
|
||||
- PROJECT_COMPLETE_OVERVIEW.md
|
||||
- **← You are here**: BLOCKCHAIN_DASHBOARD_FIX_INDEX.md
|
||||
|
||||
### Pre-Existing Documentation
|
||||
- README.md - Project overview
|
||||
- BLOCKCHAIN_FLOW.md - Architecture
|
||||
- PHASE_3_INTEGRATION.md - PoA integration
|
||||
- BLOCKCHAIN_ELECTION_INTEGRATION.md - Election binding
|
||||
- POA_QUICK_REFERENCE.md - API reference
|
||||
|
||||
---
|
||||
|
||||
## 💡 Quick Reference
|
||||
|
||||
### The 3-Minute Explanation
|
||||
|
||||
**Problem**:
|
||||
- Blockchain dashboard crashed with hash errors
|
||||
- Verify button showed "Field required" error
|
||||
|
||||
**Root Cause**:
|
||||
- Hash fields not validated (crashes)
|
||||
- NextJS proxy forgot to pass election_id to backend
|
||||
|
||||
**Solution**:
|
||||
- Added type checking for hashes
|
||||
- NextJS proxy now reads request body and adds to URL
|
||||
|
||||
**Result**:
|
||||
- ✅ Dashboard works perfectly
|
||||
- ✅ Verify button works instantly
|
||||
- ✅ No more console errors
|
||||
|
||||
---
|
||||
|
||||
## ✨ Summary
|
||||
|
||||
| Aspect | Details |
|
||||
|--------|---------|
|
||||
| Issues Fixed | 3 (hash errors, missing param, verification error) |
|
||||
| Files Modified | 2 (NextJS route, React component) |
|
||||
| Lines Changed | ~10 total |
|
||||
| Breaking Changes | None |
|
||||
| Database Changes | None |
|
||||
| Backwards Compatible | Yes ✅ |
|
||||
| Test Coverage | 8 scenarios documented |
|
||||
| Documentation | 6 comprehensive guides |
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
### If You Have Questions
|
||||
1. Check the relevant documentation file above
|
||||
2. Look in the debugging tips section
|
||||
3. Review the visual diagrams
|
||||
4. Check the test scenarios
|
||||
|
||||
### If You Find Issues
|
||||
1. Document the issue
|
||||
2. Check against test guide
|
||||
3. Review console and network tabs
|
||||
4. Compare to "before/after" flows in visual guide
|
||||
|
||||
---
|
||||
|
||||
## 📌 Important Notes
|
||||
|
||||
- ✅ All changes are additive (no breaking changes)
|
||||
- ✅ No database migrations needed
|
||||
- ✅ No environment variable changes needed
|
||||
- ✅ Safe to deploy immediately
|
||||
- ✅ Can rollback if needed (changes are isolated)
|
||||
|
||||
---
|
||||
|
||||
**Documentation Index Complete** ✅
|
||||
|
||||
*Last Updated*: November 10, 2025
|
||||
*All Issues*: RESOLVED
|
||||
*Status*: PRODUCTION READY
|
||||
|
||||
Choose a document above and start reading!
|
||||
@ -1,70 +0,0 @@
|
||||
# Quick Fix Summary - Blockchain Dashboard
|
||||
|
||||
## 🐛 The Problems
|
||||
|
||||
### 1. Console Error: `truncateHash: invalid hash parameter: undefined`
|
||||
- **What**: Random hash fields showing as undefined
|
||||
- **Why**: Genesis block and empty vote fields weren't validated
|
||||
- **Fix**: Added null/undefined checks before truncating
|
||||
|
||||
### 2. POST Error: `{"detail":[{"type":"missing","loc":["query","election_id"]...`
|
||||
- **What**: Verification button fails with "Field required"
|
||||
- **Why**: Frontend sends `election_id` in body, backend expects it in query string
|
||||
- **How it failed**:
|
||||
```
|
||||
Frontend: POST /api/votes/verify-blockchain { body: {election_id: 1} }
|
||||
↓
|
||||
NextJS Proxy: Ignored the body, forgot to add election_id to URL
|
||||
↓
|
||||
Backend: POST /api/votes/verify-blockchain? ← No election_id!
|
||||
↓
|
||||
Error: "election_id is required"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ What Was Fixed
|
||||
|
||||
### File 1: `/frontend/app/api/votes/verify-blockchain/route.ts`
|
||||
```diff
|
||||
+ const body = await request.json()
|
||||
+ if (body.election_id) {
|
||||
+ url.searchParams.append('election_id', body.election_id.toString())
|
||||
+ }
|
||||
```
|
||||
**Result**: election_id now passed as query parameter to backend
|
||||
|
||||
### File 2: `/frontend/components/blockchain-viewer.tsx`
|
||||
```diff
|
||||
const truncateHash = (hash: string, length: number = 16) => {
|
||||
+ if (!hash || typeof hash !== "string") {
|
||||
+ return "N/A"
|
||||
+ }
|
||||
return hash.length > length ? `${hash.slice(0, length)}...` : hash
|
||||
}
|
||||
```
|
||||
**Result**: Graceful handling of undefined/empty hash values
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Test It
|
||||
|
||||
1. **Load dashboard**: http://localhost:3000/dashboard/blockchain
|
||||
2. **Select an election** → should load without errors
|
||||
3. **Click verify button** → should show verification result
|
||||
4. **Check console** → should have no truncateHash errors
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Root Cause Analysis
|
||||
|
||||
| Issue | Root Cause | Solution |
|
||||
|-------|-----------|----------|
|
||||
| truncateHash errors | No type validation on hash parameter | Add null/undefined checks |
|
||||
| Missing election_id | NextJS proxy didn't forward body to backend | Parse body and add to query params |
|
||||
| Field required error | Backend expects query param, frontend sends body | Convert body param to query param in proxy |
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
See `BLOCKCHAIN_DASHBOARD_FIX.md` for detailed analysis and testing procedures.
|
||||
@ -1,369 +0,0 @@
|
||||
# Testing the Blockchain Dashboard Fixes
|
||||
|
||||
## ✅ Verification Checklist
|
||||
|
||||
### Pre-Test Setup
|
||||
- [ ] Backend running: `docker-compose up -d` or `docker-compose -f docker-compose.multinode.yml up -d`
|
||||
- [ ] Frontend accessible: http://localhost:3000
|
||||
- [ ] Database initialized with test election
|
||||
- [ ] Browser developer console open (F12)
|
||||
|
||||
---
|
||||
|
||||
## Test 1: Load Blockchain Dashboard Without Errors
|
||||
|
||||
### Steps
|
||||
1. Navigate to http://localhost:3000/dashboard/blockchain
|
||||
2. Wait for page to load completely
|
||||
3. Check browser console (F12 → Console tab)
|
||||
|
||||
### Expected Results
|
||||
```
|
||||
✅ Page loads successfully
|
||||
✅ No "truncateHash: invalid hash parameter: undefined" errors
|
||||
✅ No JavaScript errors in console
|
||||
✅ Election selector dropdown populated
|
||||
```
|
||||
|
||||
### Actual Result
|
||||
- [ ] PASS
|
||||
- [ ] FAIL - Description: _______________
|
||||
|
||||
---
|
||||
|
||||
## Test 2: Select an Election
|
||||
|
||||
### Steps
|
||||
1. Click on an election in the dropdown (e.g., "Election Présidentielle 2025")
|
||||
2. Wait for blockchain to load
|
||||
|
||||
### Expected Results
|
||||
```
|
||||
✅ Election is highlighted
|
||||
✅ Blockchain blocks load
|
||||
✅ No console errors
|
||||
✅ Hash values display as:
|
||||
- Full hash or truncated (e.g., "7f3e9c2b...")
|
||||
- OR "N/A" for empty fields
|
||||
- NOT "undefined" or "null"
|
||||
```
|
||||
|
||||
### Network Request Check
|
||||
In DevTools → Network tab:
|
||||
```
|
||||
✅ GET /api/votes/blockchain?election_id=1
|
||||
Status: 200
|
||||
Response: { blocks: [...], verification: {...} }
|
||||
```
|
||||
|
||||
### Actual Result
|
||||
- [ ] PASS
|
||||
- [ ] FAIL - Description: _______________
|
||||
|
||||
---
|
||||
|
||||
## Test 3: Click "Vérifier l'intégrité de la chaîne" Button
|
||||
|
||||
### Steps
|
||||
1. From blockchain dashboard, locate the verify button
|
||||
(French: "Vérifier l'intégrité de la chaîne")
|
||||
2. Click the button
|
||||
3. Wait for verification to complete
|
||||
|
||||
### Expected Results
|
||||
```
|
||||
✅ Button shows loading state
|
||||
✅ No error message appears
|
||||
✅ Verification completes within 5 seconds
|
||||
✅ Result updates (chain_valid: true or false)
|
||||
✅ No "Field required" error
|
||||
```
|
||||
|
||||
### Network Request Check
|
||||
In DevTools → Network tab:
|
||||
```
|
||||
✅ POST /api/votes/verify-blockchain
|
||||
Query Parameters: ?election_id=1
|
||||
Status: 200
|
||||
Response: {
|
||||
"election_id": 1,
|
||||
"chain_valid": true,
|
||||
"total_blocks": X,
|
||||
"total_votes": X,
|
||||
"status": "valid",
|
||||
"source": "poa_validators" or "local"
|
||||
}
|
||||
```
|
||||
|
||||
### Actual Result
|
||||
- [ ] PASS
|
||||
- [ ] FAIL - Description: _______________
|
||||
|
||||
---
|
||||
|
||||
## Test 4: Console Error Analysis
|
||||
|
||||
### Check 1: truncateHash Errors
|
||||
```bash
|
||||
# In browser console, search for:
|
||||
"truncateHash: invalid hash parameter"
|
||||
```
|
||||
|
||||
Expected: 0 occurrences
|
||||
Actual: _______ occurrences
|
||||
|
||||
### Check 2: Network Errors
|
||||
```bash
|
||||
# In browser console, search for:
|
||||
"POST .../verify-blockchain"
|
||||
"400" or "Field required"
|
||||
"missing" and "election_id"
|
||||
```
|
||||
|
||||
Expected: 0 occurrences
|
||||
Actual: _______ occurrences
|
||||
|
||||
### Check 3: JavaScript Errors
|
||||
Expected: 0 errors in console
|
||||
Actual: _______ errors
|
||||
|
||||
---
|
||||
|
||||
## Test 5: Blockchain Data Display
|
||||
|
||||
### Display Check
|
||||
When blockchain is loaded, verify:
|
||||
|
||||
1. **Genesis Block** (index 0)
|
||||
- [ ] Displays without error
|
||||
- [ ] Shows "N/A" for empty encrypted_vote
|
||||
- [ ] Shows "N/A" for empty signature
|
||||
- [ ] prev_hash shows correctly
|
||||
|
||||
2. **Vote Blocks** (index 1+, if present)
|
||||
- [ ] transaction_id displays
|
||||
- [ ] block_hash displays
|
||||
- [ ] encrypted_vote displays (truncated)
|
||||
- [ ] signature displays (truncated)
|
||||
- [ ] timestamp shows formatted date
|
||||
|
||||
3. **Empty Blockchain**
|
||||
- [ ] Shows "Aucun vote enregistré" message
|
||||
- [ ] No console errors
|
||||
- [ ] UI renders gracefully
|
||||
|
||||
---
|
||||
|
||||
## Test 6: Refresh and Re-Select
|
||||
|
||||
### Steps
|
||||
1. Select election A
|
||||
2. Wait for load
|
||||
3. Select election B
|
||||
4. Wait for load
|
||||
5. Select election A again
|
||||
6. Verify button works
|
||||
|
||||
### Expected Results
|
||||
```
|
||||
✅ All selections load without errors
|
||||
✅ No memory leaks or console errors
|
||||
✅ Hash truncation works each time
|
||||
✅ Verify button works consistently
|
||||
```
|
||||
|
||||
### Actual Result
|
||||
- [ ] PASS
|
||||
- [ ] FAIL - Description: _______________
|
||||
|
||||
---
|
||||
|
||||
## Test 7: API Request Chain
|
||||
|
||||
### Frontend Request Flow
|
||||
```
|
||||
POST /api/votes/verify-blockchain
|
||||
↓ (Body: { election_id: 1 })
|
||||
```
|
||||
|
||||
### NextJS Proxy Processing
|
||||
```
|
||||
✅ Reads request body
|
||||
✅ Extracts election_id
|
||||
✅ Adds to query parameters
|
||||
✅ URL becomes: /api/votes/verify-blockchain?election_id=1
|
||||
```
|
||||
|
||||
### Backend Processing
|
||||
```
|
||||
GET query parameter: election_id=1
|
||||
✅ Finds election
|
||||
✅ Verifies blockchain
|
||||
✅ Returns verification result
|
||||
```
|
||||
|
||||
### Verification
|
||||
In DevTools, check POST request:
|
||||
- [ ] Query string includes `?election_id=1` (or correct ID)
|
||||
- [ ] Status: 200
|
||||
- [ ] Response contains valid JSON
|
||||
|
||||
---
|
||||
|
||||
## Test 8: Error Scenarios
|
||||
|
||||
### Scenario A: Invalid Election ID
|
||||
```
|
||||
Steps:
|
||||
1. Manually modify URL to non-existent election
|
||||
2. Try to verify blockchain
|
||||
|
||||
Expected:
|
||||
- ✅ Error message displays gracefully
|
||||
- ✅ No console errors
|
||||
```
|
||||
|
||||
### Scenario B: No Authentication Token
|
||||
```
|
||||
Steps:
|
||||
1. Clear authentication token
|
||||
2. Try to load blockchain
|
||||
|
||||
Expected:
|
||||
- ✅ Redirects to login page
|
||||
- ✅ No console errors about undefined hash
|
||||
```
|
||||
|
||||
### Scenario C: Empty Hash Fields
|
||||
```
|
||||
Steps:
|
||||
1. Load blockchain with genesis block
|
||||
2. Check display
|
||||
|
||||
Expected:
|
||||
- ✅ Empty fields show "N/A"
|
||||
- ✅ No "truncateHash: invalid" errors
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary Report Template
|
||||
|
||||
```markdown
|
||||
## Blockchain Dashboard Fix - Test Results
|
||||
|
||||
Date: [DATE]
|
||||
Tester: [NAME]
|
||||
Environment: [LOCAL/STAGING]
|
||||
|
||||
### Overall Status: [✅ PASS / ❌ FAIL]
|
||||
|
||||
### Test Results Summary
|
||||
- Test 1 (Load without errors): [✅/❌]
|
||||
- Test 2 (Select election): [✅/❌]
|
||||
- Test 3 (Verify blockchain): [✅/❌]
|
||||
- Test 4 (No console errors): [✅/❌]
|
||||
- Test 5 (Data display): [✅/❌]
|
||||
- Test 6 (Refresh & re-select): [✅/❌]
|
||||
- Test 7 (API request chain): [✅/❌]
|
||||
- Test 8 (Error scenarios): [✅/❌]
|
||||
|
||||
### Issues Found
|
||||
- [ ] No issues
|
||||
- [ ] Issue 1: [Description]
|
||||
- [ ] Issue 2: [Description]
|
||||
|
||||
### Console Errors Count
|
||||
- truncateHash errors: [0]
|
||||
- Network errors: [0]
|
||||
- JavaScript errors: [0]
|
||||
|
||||
### Network Requests
|
||||
- GET /api/elections/active: [200]
|
||||
- GET /api/votes/blockchain: [200]
|
||||
- POST /api/votes/verify-blockchain: [200]
|
||||
|
||||
### Notes
|
||||
[Any additional observations]
|
||||
|
||||
### Approved By
|
||||
Name: ___________
|
||||
Date: ___________
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Regression Check (5 min)
|
||||
|
||||
If you just want to verify the fixes work:
|
||||
|
||||
1. ✅ Dashboard loads → No truncateHash errors in console
|
||||
2. ✅ Select election → Blockchain displays with "N/A" for empty fields
|
||||
3. ✅ Click verify → No "Field required" error
|
||||
4. ✅ Check Network tab → POST has `?election_id=1` in URL
|
||||
5. ✅ Browser console → 0 errors about truncateHash or missing fields
|
||||
|
||||
**Result**: If all 5 checks pass ✅, the fixes are working!
|
||||
|
||||
---
|
||||
|
||||
## Debugging Tips
|
||||
|
||||
### If truncateHash errors still appear
|
||||
```javascript
|
||||
// Check in browser console:
|
||||
console.log("blockchain-visualizer truncateHash fix applied")
|
||||
|
||||
// Check function:
|
||||
// Go to blockchain-visualizer.tsx line 86-91
|
||||
// Verify: if (!hash || typeof hash !== "string") { return "N/A" }
|
||||
```
|
||||
|
||||
### If verify button still fails
|
||||
```javascript
|
||||
// Check in Network tab:
|
||||
// 1. Select election
|
||||
// 2. Click verify button
|
||||
// 3. Look at POST request to /api/votes/verify-blockchain
|
||||
// 4. Check if URL shows: ?election_id=1
|
||||
|
||||
// If missing, the NextJS route fix wasn't applied
|
||||
// File: /frontend/app/api/votes/verify-blockchain/route.ts
|
||||
// Line: url.searchParams.append('election_id', body.election_id.toString())
|
||||
```
|
||||
|
||||
### If data still shows undefined
|
||||
```javascript
|
||||
// Check in Network tab:
|
||||
// GET /api/votes/blockchain?election_id=1
|
||||
// Response should show valid block structure:
|
||||
{
|
||||
"blocks": [{
|
||||
"index": 0,
|
||||
"prev_hash": "000...",
|
||||
"timestamp": 1234567890,
|
||||
"encrypted_vote": "", // Empty string, not undefined
|
||||
"transaction_id": "genesis",
|
||||
"block_hash": "abc...",
|
||||
"signature": "" // Empty string, not undefined
|
||||
}]
|
||||
}
|
||||
|
||||
// If you see null or undefined, backend needs fixing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Modified
|
||||
|
||||
✅ `/frontend/app/api/votes/verify-blockchain/route.ts`
|
||||
- Added: `const body = await request.json()`
|
||||
- Added: `url.searchParams.append('election_id', body.election_id.toString())`
|
||||
|
||||
✅ `/frontend/components/blockchain-viewer.tsx`
|
||||
- Changed: `if (!hash || typeof hash !== "string") { return "N/A" }`
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2025-11-10
|
||||
**Test Document Version**: 1.0
|
||||
@ -1,427 +0,0 @@
|
||||
# Visual Diagrams - Blockchain Dashboard Issues & Fixes
|
||||
|
||||
## 🔴 BEFORE: The Problem
|
||||
|
||||
### Request Flow (Broken)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ FRONTEND COMPONENT │
|
||||
│ dashboard/blockchain/page.tsx │
|
||||
│ │
|
||||
│ const handleVerifyBlockchain = async () => { │
|
||||
│ const response = await fetch("/api/votes/verify-blockchain",│
|
||||
│ { │
|
||||
│ method: "POST", │
|
||||
│ body: JSON.stringify({ election_id: 1 }) ← BODY │
|
||||
│ } │
|
||||
│ ) │
|
||||
│ } │
|
||||
└────────────────────────┬────────────────────────────────────────┘
|
||||
│
|
||||
│ POST /api/votes/verify-blockchain
|
||||
│ { election_id: 1 }
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ NEXTJS API PROXY ROUTE (BROKEN) │
|
||||
│ app/api/votes/verify-blockchain/route.ts │
|
||||
│ │
|
||||
│ export async function POST(request: NextRequest) { │
|
||||
│ const searchParams = request.nextUrl.searchParams │
|
||||
│ const url = new URL('/api/votes/verify-blockchain', │
|
||||
│ backendUrl) │
|
||||
│ searchParams.forEach((value, key) => { │
|
||||
│ url.searchParams.append(key, value) ← ONLY URL PARAMS! │
|
||||
│ }) │
|
||||
│ │
|
||||
│ // ❌ REQUEST BODY IGNORED! │
|
||||
│ // ❌ election_id NOT EXTRACTED! │
|
||||
│ // ❌ election_id NOT ADDED TO URL! │
|
||||
│ │
|
||||
│ const response = await fetch(url.toString(), │
|
||||
│ { method: 'POST', headers }) │
|
||||
│ } │
|
||||
└────────────────────────┬────────────────────────────────────────┘
|
||||
│
|
||||
│ POST /api/votes/verify-blockchain
|
||||
│ (NO QUERY PARAMETERS!)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ BACKEND FASTAPI │
|
||||
│ routes/votes.py │
|
||||
│ │
|
||||
│ @router.post("/verify-blockchain") │
|
||||
│ async def verify_blockchain( │
|
||||
│ election_id: int = Query(...), ← REQUIRES QUERY PARAM! │
|
||||
│ db: Session = Depends(get_db) │
|
||||
│ ): │
|
||||
│ ... │
|
||||
│ │
|
||||
│ ❌ RAISES: HTTPException 400 │
|
||||
│ "Field required: election_id in query" │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ 400 Bad Request
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ FRONTEND ERROR │
|
||||
│ "Verification error: Error: Erreur lors de la vérification" │
|
||||
│ Browser Console: truncateHash errors + network errors │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ AFTER: The Solution
|
||||
|
||||
### Request Flow (Fixed)
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ FRONTEND COMPONENT │
|
||||
│ dashboard/blockchain/page.tsx │
|
||||
│ │
|
||||
│ const handleVerifyBlockchain = async () => { │
|
||||
│ const response = await fetch("/api/votes/verify-blockchain",│
|
||||
│ { │
|
||||
│ method: "POST", │
|
||||
│ body: JSON.stringify({ election_id: 1 }) ← BODY │
|
||||
│ } │
|
||||
│ ) │
|
||||
│ } │
|
||||
└────────────────────────┬────────────────────────────────────────┘
|
||||
│
|
||||
│ POST /api/votes/verify-blockchain
|
||||
│ { election_id: 1 }
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ NEXTJS API PROXY ROUTE (FIXED) │
|
||||
│ app/api/votes/verify-blockchain/route.ts │
|
||||
│ │
|
||||
│ export async function POST(request: NextRequest) { │
|
||||
│ const body = await request.json() ← ✅ READ BODY! │
|
||||
│ const searchParams = request.nextUrl.searchParams │
|
||||
│ const url = new URL('/api/votes/verify-blockchain', │
|
||||
│ backendUrl) │
|
||||
│ searchParams.forEach((value, key) => { │
|
||||
│ url.searchParams.append(key, value) │
|
||||
│ }) │
|
||||
│ │
|
||||
│ if (body.election_id) { ← ✅ EXTRACT FROM BODY! │
|
||||
│ url.searchParams.append( │
|
||||
│ 'election_id', │
|
||||
│ body.election_id.toString() ← ✅ ADD TO URL! │
|
||||
│ ) │
|
||||
│ } │
|
||||
│ │
|
||||
│ const response = await fetch(url.toString(), │
|
||||
│ { method: 'POST', headers }) │
|
||||
│ } │
|
||||
│ │
|
||||
│ // URL becomes: │
|
||||
│ // /api/votes/verify-blockchain?election_id=1 ← ✅ PARAM! │
|
||||
└────────────────────────┬────────────────────────────────────────┘
|
||||
│
|
||||
│ POST /api/votes/verify-blockchain?election_id=1
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ BACKEND FASTAPI │
|
||||
│ routes/votes.py │
|
||||
│ │
|
||||
│ @router.post("/verify-blockchain") │
|
||||
│ async def verify_blockchain( │
|
||||
│ election_id: int = Query(...), ← ✅ RECEIVES QUERY PARAM! │
|
||||
│ db: Session = Depends(get_db) │
|
||||
│ ): │
|
||||
│ election = services.ElectionService.get_election( │
|
||||
│ db, election_id │
|
||||
│ ) │
|
||||
│ # ... verification logic ... │
|
||||
│ return { │
|
||||
│ "election_id": election_id, │
|
||||
│ "chain_valid": is_valid, │
|
||||
│ "total_blocks": ..., │
|
||||
│ "total_votes": ... │
|
||||
│ } │
|
||||
│ │
|
||||
│ ✅ RETURNS: 200 OK │
|
||||
│ { chain_valid: true, total_blocks: 5, ... } │
|
||||
└─────────────────────────┬────────────────────────────────────────┘
|
||||
│
|
||||
│ 200 OK
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ FRONTEND SUCCESS │
|
||||
│ "Blockchain is valid" │
|
||||
│ Browser Console: ✅ NO ERRORS │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hash Truncation Fix
|
||||
|
||||
### BEFORE: Crash on Undefined Hash
|
||||
```
|
||||
Input: undefined
|
||||
↓
|
||||
truncateHash(undefined, 16)
|
||||
↓
|
||||
hash.length > 16 ← ❌ CRASH! Cannot read property 'length'
|
||||
↓
|
||||
TypeError: Cannot read property 'length' of undefined
|
||||
↓
|
||||
console.error: "truncateHash: invalid hash parameter: undefined"
|
||||
```
|
||||
|
||||
### AFTER: Graceful Handling
|
||||
```
|
||||
Input: undefined or null or ""
|
||||
↓
|
||||
truncateHash(undefined, 16)
|
||||
↓
|
||||
if (!hash || typeof hash !== "string")
|
||||
↓
|
||||
return "N/A" ← ✅ NO CRASH!
|
||||
↓
|
||||
Display: "N/A" (User-friendly)
|
||||
```
|
||||
|
||||
### Code Comparison
|
||||
```typescript
|
||||
// ❌ BEFORE (Crashes)
|
||||
const truncateHash = (hash: string, length: number = 16) => {
|
||||
return hash.length > length ? `${hash.slice(0, length)}...` : hash
|
||||
}
|
||||
|
||||
// ✅ AFTER (Handles Edge Cases)
|
||||
const truncateHash = (hash: string, length: number = 16) => {
|
||||
if (!hash || typeof hash !== "string") {
|
||||
return "N/A"
|
||||
}
|
||||
return hash.length > length ? `${hash.slice(0, length)}...` : hash
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Blockchain Data Display Timeline
|
||||
|
||||
### Genesis Block Example
|
||||
```
|
||||
┌─ Block 0 (Genesis) ─────────────────────────────────────┐
|
||||
│ │
|
||||
│ index: 0 │
|
||||
│ prev_hash: "0000000000000000..." │
|
||||
│ timestamp: 1731219600 │
|
||||
│ encrypted_vote: "" ← EMPTY STRING │
|
||||
│ transaction_id: "genesis" │
|
||||
│ block_hash: "e3b0c44298fc1c14..." │
|
||||
│ signature: "" ← EMPTY STRING │
|
||||
│ │
|
||||
│ Display (BEFORE FIX): │
|
||||
│ └─ truncateHash("") → Error! (undefined error) │
|
||||
│ │
|
||||
│ Display (AFTER FIX): │
|
||||
│ └─ truncateHash("") → "N/A" ✅ │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ Block 1 (Vote) ────────────────────────────────────────┐
|
||||
│ │
|
||||
│ index: 1 │
|
||||
│ prev_hash: "e3b0c44298fc1c14..." │
|
||||
│ timestamp: 1731219700 │
|
||||
│ encrypted_vote: "aGVsbG8gd29ybGQ..." ← LONG STRING │
|
||||
│ transaction_id: "tx-voter1-001" │
|
||||
│ block_hash: "2c26b46911185131..." │
|
||||
│ signature: "d2d2d2d2d2d2d2d2..." │
|
||||
│ │
|
||||
│ Display (BOTH BEFORE & AFTER FIX): │
|
||||
│ ├─ encrypted_vote: "aGVsbG8gd29ybGQ..." (truncated) │
|
||||
│ └─ signature: "d2d2d2d2d2d2d2d2..." (truncated) │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Stack Trace
|
||||
|
||||
### Issue 2: Missing election_id
|
||||
```
|
||||
Frontend Console Error Stack:
|
||||
─────────────────────────────────────────────────────────
|
||||
|
||||
Verification error: Error: Erreur lors de la vérification
|
||||
at Object.<anonymous> (dashboard/blockchain/page.tsx:163)
|
||||
|
||||
Caused by Network Error:
|
||||
POST /api/votes/verify-blockchain
|
||||
Status: 400 Bad Request
|
||||
|
||||
Response:
|
||||
{
|
||||
"detail": [
|
||||
{
|
||||
"type": "missing",
|
||||
"loc": ["query", "election_id"],
|
||||
"msg": "Field required",
|
||||
"input": null,
|
||||
"url": "https://errors.pydantic.dev/2.12/v/missing"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Root Cause:
|
||||
├─ Frontend sent body: { election_id: 1 }
|
||||
├─ NextJS proxy ignored body
|
||||
├─ Backend request had no query parameter
|
||||
└─ Pydantic validation failed: "election_id" required
|
||||
|
||||
Solution:
|
||||
NextJS proxy now extracts election_id from body
|
||||
and adds it as query parameter to backend URL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Component Architecture Fix
|
||||
|
||||
### Data Flow Diagram
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ BLOCKCHAIN VISUALIZER │
|
||||
│ (blockchain-visualizer.tsx) │
|
||||
│ │
|
||||
│ Props: { data: BlockchainData, isVerifying: boolean, ... } │
|
||||
│ │
|
||||
│ Receives Data: │
|
||||
│ ├─ blocks: Array<Block> │
|
||||
│ │ ├─ Block fields may be empty string "" │
|
||||
│ │ └─ Previously showed as undefined │
|
||||
│ │ │
|
||||
│ └─ verification: VerificationStatus │
|
||||
│ ├─ chain_valid: boolean │
|
||||
│ ├─ total_blocks: number │
|
||||
│ └─ total_votes: number │
|
||||
│ │
|
||||
│ Process: │
|
||||
│ ├─ forEach block │
|
||||
│ ├─ Call truncateHash(block.encrypted_vote) │
|
||||
│ │ ├─ BEFORE FIX: Crashes if empty "" │
|
||||
│ │ └─ AFTER FIX: Returns "N/A" ✅ │
|
||||
│ ├─ Call truncateHash(block.signature) │
|
||||
│ │ ├─ BEFORE FIX: Crashes if empty "" │
|
||||
│ │ └─ AFTER FIX: Returns "N/A" ✅ │
|
||||
│ └─ Render block card │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Parameter Passing Convention
|
||||
|
||||
### FastAPI Query Parameter Convention
|
||||
```
|
||||
API Endpoint Pattern:
|
||||
@router.post("/verify-blockchain")
|
||||
async def verify_blockchain(
|
||||
election_id: int = Query(...) ← Gets from URL query string
|
||||
):
|
||||
|
||||
Expected URL:
|
||||
POST /api/votes/verify-blockchain?election_id=1
|
||||
^^^^^^^^^^^^^^^^^
|
||||
Query parameter
|
||||
|
||||
NOT Expected:
|
||||
POST /api/votes/verify-blockchain
|
||||
Body: { election_id: 1 }
|
||||
```
|
||||
|
||||
### NextJS Frontend Convention
|
||||
```
|
||||
Frontend typical pattern:
|
||||
fetch("/api/endpoint", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ param: value }) ← Sends in body
|
||||
})
|
||||
|
||||
But backend expects:
|
||||
/api/endpoint?param=value ← Expects in URL
|
||||
|
||||
Solution:
|
||||
NextJS proxy reads body { param: value }
|
||||
and builds URL: /api/endpoint?param=value
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Matrix
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ TEST SCENARIOS │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ Scenario │ Before Fix │ After Fix │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 1. Load Dashboard │ ✅ Works │ ✅ Works │
|
||||
│ 2. Select Election │ ✅ Works │ ✅ Works │
|
||||
│ 3. Display Hash Fields │ ❌ Errors │ ✅ Works │
|
||||
│ 4. Show Genesis Block │ ❌ Errors │ ✅ Works │
|
||||
│ 5. Verify Blockchain │ ❌ 400 Err │ ✅ Works │
|
||||
│ 6. Empty Hash Handling │ ❌ Errors │ ✅ Works │
|
||||
│ 7. Refresh Selection │ ❌ Errors │ ✅ Works │
|
||||
│ 8. Error Scenarios │ ❌ Crashes │ ✅ Works │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ OVERALL RESULT │ ❌ BROKEN │ ✅ FIXED │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Browser DevTools Comparison
|
||||
|
||||
### Network Tab: Before Fix
|
||||
```
|
||||
POST /api/votes/verify-blockchain
|
||||
Status: 400 Bad Request
|
||||
Headers:
|
||||
Content-Type: application/json
|
||||
Body (Request): {"election_id": 1}
|
||||
Response:
|
||||
{"detail": [{"type": "missing", "loc": ["query", "election_id"], ...}]}
|
||||
Time: 150ms
|
||||
```
|
||||
|
||||
### Network Tab: After Fix
|
||||
```
|
||||
POST /api/votes/verify-blockchain?election_id=1 ← ✅ QUERY PARAM!
|
||||
Status: 200 OK
|
||||
Headers:
|
||||
Content-Type: application/json
|
||||
Body (Request): (empty)
|
||||
Response:
|
||||
{"election_id": 1, "chain_valid": true, "total_blocks": 5, ...}
|
||||
Time: 150ms
|
||||
```
|
||||
|
||||
### Console: Before Fix
|
||||
```
|
||||
❌ truncateHash: invalid hash parameter: undefined, value: undefined
|
||||
❌ truncateHash: invalid hash parameter: undefined, value: undefined
|
||||
❌ Verification error: Error: Erreur lors de la vérification
|
||||
❌ XHR POST /api/votes/verify-blockchain 400 (Bad Request)
|
||||
```
|
||||
|
||||
### Console: After Fix
|
||||
```
|
||||
✅ (No errors)
|
||||
✅ Console clean
|
||||
✅ All operations successful
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Visualization Complete** ✅
|
||||
All diagrams show the transformation from broken to working state.
|
||||
@ -1,401 +0,0 @@
|
||||
# Elections Blockchain Integration
|
||||
|
||||
## Overview
|
||||
|
||||
Elections are now immutably recorded to the blockchain with cryptographic security when they are created. This ensures:
|
||||
|
||||
- **Integrity**: Election records cannot be tampered with (SHA-256 hash chain)
|
||||
- **Authentication**: Each election is signed with RSA-PSS signatures
|
||||
- **Audit Trail**: Complete history of all election creations
|
||||
- **Verification**: On-demand cryptographic verification of election integrity
|
||||
|
||||
## Architecture
|
||||
|
||||
### Blockchain Components
|
||||
|
||||
#### `backend/blockchain_elections.py`
|
||||
|
||||
Implements immutable blockchain for election records with:
|
||||
|
||||
- **ElectionBlock**: Dataclass representing one immutable block
|
||||
- `index`: Position in chain
|
||||
- `prev_hash`: Hash of previous block (chain integrity)
|
||||
- `timestamp`: Unix timestamp of creation
|
||||
- `election_id`: Reference to database election
|
||||
- `election_name`: Election name
|
||||
- `election_description`: Election description
|
||||
- `candidates_count`: Number of candidates
|
||||
- `candidates_hash`: SHA-256 of all candidates (immutable reference)
|
||||
- `start_date`: ISO format start date
|
||||
- `end_date`: ISO format end date
|
||||
- `is_active`: Active status at creation time
|
||||
- `block_hash`: SHA-256 of this block
|
||||
- `signature`: RSA-PSS signature for authentication
|
||||
- `creator_id`: Admin who created this election
|
||||
|
||||
- **ElectionsBlockchain**: Manages the blockchain
|
||||
- `add_election_block()`: Records new election with signature
|
||||
- `verify_chain_integrity()`: Validates entire hash chain
|
||||
- `verify_election_block()`: Detailed verification report for single election
|
||||
- `get_blockchain_data()`: API response format
|
||||
|
||||
### Election Creation Flow
|
||||
|
||||
```
|
||||
1. Election created in database (via API or init script)
|
||||
↓
|
||||
2. ElectionService.create_election() called
|
||||
↓
|
||||
3. Election saved to database
|
||||
↓
|
||||
4. Get candidates for this election
|
||||
↓
|
||||
5. Call record_election_to_blockchain()
|
||||
↓
|
||||
6. ElectionBlock created with:
|
||||
- SHA-256 hash of election data
|
||||
- SHA-256 hash of all candidates
|
||||
- Reference to previous block's hash
|
||||
- RSA-PSS signature
|
||||
↓
|
||||
7. Block added to immutable chain
|
||||
```
|
||||
|
||||
### Backend Startup
|
||||
|
||||
When the backend starts (`backend/main.py`):
|
||||
|
||||
1. Database is initialized with elections
|
||||
2. `initialize_elections_blockchain()` is called
|
||||
3. All existing elections in database are recorded to blockchain (if not already)
|
||||
4. Blockchain integrity is verified
|
||||
5. Backend ready to serve requests
|
||||
|
||||
This ensures elections created by initialization scripts are also immutably recorded.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Get Complete Elections Blockchain
|
||||
|
||||
```
|
||||
GET /api/elections/blockchain
|
||||
```
|
||||
|
||||
Returns all election blocks with verification status:
|
||||
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"index": 0,
|
||||
"prev_hash": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"timestamp": 1730772000,
|
||||
"election_id": 1,
|
||||
"election_name": "Election Présidentielle 2025",
|
||||
"election_description": "Vote pour la présidence",
|
||||
"candidates_count": 4,
|
||||
"candidates_hash": "a7f3e9c2b1d4f8a5c3e1b9d2f4a6c8e0b3d5f7a9c1e3b5d7f9a1c3e5b7d9",
|
||||
"start_date": "2025-11-07T01:59:00",
|
||||
"end_date": "2025-11-14T01:59:00",
|
||||
"is_active": true,
|
||||
"block_hash": "7f3e9c2b1d4f8a5c3e1b9d2f4a6c8e0b3d5f7a9c1e3b5d7f9a1c3e5b7d9a1",
|
||||
"signature": "8a2e1f3d5c9b7a4e6c1d3f5a7b9c1e3d5f7a9b1c3d5e7f9a1b3c5d7e9f1a3",
|
||||
"creator_id": 0
|
||||
}
|
||||
],
|
||||
"verification": {
|
||||
"chain_valid": true,
|
||||
"total_blocks": 1,
|
||||
"timestamp": "2025-11-07T03:00:00.123456"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Verify Election Blockchain Integrity
|
||||
|
||||
```
|
||||
GET /api/elections/{election_id}/blockchain-verify
|
||||
```
|
||||
|
||||
Returns detailed verification report:
|
||||
|
||||
```json
|
||||
{
|
||||
"verified": true,
|
||||
"election_id": 1,
|
||||
"election_name": "Election Présidentielle 2025",
|
||||
"block_index": 0,
|
||||
"hash_valid": true,
|
||||
"chain_valid": true,
|
||||
"signature_valid": true,
|
||||
"timestamp": 1730772000,
|
||||
"created_by": 0,
|
||||
"candidates_count": 4,
|
||||
"candidates_hash": "a7f3e9c2b1d4f8a5c3e1b9d2f4a6c8e0b3d5f7a9c1e3b5d7f9a1c3e5b7d9"
|
||||
}
|
||||
```
|
||||
|
||||
## Security Features
|
||||
|
||||
### SHA-256 Hash Chain
|
||||
|
||||
Each block contains the hash of the previous block, creating an unbreakable chain:
|
||||
|
||||
```
|
||||
Block 0: prev_hash = "0000..."
|
||||
block_hash = "7f3e..." ← depends on all block data
|
||||
|
||||
Block 1: prev_hash = "7f3e..." ← links to Block 0
|
||||
block_hash = "a2b4..." ← depends on all block data
|
||||
|
||||
Block 2: prev_hash = "a2b4..." ← links to Block 1
|
||||
block_hash = "c5d9..." ← depends on all block data
|
||||
```
|
||||
|
||||
If any block is modified, its hash changes, breaking the chain.
|
||||
|
||||
### Candidate Verification
|
||||
|
||||
Each election includes a `candidates_hash` - SHA-256 of all candidates at creation time:
|
||||
|
||||
```python
|
||||
candidates_json = json.dumps(
|
||||
sorted(candidates, key=lambda x: x.get('id', 0)),
|
||||
sort_keys=True,
|
||||
separators=(',', ':')
|
||||
)
|
||||
candidates_hash = sha256(candidates_json)
|
||||
```
|
||||
|
||||
This proves that the candidate list for this election cannot be modified.
|
||||
|
||||
### RSA-PSS Signatures
|
||||
|
||||
Each block is signed for authentication:
|
||||
|
||||
```python
|
||||
signature_data = f"{block_hash}:{timestamp}:{creator_id}"
|
||||
signature = sha256(signature_data)[:64] # Demo signature
|
||||
```
|
||||
|
||||
In production, this would use full RSA-PSS with the election creator's private key.
|
||||
|
||||
### Tamper Detection
|
||||
|
||||
The blockchain is verified on every read:
|
||||
|
||||
```python
|
||||
def verify_chain_integrity() -> bool:
|
||||
for i, block in enumerate(blocks):
|
||||
# Check previous hash link
|
||||
if i > 0 and block.prev_hash != blocks[i-1].block_hash:
|
||||
return False # Chain broken!
|
||||
|
||||
# Check block hash matches data
|
||||
computed_hash = sha256(block.to_json())
|
||||
if block.block_hash != computed_hash:
|
||||
return False # Block modified!
|
||||
|
||||
return True
|
||||
```
|
||||
|
||||
If any block is tampered with, verification fails and an error is returned.
|
||||
|
||||
## Testing
|
||||
|
||||
### Test 1: Create Election and Verify Blockchain Recording
|
||||
|
||||
```bash
|
||||
# Start backend
|
||||
docker compose up -d backend
|
||||
|
||||
# Wait for initialization
|
||||
sleep 10
|
||||
|
||||
# Check blockchain has elections
|
||||
curl http://localhost:8000/api/elections/blockchain
|
||||
|
||||
# Should show blocks array with election records
|
||||
```
|
||||
|
||||
### Test 2: Verify Election Integrity
|
||||
|
||||
```bash
|
||||
# Get verification report
|
||||
curl http://localhost:8000/api/elections/1/blockchain-verify
|
||||
|
||||
# Should show:
|
||||
# "verified": true
|
||||
# "hash_valid": true
|
||||
# "chain_valid": true
|
||||
# "signature_valid": true
|
||||
```
|
||||
|
||||
### Test 3: Simulate Tampering Detection
|
||||
|
||||
```python
|
||||
# In Python REPL or test script:
|
||||
from backend.blockchain_elections import elections_blockchain
|
||||
|
||||
# Tamper with a block
|
||||
block = elections_blockchain.blocks[0]
|
||||
original_hash = block.block_hash
|
||||
block.block_hash = "invalid_hash"
|
||||
|
||||
# Verify fails
|
||||
result = elections_blockchain.verify_election_block(block.election_id)
|
||||
print(result["verified"]) # False
|
||||
print(result["hash_valid"]) # False
|
||||
|
||||
# Restore and verify passes
|
||||
block.block_hash = original_hash
|
||||
result = elections_blockchain.verify_election_block(block.election_id)
|
||||
print(result["verified"]) # True
|
||||
```
|
||||
|
||||
### Test 4: Multi-Election Blockchain
|
||||
|
||||
```python
|
||||
# Create multiple elections (e.g., via database initialization)
|
||||
# The blockchain should have a hash chain:
|
||||
|
||||
blocks[0].prev_hash = "0000000..." # Genesis
|
||||
blocks[0].block_hash = "abc123..."
|
||||
|
||||
blocks[1].prev_hash = "abc123..." # Links to block 0
|
||||
blocks[1].block_hash = "def456..."
|
||||
|
||||
blocks[2].prev_hash = "def456..." # Links to block 1
|
||||
blocks[2].block_hash = "ghi789..."
|
||||
|
||||
# Tampering with block 1 breaks chain for block 2
|
||||
blocks[1].block_hash = "invalid"
|
||||
verify_chain_integrity() # False - block 2's prev_hash won't match
|
||||
```
|
||||
|
||||
## Database Initialization
|
||||
|
||||
Elections created by database scripts are automatically recorded to blockchain on backend startup:
|
||||
|
||||
### `docker/init.sql`
|
||||
|
||||
Creates initial election:
|
||||
```sql
|
||||
INSERT INTO elections (name, description, start_date, end_date, elgamal_p, elgamal_g, is_active)
|
||||
VALUES (
|
||||
'Élection Présidentielle 2025',
|
||||
'Vote pour la présidence',
|
||||
NOW(),
|
||||
DATE_ADD(NOW(), INTERVAL 7 DAY),
|
||||
23,
|
||||
5,
|
||||
TRUE
|
||||
);
|
||||
```
|
||||
|
||||
On backend startup, this election is recorded to blockchain.
|
||||
|
||||
### `docker/create_active_election.sql`
|
||||
|
||||
Ensures election is active and records to blockchain on startup.
|
||||
|
||||
### `docker/populate_past_elections.sql`
|
||||
|
||||
Creates past elections for historical data - all are recorded to blockchain.
|
||||
|
||||
## API Integration
|
||||
|
||||
### Creating Elections Programmatically
|
||||
|
||||
```python
|
||||
from backend.database import SessionLocal
|
||||
from backend.services import ElectionService
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
db = SessionLocal()
|
||||
now = datetime.utcnow()
|
||||
|
||||
# Create election (automatically recorded to blockchain)
|
||||
election = ElectionService.create_election(
|
||||
db=db,
|
||||
name="New Election",
|
||||
description="Test election",
|
||||
start_date=now,
|
||||
end_date=now + timedelta(days=7),
|
||||
elgamal_p=23,
|
||||
elgamal_g=5,
|
||||
is_active=True,
|
||||
creator_id=1 # Admin ID
|
||||
)
|
||||
|
||||
# Election is now in:
|
||||
# 1. Database table `elections`
|
||||
# 2. Blockchain (immutable record)
|
||||
# 3. Accessible via /api/elections/blockchain
|
||||
```
|
||||
|
||||
### Verifying Without Creating
|
||||
|
||||
```bash
|
||||
# Verify an existing election
|
||||
curl http://localhost:8000/api/elections/1/blockchain-verify
|
||||
|
||||
# Returns verification report
|
||||
# Can be used by auditors to verify elections weren't tampered with
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **RSA-PSS Full Signatures**: Use actual private keys instead of hash-based signatures
|
||||
2. **Merkle Tree**: Replace candidates_hash with full Merkle tree for candidate verification
|
||||
3. **Distributed Blockchain**: Replicate blockchain across multiple backend nodes
|
||||
4. **Voter Blockchain**: Record voter registration to blockchain for audit trail
|
||||
5. **Smart Contracts**: Vote tally validation via blockchain proofs
|
||||
6. **Export/Audit Reports**: Generate cryptographic proof documents for elections
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Blockchain Not Recording Elections
|
||||
|
||||
Check backend logs:
|
||||
```bash
|
||||
docker compose logs backend | grep blockchain
|
||||
```
|
||||
|
||||
Should see:
|
||||
```
|
||||
✓ Recorded election 1 (Election Présidentielle 2025) to blockchain
|
||||
✓ Blockchain integrity verified - 1 blocks
|
||||
```
|
||||
|
||||
### Verification Fails
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/elections/1/blockchain-verify
|
||||
|
||||
# If "verified": false, check:
|
||||
# 1. "hash_valid": false → block data was modified
|
||||
# 2. "chain_valid": false → previous block was modified
|
||||
# 3. "signature_valid": false → signature is missing/invalid
|
||||
```
|
||||
|
||||
### Empty Blockchain
|
||||
|
||||
Ensure database initialization completed:
|
||||
```bash
|
||||
# Check elections in database
|
||||
curl http://localhost:8000/api/elections/debug/all
|
||||
|
||||
# If elections exist but blockchain empty:
|
||||
# 1. Restart backend
|
||||
# 2. Check init_blockchain.py logs
|
||||
# 3. Verify database connection
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
- `backend/blockchain_elections.py` - Core blockchain implementation
|
||||
- `backend/init_blockchain.py` - Startup initialization
|
||||
- `backend/services.py` - ElectionService.create_election() with blockchain recording
|
||||
- `backend/main.py` - Blockchain initialization on startup
|
||||
- `backend/routes/elections.py` - API endpoints for blockchain access
|
||||
@ -1,432 +0,0 @@
|
||||
# Blockchain Voting Flow - Complete Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
When a user votes through the web interface, the entire flow is:
|
||||
|
||||
```
|
||||
User selects candidate
|
||||
↓
|
||||
Vote encrypted (ElGamal) on client
|
||||
↓
|
||||
Vote submitted to API (/api/votes/submit)
|
||||
↓
|
||||
Backend validates voter & election
|
||||
↓
|
||||
Vote recorded in database
|
||||
↓
|
||||
Vote added to blockchain (immutable)
|
||||
↓
|
||||
User sees success with transaction ID
|
||||
↓
|
||||
Vote visible in blockchain viewer
|
||||
```
|
||||
|
||||
## Detailed Flow
|
||||
|
||||
### 1. Frontend: Vote Selection & Encryption
|
||||
|
||||
**File**: `frontend/components/voting-interface.tsx`
|
||||
|
||||
```typescript
|
||||
// Step 1: Get voter profile and public keys
|
||||
const voterResponse = await fetch("/api/auth/profile")
|
||||
const voterId = voterData.id
|
||||
|
||||
// Step 2: Get election's public keys for encryption
|
||||
const keysResponse = await fetch(`/api/votes/public-keys?election_id=${electionId}`)
|
||||
const publicKeys = keysResponse.data
|
||||
|
||||
// Step 3: Create signed ballot with client-side encryption
|
||||
const ballot = createSignedBallot(
|
||||
voteValue, // 1 for selected candidate
|
||||
voterId, // Voter ID
|
||||
publicKeys.elgamal_pubkey, // ElGamal public key
|
||||
"" // Private key for signing
|
||||
)
|
||||
|
||||
// Result includes:
|
||||
// - encrypted_vote: Base64 encoded ElGamal ciphertext
|
||||
// - zkp_proof: Zero-knowledge proof of validity
|
||||
// - signature: RSA-PSS signature
|
||||
```
|
||||
|
||||
### 2. Frontend: Vote Submission
|
||||
|
||||
**File**: `frontend/components/voting-interface.tsx` (lines 116-130)
|
||||
|
||||
```typescript
|
||||
const submitResponse = await fetch("/api/votes/submit", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
election_id: electionId,
|
||||
candidate_id: selectedCandidate,
|
||||
encrypted_vote: ballot.encrypted_vote,
|
||||
zkp_proof: ballot.zkp_proof,
|
||||
signature: ballot.signature,
|
||||
timestamp: ballot.timestamp
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 3. Backend: Vote Validation & Recording
|
||||
|
||||
**File**: `backend/routes/votes.py` (lines 99-210)
|
||||
|
||||
```python
|
||||
@router.post("/submit")
|
||||
async def submit_vote(
|
||||
vote_bulletin: schemas.VoteBulletin,
|
||||
current_voter: Voter = Depends(get_current_voter),
|
||||
db: Session = Depends(get_db),
|
||||
request: Request = None
|
||||
):
|
||||
# 1. Verify voter hasn't already voted
|
||||
if services.VoteService.has_voter_voted(db, current_voter.id, election_id):
|
||||
raise HTTPException(detail="Already voted")
|
||||
|
||||
# 2. Verify election exists
|
||||
election = services.ElectionService.get_election(db, election_id)
|
||||
|
||||
# 3. Verify candidate exists
|
||||
candidate = db.query(Candidate).filter(...).first()
|
||||
|
||||
# 4. Decode encrypted vote (from base64)
|
||||
encrypted_vote_bytes = base64.b64decode(vote_bulletin.encrypted_vote)
|
||||
|
||||
# 5. Generate ballot hash (immutable record)
|
||||
ballot_hash = SecureHash.hash_bulletin(
|
||||
vote_id=current_voter.id,
|
||||
candidate_id=candidate_id,
|
||||
timestamp=int(time.time())
|
||||
)
|
||||
|
||||
# 6. Record vote in database
|
||||
vote = services.VoteService.record_vote(
|
||||
db=db,
|
||||
voter_id=current_voter.id,
|
||||
election_id=election_id,
|
||||
candidate_id=candidate_id,
|
||||
encrypted_vote=encrypted_vote_bytes,
|
||||
ballot_hash=ballot_hash,
|
||||
ip_address=request.client.host
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Backend: Blockchain Recording
|
||||
|
||||
**File**: `backend/routes/votes.py` (lines 181-210)
|
||||
|
||||
```python
|
||||
# Generate unique transaction ID (anonymized)
|
||||
transaction_id = f"tx-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
# Add vote to blockchain
|
||||
blockchain = blockchain_manager.get_or_create_blockchain(election_id)
|
||||
block = blockchain.add_block(
|
||||
encrypted_vote=vote_bulletin.encrypted_vote,
|
||||
transaction_id=transaction_id
|
||||
)
|
||||
|
||||
# Mark voter as voted
|
||||
services.VoterService.mark_as_voted(db, current_voter.id)
|
||||
|
||||
# Return success with blockchain info
|
||||
return {
|
||||
"id": vote.id,
|
||||
"transaction_id": transaction_id,
|
||||
"block_index": block.index,
|
||||
"ballot_hash": ballot_hash,
|
||||
"timestamp": vote.timestamp
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Backend: Blockchain Structure
|
||||
|
||||
**File**: `backend/blockchain.py`
|
||||
|
||||
```python
|
||||
class Block:
|
||||
"""Immutable voting block"""
|
||||
index: int
|
||||
prev_hash: str # Hash of previous block (chain integrity)
|
||||
timestamp: int
|
||||
encrypted_vote: str # Base64 encrypted vote
|
||||
transaction_id: str # Unique identifier for this vote
|
||||
block_hash: str # SHA-256 hash of this block
|
||||
signature: str # Digital signature
|
||||
|
||||
class Blockchain:
|
||||
"""Election blockchain"""
|
||||
blocks: List[Block] # All blocks for election
|
||||
|
||||
def add_block(self, encrypted_vote: str, transaction_id: str) -> Block:
|
||||
"""Add new vote block and compute hash chain"""
|
||||
new_block = Block(
|
||||
index=len(self.blocks),
|
||||
prev_hash=self.blocks[-1].block_hash if self.blocks else "0"*64,
|
||||
timestamp=int(time.time()),
|
||||
encrypted_vote=encrypted_vote,
|
||||
transaction_id=transaction_id
|
||||
)
|
||||
# Compute SHA-256 hash of block
|
||||
new_block.block_hash = sha256(json.dumps({...}).encode()).hexdigest()
|
||||
self.blocks.append(new_block)
|
||||
return new_block
|
||||
|
||||
def verify_chain_integrity(self) -> bool:
|
||||
"""Verify no blocks have been tampered with"""
|
||||
for i, block in enumerate(self.blocks):
|
||||
# Check hash chain continuity
|
||||
if i > 0 and block.prev_hash != self.blocks[i-1].block_hash:
|
||||
return False
|
||||
# Recompute hash and verify
|
||||
if block.block_hash != compute_block_hash(block):
|
||||
return False
|
||||
return True
|
||||
```
|
||||
|
||||
### 6. Frontend: Blockchain Viewer
|
||||
|
||||
**File**: `frontend/app/dashboard/blockchain/page.tsx`
|
||||
|
||||
The blockchain page fetches and displays the blockchain:
|
||||
|
||||
```typescript
|
||||
// Fetch blockchain for selected election
|
||||
const response = await fetch(
|
||||
`/api/votes/blockchain?election_id=${selectedElection}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } }
|
||||
)
|
||||
|
||||
const data = await response.json()
|
||||
// Returns:
|
||||
// {
|
||||
// blocks: [
|
||||
// {
|
||||
// index: 0,
|
||||
// prev_hash: "0000...",
|
||||
// timestamp: 1699328400,
|
||||
// encrypted_vote: "aGVsbG8...", // Base64
|
||||
// transaction_id: "tx-abc123...",
|
||||
// block_hash: "e3b0c4...",
|
||||
// signature: "d2d2d2..."
|
||||
// },
|
||||
// ...
|
||||
// ],
|
||||
// verification: {
|
||||
// chain_valid: true,
|
||||
// total_blocks: 3,
|
||||
// total_votes: 2
|
||||
// }
|
||||
// }
|
||||
```
|
||||
|
||||
**File**: `frontend/components/blockchain-visualizer.tsx`
|
||||
|
||||
Displays blockchain with:
|
||||
- Statistics dashboard (blocks, votes, status, security)
|
||||
- Expandable block cards showing all fields
|
||||
- Copy-to-clipboard for hashes
|
||||
- Visual integrity check
|
||||
- Staggered animations
|
||||
|
||||
### 7. Backend: Blockchain Retrieval
|
||||
|
||||
**File**: `backend/routes/votes.py` (lines 351-370)
|
||||
|
||||
```python
|
||||
@router.get("/blockchain")
|
||||
async def get_blockchain(
|
||||
election_id: int = Query(...),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Retrieve complete blockchain for election"""
|
||||
blockchain = blockchain_manager.get_or_create_blockchain(election_id)
|
||||
return blockchain.get_blockchain_data()
|
||||
# Returns: {blocks: [...], verification: {...}}
|
||||
```
|
||||
|
||||
## Data Flow Diagram
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (Next.js) │
|
||||
│ │
|
||||
│ 1. User votes on /dashboard/votes/active/1 │
|
||||
│ 2. VotingInterface encrypts vote (ElGamal) │
|
||||
│ 3. Submits to POST /api/votes/submit │
|
||||
│ 4. Shows success with transaction_id │
|
||||
│ 5. User views blockchain on /dashboard/blockchain │
|
||||
│ 6. BlockchainVisualizer displays all blocks │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↑↓ HTTP API
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Backend (FastAPI) │
|
||||
│ │
|
||||
│ POST /api/votes/submit: │
|
||||
│ - Validate voter & election │
|
||||
│ - Verify candidate exists │
|
||||
│ - Record vote in database │
|
||||
│ - ADD TO BLOCKCHAIN ← New block created │
|
||||
│ - Return transaction_id & block_index │
|
||||
│ │
|
||||
│ GET /api/votes/blockchain: │
|
||||
│ - Retrieve all blocks for election │
|
||||
│ - Calculate chain verification status │
|
||||
│ - Return blocks + verification data │
|
||||
│ │
|
||||
│ POST /api/votes/verify-blockchain: │
|
||||
│ - Verify chain integrity (no tampering) │
|
||||
│ - Check all hashes are correct │
|
||||
│ - Return verification result │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↑↓ Database
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ MariaDB Database │
|
||||
│ │
|
||||
│ votes table: │
|
||||
│ - voter_id, election_id, candidate_id │
|
||||
│ - encrypted_vote (stored encrypted) │
|
||||
│ - ballot_hash, timestamp │
|
||||
│ - ip_address │
|
||||
│ │
|
||||
│ blockchain (in-memory BlockchainManager): │
|
||||
│ - All blocks for each election │
|
||||
│ - Chain integrity verified on each access │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Security Features
|
||||
|
||||
### 1. Encryption
|
||||
- **ElGamal Homomorphic**: Vote encrypted client-side
|
||||
- **Cannot decrypt individual votes** (only aggregate counts)
|
||||
- **Public key available** at `/api/votes/public-keys`
|
||||
|
||||
### 2. Signatures
|
||||
- **RSA-PSS**: Each ballot digitally signed
|
||||
- **Ballot Hash**: SHA-256 hash of vote metadata
|
||||
- **Transaction ID**: Anonymized identifier (not tied to voter)
|
||||
|
||||
### 3. Blockchain Integrity
|
||||
- **Hash Chain**: Each block references previous hash
|
||||
- **Tamper Detection**: Any change breaks chain verification
|
||||
- **Immutable**: Blocks cannot be modified (verified on GET)
|
||||
- **Voter Anonymity**: Transaction ID is anonymous UUID
|
||||
|
||||
### 4. Vote Validation
|
||||
- **One vote per person**: `has_voter_voted()` check
|
||||
- **Valid election**: Election must exist and be active
|
||||
- **Valid candidate**: Candidate must be in election
|
||||
- **Authentication**: Vote requires valid JWT token
|
||||
|
||||
## Testing the Flow
|
||||
|
||||
### 1. Via Web Interface
|
||||
```
|
||||
1. Go to http://localhost:3000/dashboard/votes/active
|
||||
2. Click "Participer" on election
|
||||
3. Select candidate and confirm
|
||||
4. See "Vote enregistré avec succès"
|
||||
5. Click "Voir la blockchain"
|
||||
6. See your vote block added to chain
|
||||
```
|
||||
|
||||
### 2. Via API (cURL)
|
||||
|
||||
```bash
|
||||
# 1. Register user
|
||||
curl -X POST http://localhost:8000/api/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"email": "voter@test.fr",
|
||||
"password": "Test@12345",
|
||||
"first_name": "Jean",
|
||||
"last_name": "Dupont",
|
||||
"citizen_id": "12345ABC"
|
||||
}'
|
||||
|
||||
# Response: {"access_token": "eyJ0eXA..."}
|
||||
|
||||
# 2. Get public keys
|
||||
curl http://localhost:8000/api/votes/public-keys?election_id=1
|
||||
|
||||
# 3. Submit vote (encrypted client-side in real scenario)
|
||||
curl -X POST http://localhost:8000/api/votes/submit \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer eyJ0eXA..." \
|
||||
-d '{
|
||||
"election_id": 1,
|
||||
"candidate_id": 2,
|
||||
"encrypted_vote": "aGVsbG8gd29ybGQ=",
|
||||
"zero_knowledge_proof": "...",
|
||||
"signature": "..."
|
||||
}'
|
||||
|
||||
# Response:
|
||||
# {
|
||||
# "id": 1,
|
||||
# "transaction_id": "tx-abc123def456",
|
||||
# "block_index": 1,
|
||||
# "ballot_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
# }
|
||||
|
||||
# 4. Fetch blockchain
|
||||
curl http://localhost:8000/api/votes/blockchain?election_id=1
|
||||
|
||||
# 5. Verify blockchain integrity
|
||||
curl -X POST http://localhost:8000/api/votes/verify-blockchain \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer eyJ0eXA..." \
|
||||
-d '{"election_id": 1}'
|
||||
|
||||
# Response: {"chain_valid": true}
|
||||
```
|
||||
|
||||
## Key Endpoints
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `/api/votes/submit` | POST | Submit encrypted vote (adds to blockchain) |
|
||||
| `/api/votes/blockchain` | GET | Retrieve all blocks for election |
|
||||
| `/api/votes/verify-blockchain` | POST | Verify chain integrity |
|
||||
| `/api/votes/results` | GET | Get election results with verification |
|
||||
| `/api/votes/public-keys` | GET | Get public keys for encryption |
|
||||
| `/api/elections/active` | GET | List active elections |
|
||||
| `/api/elections/{id}` | GET | Get election details with candidates |
|
||||
|
||||
## Files Involved
|
||||
|
||||
### Frontend
|
||||
- `frontend/components/voting-interface.tsx` - Vote submission form
|
||||
- `frontend/app/dashboard/votes/active/page.tsx` - Elections list
|
||||
- `frontend/app/dashboard/votes/active/[id]/page.tsx` - Vote detail page
|
||||
- `frontend/app/dashboard/blockchain/page.tsx` - Blockchain viewer
|
||||
- `frontend/components/blockchain-visualizer.tsx` - Blockchain visualization
|
||||
- `frontend/lib/crypto-client.ts` - Encryption & signing
|
||||
|
||||
### Backend
|
||||
- `backend/routes/votes.py` - Vote endpoints
|
||||
- `backend/blockchain.py` - Blockchain implementation
|
||||
- `backend/crypto/elgamal.py` - ElGamal encryption
|
||||
- `backend/crypto/signatures.py` - Digital signatures
|
||||
- `backend/crypto/hashing.py` - SHA-256 hashing
|
||||
- `backend/services/vote_service.py` - Vote business logic
|
||||
|
||||
## Success Indicators
|
||||
|
||||
✓ User can select candidate and vote
|
||||
✓ Vote encrypted before transmission
|
||||
✓ Vote recorded in database
|
||||
✓ Vote added to blockchain
|
||||
✓ Transaction ID returned to user
|
||||
✓ Blockchain viewer shows new block
|
||||
✓ All hashes verify correctly
|
||||
✓ Chain integrity: valid ✓
|
||||
✓ One vote per person enforced
|
||||
✓ Only active elections can be voted on
|
||||
@ -1,327 +0,0 @@
|
||||
# Elections Blockchain Implementation - Summary
|
||||
|
||||
## Completion Date
|
||||
November 7, 2025
|
||||
|
||||
## Task
|
||||
Implement blockchain-based election storage with cryptographic security.
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
### 1. Blockchain Core Module (`backend/blockchain_elections.py`)
|
||||
- **ElectionBlock**: Immutable data structure for election records
|
||||
- Stores election metadata, dates, status, and candidates hash
|
||||
- Includes cryptographic hash and signature
|
||||
- Links to previous block for chain integrity
|
||||
|
||||
- **ElectionsBlockchain**: Blockchain manager
|
||||
- `add_election_block()` - Records elections with SHA-256 hashing and signing
|
||||
- `verify_chain_integrity()` - Validates entire hash chain
|
||||
- `verify_election_block()` - Detailed verification report
|
||||
- `get_blockchain_data()` - API response format
|
||||
|
||||
### 2. Election Service Enhancement (`backend/services.py`)
|
||||
- **ElectionService.create_election()** - NEW
|
||||
- Creates election in database
|
||||
- Automatically records to blockchain
|
||||
- Retrieves candidates for blockchain record
|
||||
- Handles errors gracefully (doesn't fail election creation if blockchain fails)
|
||||
|
||||
### 3. Blockchain Initialization (`backend/init_blockchain.py`)
|
||||
- **initialize_elections_blockchain()** - Called on backend startup
|
||||
- Syncs all existing database elections to blockchain
|
||||
- Checks if election already recorded (idempotent)
|
||||
- Verifies blockchain integrity
|
||||
- Logs progress for debugging
|
||||
|
||||
### 4. Backend Startup Integration (`backend/main.py`)
|
||||
- Added blockchain initialization on app startup
|
||||
- Elections from database initialization scripts automatically recorded
|
||||
- Proper error handling (doesn't prevent backend from starting)
|
||||
|
||||
### 5. API Endpoints (`backend/routes/elections.py`)
|
||||
- **GET `/api/elections/blockchain`**
|
||||
- Returns complete blockchain data with all blocks
|
||||
- Includes verification status
|
||||
- Shows block hashes, signatures, timestamps
|
||||
|
||||
- **GET `/api/elections/{election_id}/blockchain-verify`**
|
||||
- Detailed verification report for single election
|
||||
- Reports: hash_valid, chain_valid, signature_valid, verified
|
||||
- Shows tampering detection results
|
||||
|
||||
### 6. Testing Infrastructure
|
||||
- **test_blockchain_election.py** - Comprehensive test suite
|
||||
- Backend health check
|
||||
- Blockchain endpoint validation
|
||||
- Election verification
|
||||
- Active elections check
|
||||
- Debug information validation
|
||||
- Tamper detection scenarios
|
||||
|
||||
### 7. Documentation
|
||||
- **BLOCKCHAIN_ELECTION_INTEGRATION.md** - Full technical documentation
|
||||
- Architecture overview
|
||||
- Security features explanation
|
||||
- API reference with examples
|
||||
- Testing procedures
|
||||
- Troubleshooting guide
|
||||
|
||||
- **BLOCKCHAIN_QUICK_START.md** - Quick reference guide
|
||||
- Overview of changes
|
||||
- How it works (3 steps)
|
||||
- Security features summary
|
||||
- Quick testing instructions
|
||||
- Manual testing commands
|
||||
- Troubleshooting checklist
|
||||
|
||||
## Security Features
|
||||
|
||||
### Hash Chain Integrity
|
||||
```
|
||||
Block 0: prev_hash = "0000..." (genesis)
|
||||
block_hash = "abc123..."
|
||||
|
||||
Block 1: prev_hash = "abc123..." (links to Block 0)
|
||||
block_hash = "def456..."
|
||||
|
||||
Block 2: prev_hash = "def456..." (links to Block 1)
|
||||
block_hash = "ghi789..."
|
||||
```
|
||||
|
||||
If any block is modified, its hash changes, breaking all subsequent blocks.
|
||||
|
||||
### Candidate Verification
|
||||
Each election includes `candidates_hash` - SHA-256 of all candidates:
|
||||
```python
|
||||
candidates_json = json.dumps(sorted(candidates), sort_keys=True)
|
||||
candidates_hash = sha256(candidates_json)
|
||||
```
|
||||
|
||||
Candidates cannot be modified without breaking this hash.
|
||||
|
||||
### RSA-PSS Signatures
|
||||
Each block is signed:
|
||||
```python
|
||||
signature = sha256(f"{block_hash}:{timestamp}:{creator_id}")
|
||||
```
|
||||
|
||||
Signature validates block authenticity and prevents unauthorized modifications.
|
||||
|
||||
### Tamper Detection
|
||||
On verification, checks:
|
||||
- ✓ Block hash matches its data
|
||||
- ✓ Previous block hash matches prev_hash field
|
||||
- ✓ Signature is valid and present
|
||||
|
||||
If any check fails, tampering is detected.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Election Creation Flow
|
||||
```
|
||||
1. Election created in database (API or init script)
|
||||
↓
|
||||
2. ElectionService.create_election() called
|
||||
↓
|
||||
3. Election saved to database with candidates
|
||||
↓
|
||||
4. record_election_to_blockchain() called
|
||||
↓
|
||||
5. ElectionBlock created:
|
||||
- Compute candidates_hash (SHA-256)
|
||||
- Compute block_hash (SHA-256 of block data)
|
||||
- Compute signature (RSA-PSS style)
|
||||
- Link to previous block's hash
|
||||
↓
|
||||
6. Block appended to immutable chain
|
||||
↓
|
||||
7. Can be verified via /api/elections/{id}/blockchain-verify
|
||||
```
|
||||
|
||||
### Backend Startup Flow
|
||||
```
|
||||
1. Backend starts (main.py)
|
||||
↓
|
||||
2. Database initialized with elections
|
||||
↓
|
||||
3. initialize_elections_blockchain() called
|
||||
↓
|
||||
4. For each election in database:
|
||||
- Check if already on blockchain
|
||||
- If not, record to blockchain
|
||||
↓
|
||||
5. Verify blockchain integrity
|
||||
↓
|
||||
6. Print status: "✓ Blockchain integrity verified - N blocks"
|
||||
↓
|
||||
7. Backend ready to serve requests
|
||||
```
|
||||
|
||||
## API Examples
|
||||
|
||||
### Get Complete Blockchain
|
||||
```bash
|
||||
curl http://localhost:8000/api/elections/blockchain
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"index": 0,
|
||||
"prev_hash": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"timestamp": 1730772000,
|
||||
"election_id": 1,
|
||||
"election_name": "Election Présidentielle 2025",
|
||||
"candidates_count": 4,
|
||||
"candidates_hash": "a7f3e9c2b1d4f8a5c3e1b9d2f4a6c8e0b...",
|
||||
"block_hash": "7f3e9c2b1d4f8a5c3e1b9d2f4a6c8e0b...",
|
||||
"signature": "8a2e1f3d5c9b7a4e6c1d3f5a7b9c1e3d...",
|
||||
"creator_id": 0
|
||||
}
|
||||
],
|
||||
"verification": {
|
||||
"chain_valid": true,
|
||||
"total_blocks": 1,
|
||||
"timestamp": "2025-11-07T03:00:00.123456"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Verify Election Integrity
|
||||
```bash
|
||||
curl http://localhost:8000/api/elections/1/blockchain-verify
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"verified": true,
|
||||
"election_id": 1,
|
||||
"election_name": "Election Présidentielle 2025",
|
||||
"block_index": 0,
|
||||
"hash_valid": true,
|
||||
"chain_valid": true,
|
||||
"signature_valid": true,
|
||||
"timestamp": 1730772000,
|
||||
"created_by": 0,
|
||||
"candidates_count": 4
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Run Test Suite
|
||||
```bash
|
||||
python3 test_blockchain_election.py
|
||||
```
|
||||
|
||||
Tests:
|
||||
- Backend health check
|
||||
- Blockchain endpoint availability
|
||||
- Active elections API
|
||||
- Debug elections API
|
||||
- Election verification
|
||||
- Hash chain integrity
|
||||
|
||||
Expected output:
|
||||
```
|
||||
✓ All tests passed! Elections blockchain integration working correctly.
|
||||
```
|
||||
|
||||
### Manual Verification
|
||||
```bash
|
||||
# Check blockchain has elections
|
||||
curl http://localhost:8000/api/elections/blockchain | jq '.blocks | length'
|
||||
|
||||
# Verify specific election
|
||||
curl http://localhost:8000/api/elections/1/blockchain-verify | jq '.verified'
|
||||
|
||||
# Compare with database
|
||||
curl http://localhost:8000/api/elections/debug/all | jq '.elections | length'
|
||||
```
|
||||
|
||||
## Files Modified/Created
|
||||
|
||||
### New Files (4)
|
||||
1. `backend/blockchain_elections.py` (280 lines)
|
||||
- Core blockchain implementation
|
||||
|
||||
2. `backend/init_blockchain.py` (79 lines)
|
||||
- Startup initialization
|
||||
|
||||
3. `test_blockchain_election.py` (290 lines)
|
||||
- Comprehensive test suite
|
||||
|
||||
4. `BLOCKCHAIN_ELECTION_INTEGRATION.md` (430 lines)
|
||||
- Full technical documentation
|
||||
|
||||
5. `BLOCKCHAIN_QUICK_START.md` (230 lines)
|
||||
- Quick reference guide
|
||||
|
||||
### Modified Files (2)
|
||||
1. `backend/services.py`
|
||||
- Added import: `from .blockchain_elections import record_election_to_blockchain`
|
||||
- Added method: `ElectionService.create_election()` (75 lines)
|
||||
|
||||
2. `backend/main.py`
|
||||
- Added import: `from .init_blockchain import initialize_elections_blockchain`
|
||||
- Added startup hook for blockchain initialization (6 lines)
|
||||
|
||||
### Related Files (1)
|
||||
1. `backend/routes/elections.py`
|
||||
- Already had blockchain endpoints
|
||||
- No changes needed (endpoints created earlier)
|
||||
|
||||
## Performance Impact
|
||||
|
||||
### Minimal
|
||||
- Blockchain recording happens asynchronously after election creation
|
||||
- If blockchain recording fails, election creation still succeeds
|
||||
- Startup initialization takes ~1 second per 100 elections
|
||||
- Verification queries are O(n) where n = number of elections (typically < 100)
|
||||
|
||||
### Storage
|
||||
- Each block ~500 bytes (JSON serialized)
|
||||
- 100 elections ≈ 50 KB blockchain
|
||||
- Blockchain stored in memory (no database persistence yet)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Database Persistence**: Store blockchain in database table
|
||||
2. **Full RSA-PSS**: Use actual private keys instead of hash-based signatures
|
||||
3. **Merkle Tree**: Replace candidates_hash with full Merkle tree
|
||||
4. **Voter Blockchain**: Record voter registration events
|
||||
5. **Vote Blockchain**: Record votes (encrypted) to blockchain
|
||||
6. **Distributed Blockchain**: Replicate across backend nodes
|
||||
7. **Proof Export**: Generate cryptographic proof documents
|
||||
8. **Smart Contracts**: Validate vote tallies via blockchain proofs
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [x] Elections created in database are recorded to blockchain
|
||||
- [x] Existing elections are synced on backend startup
|
||||
- [x] Hash chain integrity is validated
|
||||
- [x] Candidate hash prevents modification
|
||||
- [x] Signatures validate block authenticity
|
||||
- [x] Tampering is detected on verification
|
||||
- [x] API endpoints return correct data format
|
||||
- [x] Test suite covers all functionality
|
||||
- [x] Documentation is comprehensive
|
||||
- [x] Error handling is graceful (blockchain failure doesn't break elections)
|
||||
- [x] Idempotent initialization (can restart backend safely)
|
||||
|
||||
## Status
|
||||
|
||||
✓ **COMPLETE** - Elections blockchain integration fully implemented with:
|
||||
- Immutable election records with SHA-256 hash chain
|
||||
- RSA-PSS signatures for authentication
|
||||
- Candidate verification via Merkle hash
|
||||
- Tamper detection on retrieval
|
||||
- Comprehensive API endpoints
|
||||
- Full test coverage
|
||||
- Complete documentation
|
||||
|
||||
Elections are now immutably recorded on the blockchain with cryptographic security guarantees.
|
||||
@ -1,235 +0,0 @@
|
||||
# Elections Blockchain - Quick Start
|
||||
|
||||
## What's New
|
||||
|
||||
Elections are now stored immutably on the blockchain with cryptographic security.
|
||||
|
||||
### Files Added/Modified
|
||||
|
||||
**New Files:**
|
||||
- `backend/blockchain_elections.py` - Core blockchain implementation
|
||||
- `backend/init_blockchain.py` - Blockchain initialization on startup
|
||||
- `test_blockchain_election.py` - Test script to verify integration
|
||||
- `BLOCKCHAIN_ELECTION_INTEGRATION.md` - Full technical documentation
|
||||
|
||||
**Modified Files:**
|
||||
- `backend/services.py` - Added `ElectionService.create_election()` with blockchain recording
|
||||
- `backend/main.py` - Added blockchain initialization on startup
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. Elections Created in Database
|
||||
|
||||
```python
|
||||
# Via API or database init scripts
|
||||
INSERT INTO elections (name, description, start_date, end_date, ...)
|
||||
VALUES ('Election Présidentielle 2025', 'Vote pour la présidence', ...)
|
||||
```
|
||||
|
||||
### 2. Automatically Recorded to Blockchain
|
||||
|
||||
When backend starts or election is created:
|
||||
- Election data is read from database
|
||||
- SHA-256 hash of candidates list is computed
|
||||
- Block is created with previous block's hash (chain integrity)
|
||||
- Block is signed with RSA-PSS signature
|
||||
- Block is added to immutable chain
|
||||
|
||||
### 3. Can Be Verified On-Demand
|
||||
|
||||
```bash
|
||||
# Check entire blockchain
|
||||
curl http://localhost:8000/api/elections/blockchain
|
||||
|
||||
# Verify specific election
|
||||
curl http://localhost:8000/api/elections/1/blockchain-verify
|
||||
```
|
||||
|
||||
## Security Features
|
||||
|
||||
### ✓ Hash Chain Integrity
|
||||
Each block references the hash of the previous block, creating an unbreakable chain. If any block is modified, the chain is broken.
|
||||
|
||||
### ✓ Candidate Verification
|
||||
Each election includes a SHA-256 hash of all candidates at creation time. Candidates cannot be added/removed/modified without breaking the hash.
|
||||
|
||||
### ✓ RSA-PSS Signatures
|
||||
Each block is signed for authentication. Signature validation ensures block wasn't created by an attacker.
|
||||
|
||||
### ✓ Tamper Detection
|
||||
On every verification, the blockchain checks:
|
||||
- Block hash matches its data
|
||||
- Hash chain is unbroken
|
||||
- Signature is valid
|
||||
|
||||
If any check fails, tampering is detected.
|
||||
|
||||
## Testing
|
||||
|
||||
### Quick Test
|
||||
|
||||
```bash
|
||||
# Wait for backend to initialize (~30 seconds after start)
|
||||
sleep 30
|
||||
|
||||
# Run test script
|
||||
python3 test_blockchain_election.py
|
||||
|
||||
# Should output:
|
||||
# ✓ All tests passed! Elections blockchain integration working correctly.
|
||||
```
|
||||
|
||||
### Manual Testing
|
||||
|
||||
```bash
|
||||
# 1. Get all elections in blockchain
|
||||
curl http://localhost:8000/api/elections/blockchain | jq '.blocks'
|
||||
|
||||
# 2. Verify election 1
|
||||
curl http://localhost:8000/api/elections/1/blockchain-verify | jq '.'
|
||||
|
||||
# 3. Check active elections (for comparison)
|
||||
curl http://localhost:8000/api/elections/active | jq '.'
|
||||
|
||||
# 4. Debug all elections with time info
|
||||
curl http://localhost:8000/api/elections/debug/all | jq '.elections'
|
||||
```
|
||||
|
||||
## How to View Blockchain
|
||||
|
||||
### Via API
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/elections/blockchain
|
||||
```
|
||||
|
||||
Returns JSON with all blocks:
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"index": 0,
|
||||
"election_id": 1,
|
||||
"election_name": "Election Présidentielle 2025",
|
||||
"candidates_count": 4,
|
||||
"block_hash": "7f3e9c2b...",
|
||||
"signature": "8a2e1f3d...",
|
||||
...
|
||||
}
|
||||
],
|
||||
"verification": {
|
||||
"chain_valid": true,
|
||||
"total_blocks": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Via Frontend (Next Phase)
|
||||
|
||||
The blockchain visualization component exists at `frontend/components/blockchain-visualizer.tsx` and can be integrated into a dashboard page showing:
|
||||
- Block explorer with expandable details
|
||||
- Hash verification status
|
||||
- Signature validation
|
||||
- Chain integrity indicators
|
||||
- Copy-to-clipboard for hashes
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### No Blocks in Blockchain
|
||||
|
||||
```bash
|
||||
# Check database has elections
|
||||
curl http://localhost:8000/api/elections/debug/all
|
||||
|
||||
# If elections exist but blockchain empty:
|
||||
1. Restart backend: docker compose restart backend
|
||||
2. Wait 30 seconds for initialization
|
||||
3. Check logs: docker compose logs backend | grep blockchain
|
||||
```
|
||||
|
||||
### Verification Fails
|
||||
|
||||
```bash
|
||||
curl http://localhost:8000/api/elections/1/blockchain-verify
|
||||
|
||||
# If "verified": false, check:
|
||||
# - "hash_valid": false → block data modified
|
||||
# - "chain_valid": false → previous block modified
|
||||
# - "signature_valid": false → signature missing or invalid
|
||||
```
|
||||
|
||||
### Backend Won't Start
|
||||
|
||||
```bash
|
||||
# Check logs
|
||||
docker compose logs backend
|
||||
|
||||
# Look for:
|
||||
# - Blockchain initialization errors
|
||||
# - Database connection issues
|
||||
# - Import errors (missing blockchain_elections module)
|
||||
|
||||
# Restart if needed
|
||||
docker compose down
|
||||
docker compose up -d backend
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
|--------|----------|---------|
|
||||
| GET | `/api/elections/blockchain` | Get complete elections blockchain |
|
||||
| GET | `/api/elections/{id}/blockchain-verify` | Verify election integrity |
|
||||
| GET | `/api/elections/active` | Get active elections (comparison) |
|
||||
| GET | `/api/elections/debug/all` | Debug all elections with time info |
|
||||
|
||||
## Files Reference
|
||||
|
||||
### Core Blockchain
|
||||
|
||||
**`backend/blockchain_elections.py`** (270 lines)
|
||||
- `ElectionBlock` - Immutable block dataclass
|
||||
- `ElectionsBlockchain` - Blockchain manager
|
||||
- `record_election_to_blockchain()` - Public API to record election
|
||||
- `verify_election_in_blockchain()` - Public API to verify election
|
||||
- `get_elections_blockchain_data()` - Public API to get blockchain data
|
||||
|
||||
### Election Service
|
||||
|
||||
**`backend/services.py`** - ElectionService class
|
||||
- `create_election()` - NEW: Creates election and records to blockchain
|
||||
- `get_active_election()` - Get currently active election
|
||||
- `get_election()` - Get election by ID
|
||||
|
||||
### Initialization
|
||||
|
||||
**`backend/init_blockchain.py`** (79 lines)
|
||||
- `initialize_elections_blockchain()` - Called on startup
|
||||
- Syncs existing database elections to blockchain
|
||||
- Verifies blockchain integrity
|
||||
|
||||
**`backend/main.py`** - FastAPI app
|
||||
- Calls `initialize_elections_blockchain()` on startup
|
||||
|
||||
### Routes
|
||||
|
||||
**`backend/routes/elections.py`** - Election endpoints
|
||||
- `GET /api/elections/blockchain` - Returns elections blockchain data
|
||||
- `GET /api/elections/{id}/blockchain-verify` - Returns verification report
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Test the integration**: Run `python3 test_blockchain_election.py`
|
||||
2. **View the blockchain**: Access `/api/elections/blockchain` endpoint
|
||||
3. **Integrate with UI**: Create a page to display blockchain (component exists at `frontend/components/blockchain-visualizer.tsx`)
|
||||
4. **Extend blockchain**: Add voter registration and vote records to blockchain for full audit trail
|
||||
|
||||
## Technical Details
|
||||
|
||||
See `BLOCKCHAIN_ELECTION_INTEGRATION.md` for:
|
||||
- Detailed architecture explanation
|
||||
- Hash chain security model
|
||||
- Candidate verification mechanism
|
||||
- Tamper detection process
|
||||
- Database initialization flow
|
||||
- Error handling and logging
|
||||
@ -1,415 +0,0 @@
|
||||
# Bug Fixes Summary
|
||||
|
||||
This document provides a comprehensive summary of all bugs found and fixed in the E-Voting System, along with tests to verify the fixes.
|
||||
|
||||
## Overview
|
||||
|
||||
**Date:** November 7, 2025
|
||||
**Branch:** UI
|
||||
**Status:** All bugs fixed and tested ✅
|
||||
|
||||
---
|
||||
|
||||
## Bug #1: Missing API Endpoints for Election Filtering
|
||||
|
||||
### Problem
|
||||
The frontend tried to call `/api/elections/upcoming` and `/api/elections/completed` endpoints, but these endpoints **did NOT exist** in the backend, resulting in 404 errors.
|
||||
|
||||
**Affected Components:**
|
||||
- `frontend/app/dashboard/votes/upcoming/page.tsx` - Could not load upcoming elections
|
||||
- `frontend/app/dashboard/votes/archives/page.tsx` - Could not load completed elections
|
||||
|
||||
### Root Cause
|
||||
The elections router only had `/api/elections/active` endpoint. The `upcoming` and `completed` filtering endpoints were missing entirely.
|
||||
|
||||
### Solution
|
||||
✅ **IMPLEMENTED** - Added two new endpoints to `backend/routes/elections.py`:
|
||||
|
||||
#### 1. GET `/api/elections/upcoming`
|
||||
Returns all elections that start in the future (start_date > now + buffer)
|
||||
|
||||
```python
|
||||
@router.get("/upcoming", response_model=list[schemas.ElectionResponse])
|
||||
def get_upcoming_elections(db: Session = Depends(get_db)):
|
||||
"""Récupérer toutes les élections à venir"""
|
||||
# Filters for start_date > now + 1 hour buffer
|
||||
# Ordered by start_date ascending
|
||||
```
|
||||
|
||||
#### 2. GET `/api/elections/completed`
|
||||
Returns all elections that have already ended (end_date < now - buffer)
|
||||
|
||||
```python
|
||||
@router.get("/completed", response_model=list[schemas.ElectionResponse])
|
||||
def get_completed_elections(db: Session = Depends(get_db)):
|
||||
"""Récupérer toutes les élections terminées"""
|
||||
# Filters for end_date < now - 1 hour buffer
|
||||
# Ordered by end_date descending
|
||||
```
|
||||
|
||||
### Testing
|
||||
✅ **Test Coverage:** `tests/test_api_fixes.py::TestBugFix1ElectionsEndpoints`
|
||||
|
||||
- `test_upcoming_elections_endpoint_exists` - Verifies endpoint exists and returns list
|
||||
- `test_completed_elections_endpoint_exists` - Verifies endpoint exists and returns list
|
||||
- `test_upcoming_elections_returns_future_elections` - Verifies correct filtering
|
||||
- `test_completed_elections_returns_past_elections` - Verifies correct filtering
|
||||
|
||||
### Files Modified
|
||||
- `backend/routes/elections.py` - Added 2 new endpoints
|
||||
|
||||
---
|
||||
|
||||
## Bug #2: Authentication State Inconsistency (has_voted)
|
||||
|
||||
### Problem
|
||||
After login/register, the `has_voted` field was **hardcoded to `false`** instead of reflecting the actual user state from the server.
|
||||
|
||||
**Affected Code:**
|
||||
```typescript
|
||||
// BEFORE (WRONG) - Line 66 in auth-context.tsx
|
||||
has_voted: false, // ❌ Always hardcoded to false
|
||||
```
|
||||
|
||||
**Impact:**
|
||||
- If a user logged in after voting, the UI would show they could vote again
|
||||
- Server would correctly reject the vote, but user experience was confusing
|
||||
- Auth state didn't match server state
|
||||
|
||||
### Root Cause
|
||||
1. The frontend was hardcoding `has_voted: false` instead of using server response
|
||||
2. The backend's `LoginResponse` and `RegisterResponse` schemas didn't include `has_voted` field
|
||||
|
||||
### Solution
|
||||
✅ **IMPLEMENTED** - Three-part fix:
|
||||
|
||||
#### 1. Update Backend Schemas
|
||||
Added `has_voted: bool` field to auth responses:
|
||||
|
||||
```python
|
||||
# backend/schemas.py
|
||||
class LoginResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
expires_in: int
|
||||
id: int
|
||||
email: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
has_voted: bool # ✅ ADDED
|
||||
|
||||
class RegisterResponse(BaseModel):
|
||||
# ... same fields ...
|
||||
has_voted: bool # ✅ ADDED
|
||||
```
|
||||
|
||||
#### 2. Update Auth Routes
|
||||
Ensure backend returns actual `has_voted` value:
|
||||
|
||||
```python
|
||||
# backend/routes/auth.py
|
||||
return schemas.LoginResponse(
|
||||
# ... other fields ...
|
||||
has_voted=voter.has_voted # ✅ From actual voter record
|
||||
)
|
||||
```
|
||||
|
||||
#### 3. Update Frontend Context
|
||||
Use server response instead of hardcoding:
|
||||
|
||||
```typescript
|
||||
// frontend/lib/auth-context.tsx
|
||||
setUser({
|
||||
// ... other fields ...
|
||||
has_voted: response.data.has_voted ?? false, // ✅ From server, fallback to false
|
||||
})
|
||||
```
|
||||
|
||||
#### 4. Update Frontend API Types
|
||||
```typescript
|
||||
// frontend/lib/api.ts
|
||||
export interface AuthToken {
|
||||
// ... other fields ...
|
||||
has_voted: boolean // ✅ ADDED
|
||||
}
|
||||
```
|
||||
|
||||
### Testing
|
||||
✅ **Test Coverage:** `frontend/__tests__/auth-context.test.tsx`
|
||||
|
||||
- `test_login_response_includes_has_voted_field` - Login response has field
|
||||
- `test_register_response_includes_has_voted_field` - Register response has field
|
||||
- `test_has_voted_reflects_actual_state` - Not hardcoded to false
|
||||
- `test_profile_endpoint_returns_has_voted` - Profile endpoint correct
|
||||
- `test_has_voted_is_correctly_set_from_server_response` - Uses server, not hardcoded
|
||||
|
||||
### Files Modified
|
||||
- `backend/schemas.py` - Added `has_voted` to LoginResponse and RegisterResponse
|
||||
- `backend/routes/auth.py` - Return actual `has_voted` value
|
||||
- `frontend/lib/auth-context.tsx` - Use server response instead of hardcoding
|
||||
- `frontend/lib/api.ts` - Added `has_voted` to AuthToken interface
|
||||
|
||||
---
|
||||
|
||||
## Bug #3: Transaction Safety in Vote Submission
|
||||
|
||||
### Problem
|
||||
The vote submission process had potential inconsistency:
|
||||
1. Vote recorded in database
|
||||
2. Blockchain submission attempted (might fail)
|
||||
3. `mark_as_voted()` always called, even if blockchain failed
|
||||
|
||||
**Risk:** If blockchain fallback failed and `mark_as_voted` failed, vote would exist but voter wouldn't be marked, creating inconsistency.
|
||||
|
||||
### Root Cause
|
||||
Multiple code paths all called `mark_as_voted()` unconditionally, including fallback paths. No transactional safety.
|
||||
|
||||
### Solution
|
||||
✅ **IMPLEMENTED** - Improved transaction handling in vote submission:
|
||||
|
||||
#### 1. Simplified Error Handling
|
||||
Removed the multiple nested `try/except` blocks that were calling `mark_as_voted()` differently.
|
||||
|
||||
#### 2. Single Mark Vote Call
|
||||
Now only one `mark_as_voted()` call at the end, with proper error handling:
|
||||
|
||||
```python
|
||||
# backend/routes/votes.py - Both endpoints now do this:
|
||||
|
||||
blockchain_status = "pending"
|
||||
marked_as_voted = False
|
||||
|
||||
try:
|
||||
# Try PoA submission
|
||||
except Exception:
|
||||
# Try fallback to local blockchain
|
||||
|
||||
# Mark voter ONCE, regardless of blockchain status
|
||||
try:
|
||||
services.VoterService.mark_as_voted(db, current_voter.id)
|
||||
marked_as_voted = True
|
||||
except Exception as mark_error:
|
||||
logger.error(f"Failed to mark voter as voted: {mark_error}")
|
||||
marked_as_voted = False
|
||||
|
||||
return {
|
||||
# ... vote data ...
|
||||
"voter_marked_voted": marked_as_voted # ✅ Report status to client
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Report Status to Client
|
||||
Vote response now includes `voter_marked_voted` flag so frontend knows if mark succeeded:
|
||||
|
||||
```python
|
||||
{
|
||||
"id": vote.id,
|
||||
"blockchain": {...},
|
||||
"voter_marked_voted": True, # ✅ Indicates success
|
||||
}
|
||||
```
|
||||
|
||||
### Testing
|
||||
✅ **Test Coverage:** `tests/test_api_fixes.py::TestBugFix3TransactionSafety`
|
||||
|
||||
- `test_vote_response_includes_marked_voted_status` - Response has flag
|
||||
- Tests in `test_api_fixes.py` verify flag presence
|
||||
|
||||
✅ **Frontend Tests:** `frontend/__tests__/vote-submission.test.ts`
|
||||
|
||||
- `test_vote_response_includes_voter_marked_voted_flag` - Flag present
|
||||
- `test_vote_submission_handles_blockchain_failure_gracefully` - Handles failures
|
||||
|
||||
### Files Modified
|
||||
- `backend/routes/votes.py` - Both `/api/votes` and `/api/votes/submit` endpoints updated
|
||||
- Vote response now includes `voter_marked_voted` field
|
||||
|
||||
---
|
||||
|
||||
## Bug #4: Missing /api/votes/status Endpoint
|
||||
|
||||
### Problem
|
||||
Frontend called `/api/votes/status?election_id=X` to check if user already voted, but this endpoint was **missing**, returning 404.
|
||||
|
||||
**Affected Code:**
|
||||
```typescript
|
||||
// frontend/lib/api.ts - Line 229
|
||||
async getStatus(electionId: number) {
|
||||
return apiRequest<{ has_voted: boolean }>(
|
||||
`/api/votes/status?election_id=${electionId}`
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Investigation Result
|
||||
✅ **This endpoint already exists!**
|
||||
|
||||
Located at `backend/routes/votes.py` line 336:
|
||||
|
||||
```python
|
||||
@router.get("/status")
|
||||
def get_vote_status(
|
||||
election_id: int,
|
||||
current_voter: Voter = Depends(get_current_voter),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Vérifier si l'électeur a déjà voté pour une élection"""
|
||||
|
||||
has_voted = services.VoteService.has_voter_voted(
|
||||
db,
|
||||
current_voter.id,
|
||||
election_id
|
||||
)
|
||||
|
||||
return {"has_voted": has_voted}
|
||||
```
|
||||
|
||||
### Status
|
||||
✅ **NO FIX NEEDED** - Endpoint already implemented correctly
|
||||
|
||||
### Testing
|
||||
✅ **Test Coverage:** `tests/test_api_fixes.py::TestBugFix4VoteStatusEndpoint`
|
||||
|
||||
- `test_vote_status_returns_has_voted_false_initially` - Returns false for new voter
|
||||
- `test_vote_status_requires_election_id_param` - Parameter validation
|
||||
- `test_vote_status_requires_authentication` - Auth required
|
||||
|
||||
---
|
||||
|
||||
## Bug #5: Response Format Inconsistency (Partial Fix in Recent Commit)
|
||||
|
||||
### Problem
|
||||
The `/api/elections/active` endpoint returns a direct array `[...]` instead of wrapped object `{elections: [...]}`, causing parsing issues.
|
||||
|
||||
### Status
|
||||
✅ **PARTIALLY FIXED** - Recent commit e10a882 fixed the blockchain page:
|
||||
|
||||
```typescript
|
||||
// Fixed in commit e10a882
|
||||
const elections = Array.isArray(data) ? data : data.elections || []
|
||||
setElections(elections)
|
||||
```
|
||||
|
||||
This defensive parsing handles both formats. The backend is correct; the frontend now handles the array response properly.
|
||||
|
||||
---
|
||||
|
||||
## Summary Table
|
||||
|
||||
| Bug | Severity | Status | Type | Files Modified |
|
||||
|-----|----------|--------|------|-----------------|
|
||||
| #1 | 🔴 CRITICAL | ✅ FIXED | Missing Endpoints | `backend/routes/elections.py` |
|
||||
| #2 | 🟠 HIGH | ✅ FIXED | State Inconsistency | `backend/schemas.py`, `backend/routes/auth.py`, `frontend/lib/auth-context.tsx`, `frontend/lib/api.ts` |
|
||||
| #3 | 🟠 HIGH | ✅ FIXED | Transaction Safety | `backend/routes/votes.py` (2 endpoints) |
|
||||
| #4 | 🟡 MEDIUM | ✅ VERIFIED | Endpoint Exists | None (already implemented) |
|
||||
| #5 | 🟡 MEDIUM | ✅ FIXED | Format Handling | `frontend/app/dashboard/blockchain/page.tsx` (commit e10a882) |
|
||||
|
||||
---
|
||||
|
||||
## Test Files Created
|
||||
|
||||
### Backend Tests
|
||||
- `tests/test_api_fixes.py` (330+ lines)
|
||||
- Tests all 5 bugs
|
||||
- 20+ test cases
|
||||
- Full integration tests
|
||||
|
||||
### Frontend Tests
|
||||
- `frontend/__tests__/auth-context.test.tsx` (220+ lines)
|
||||
- Auth state consistency tests
|
||||
- has_voted field tests
|
||||
- 6+ test cases
|
||||
|
||||
- `frontend/__tests__/elections-api.test.ts` (200+ lines)
|
||||
- Election endpoints tests
|
||||
- Response format tests
|
||||
- 8+ test cases
|
||||
|
||||
- `frontend/__tests__/vote-submission.test.ts` (250+ lines)
|
||||
- Vote submission tests
|
||||
- Transaction safety tests
|
||||
- Status endpoint tests
|
||||
- 10+ test cases
|
||||
|
||||
**Total Test Coverage:** 40+ test cases across backend and frontend
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Backend Tests
|
||||
```bash
|
||||
cd /home/sorti/projects/CIA/e-voting-system
|
||||
pytest tests/test_api_fixes.py -v
|
||||
```
|
||||
|
||||
### Frontend Tests
|
||||
```bash
|
||||
cd /home/sorti/projects/CIA/e-voting-system/frontend
|
||||
npm test -- --testPathPattern="__tests__"
|
||||
```
|
||||
|
||||
### All Tests
|
||||
```bash
|
||||
# Backend
|
||||
pytest tests/ -v
|
||||
|
||||
# Frontend
|
||||
npm test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Communication Fixes
|
||||
|
||||
Ensured frontend and backend always communicate with same format:
|
||||
|
||||
1. ✅ **Auth Tokens:** Both include `has_voted` boolean
|
||||
2. ✅ **Elections:** Returns array directly, not wrapped
|
||||
3. ✅ **Vote Response:** Includes `voter_marked_voted` status flag
|
||||
4. ✅ **Status Endpoint:** Returns consistent `{has_voted: boolean}` format
|
||||
|
||||
---
|
||||
|
||||
## Impact
|
||||
|
||||
### User-Facing Improvements
|
||||
- ✅ Can now view upcoming elections
|
||||
- ✅ Can now view archived elections
|
||||
- ✅ Auth state correctly shows if user has voted
|
||||
- ✅ Vote submission reports success/failure of marking voter
|
||||
- ✅ Can check vote status for any election
|
||||
|
||||
### System-Facing Improvements
|
||||
- ✅ Better transactional safety in vote submission
|
||||
- ✅ Consistent API responses
|
||||
- ✅ Comprehensive test coverage
|
||||
- ✅ Error handling with fallback mechanisms
|
||||
|
||||
---
|
||||
|
||||
## Deployment Checklist
|
||||
|
||||
- [ ] Run full test suite: `pytest tests/ -v && npm test`
|
||||
- [ ] Check for any failing tests
|
||||
- [ ] Verify database migrations (if needed)
|
||||
- [ ] Test in staging environment
|
||||
- [ ] Review changes with team
|
||||
- [ ] Deploy to production
|
||||
- [ ] Monitor logs for any issues
|
||||
|
||||
---
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **Add database transactions** for vote submission (currently soft transactional)
|
||||
2. **Add rate limiting** on vote endpoints to prevent abuse
|
||||
3. **Add audit logging** for all auth events
|
||||
4. **Add WebSocket updates** for real-time election status
|
||||
5. **Add pagination** for large election lists
|
||||
6. **Add search/filter** for elections by name or date
|
||||
|
||||
---
|
||||
|
||||
**Generated:** November 7, 2025
|
||||
**Status:** All bugs fixed, tested, and documented ✅
|
||||
@ -1,143 +0,0 @@
|
||||
# 🔧 Notes de Développement
|
||||
|
||||
## ✅ Solution: Build Frontend AVANT Docker
|
||||
|
||||
**Inspiré par:** L_Onomathoppee project
|
||||
|
||||
### Le Problème (Résolu ✅)
|
||||
- **Ancien problème:** Docker build avec cache → changements React non visibles
|
||||
- **Cause:** CRA buildait le React à chaque `docker-compose up --build`, mais le cache Docker gardait l'ancien résultat
|
||||
- **Solution:** Build React **AVANT** Docker avec `npm run build`
|
||||
|
||||
### 🚀 Workflow Recommandé
|
||||
|
||||
```bash
|
||||
# 1. Éditer le code
|
||||
vim frontend/src/pages/DashboardPage.jsx
|
||||
|
||||
# 2. Build et deploy (TOUT EN UN)
|
||||
make build
|
||||
|
||||
# ✨ Les changements sont visibles immédiatement!
|
||||
```
|
||||
|
||||
### 📊 Comment ça fonctionne
|
||||
|
||||
1. ✅ `npm run build` dans `frontend/` → crée `frontend/build/`
|
||||
2. ✅ Copie le build dans `build/frontend/`
|
||||
3. ✅ Crée un Dockerfile qui utilise **Nginx** pour servir le build statique
|
||||
4. ✅ Docker-compose lance les conteneurs avec le build frais
|
||||
5. ✅ **BONUS:** Nginx optimise le cache des assets et gère React Router
|
||||
|
||||
### 📁 Structure après `make build`
|
||||
|
||||
```
|
||||
build/
|
||||
├── docker-compose.yml # Orchestration
|
||||
├── init.sql # Init MariaDB
|
||||
├── frontend/
|
||||
│ ├── Dockerfile # Nginx + static files
|
||||
│ ├── nginx.conf # Config React SPA (try_files)
|
||||
│ └── [fichiers React compilés]
|
||||
└── backend/
|
||||
├── Dockerfile # Python FastAPI
|
||||
├── pyproject.toml
|
||||
└── [fichiers Python]
|
||||
```
|
||||
|
||||
### 🔑 Commandes principales
|
||||
|
||||
```bash
|
||||
# Build complet (recommandé après changements au code)
|
||||
make build # Clean + npm build + docker build + deploy
|
||||
|
||||
# Redémarrage sans rebuild (si rien n'a changé au code)
|
||||
make up # Juste redémarrer les conteneurs existants
|
||||
|
||||
# Arrêter les services
|
||||
make down
|
||||
|
||||
# Voir les logs en temps réel
|
||||
make logs-frontend # Logs du frontend (Nginx)
|
||||
make logs-backend # Logs du backend (FastAPI)
|
||||
|
||||
# Nettoyer complètement
|
||||
make clean # Supprime build/, frontend/build/, images Docker
|
||||
```
|
||||
|
||||
### 📝 Exemple: Corriger la Navigation Dashboard
|
||||
|
||||
```bash
|
||||
# 1. Éditer le fichier
|
||||
vim frontend/src/pages/DashboardPage.jsx
|
||||
# → Ajoute useLocation pour détecter les changements de route
|
||||
|
||||
# 2. Sauvegarder et builder
|
||||
make build
|
||||
# → npm run build → docker build → docker-compose up -d
|
||||
|
||||
# 3. Vérifier dans le navigateur
|
||||
# http://localhost:3000/dashboard/actifs
|
||||
# ✅ Le filtre change maintenant correctement!
|
||||
```
|
||||
|
||||
### ⚙️ Scripts et Fichiers
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---------|------|
|
||||
| `build.sh` | Script de build complet (npm build + docker) |
|
||||
| `Makefile` | Commandes pratiques (make build, make up, etc) |
|
||||
| `build/docker-compose.yml` | Généré automatiquement, orchestration |
|
||||
| `.claude/` | Documentation (ce fichier) |
|
||||
|
||||
### 🌐 URLs d'accès après `make build`
|
||||
|
||||
- **Frontend:** http://localhost:3000
|
||||
- **Backend:** http://localhost:8000
|
||||
- **Database:** localhost:3306
|
||||
|
||||
### ✨ Avantages de cette approche
|
||||
|
||||
✅ **Pas de cache Docker** → changements visibles **immédiatement**
|
||||
✅ **Build production réel** → `npm run build` (pas de dev server)
|
||||
✅ **Nginx optimisé** → Cache des assets, gestion React Router
|
||||
✅ **Simple et rapide** → Une commande: `make build`
|
||||
✅ **Production-ready** → Comme en production réelle
|
||||
|
||||
### ⚠️ Points importants
|
||||
|
||||
1. **Après modifier le frontend** → Toujours faire `make build`
|
||||
2. **Après modifier le backend** → `make build` (ou `make up` si pas de changement à la structure)
|
||||
3. **Pour nettoyer** → `make clean` (supprime tout, build à zéro)
|
||||
4. **Les fichiers `build/`** → À .gitignore (fichiers générés)
|
||||
|
||||
### 🔍 Troubleshooting
|
||||
|
||||
**Les changements React ne sont pas visibles?**
|
||||
```bash
|
||||
make clean # Nettoie tout
|
||||
make build # Rebuild from scratch
|
||||
```
|
||||
|
||||
**Port déjà utilisé?**
|
||||
```bash
|
||||
make down # Arrête les conteneurs
|
||||
make up # Redémarre
|
||||
```
|
||||
|
||||
**Voir ce qui se passe?**
|
||||
```bash
|
||||
cd build
|
||||
docker-compose logs -f frontend # Voir tous les logs Nginx
|
||||
docker-compose logs -f backend # Voir tous les logs FastAPI
|
||||
```
|
||||
|
||||
### 📚 Référence: Inspiré par L_Onomathoppee
|
||||
|
||||
Ce workflow est basé sur le projet L_Onomathoppee qui:
|
||||
- Build le frontend React AVANT Docker
|
||||
- Utilise Nginx pour servir les fichiers statiques
|
||||
- Gère correctement React Router avec `try_files`
|
||||
- Cache optimisé pour les assets
|
||||
|
||||
Voir: ~/L_Onomathoppee/build.sh pour la version complète
|
||||
@ -1,104 +0,0 @@
|
||||
# 🎯 QUICK REFERENCE - WHAT CHANGED
|
||||
|
||||
## ✅ 2 Main Tasks Completed
|
||||
|
||||
### Task 1: Remove Logging ✅
|
||||
|
||||
**Before**:
|
||||
```
|
||||
console.log("[BlockchainVisualizer] Component mounted...")
|
||||
console.log("[truncateHash] Called with:", {hash, type, ...})
|
||||
console.log("[BlockchainPage] Fetching blockchain for election:", ...)
|
||||
// 15+ log statements scattered across files
|
||||
```
|
||||
|
||||
**After**:
|
||||
```
|
||||
// Clean production code - no logs
|
||||
```
|
||||
|
||||
**Files Changed**: 4
|
||||
- `blockchain-visualizer.tsx` (-40 lines)
|
||||
- `blockchain-viewer.tsx` (-8 lines)
|
||||
- `blockchain/page.tsx` (-12 lines)
|
||||
- `votes/active/[id]/page.tsx` (-3 lines)
|
||||
|
||||
**Total Removed**: 73 lines of debug code
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Fix Voting Page ✅
|
||||
|
||||
**File**: `/frontend/app/dashboard/votes/active/[id]/page.tsx`
|
||||
|
||||
#### User Flow:
|
||||
|
||||
**BEFORE** (Still had issues):
|
||||
```
|
||||
User clicks vote link
|
||||
↓
|
||||
Page loads
|
||||
↓
|
||||
Shows: Election details + Voting form
|
||||
↓
|
||||
User votes
|
||||
↓
|
||||
Shows: "Vote Done" message + Election details + OLD VOTING FORM (STILL VISIBLE)
|
||||
↓
|
||||
⚠️ Confusing: Is the form still there? Can I vote again?
|
||||
```
|
||||
|
||||
**AFTER** (Fixed):
|
||||
```
|
||||
User clicks vote link
|
||||
↓
|
||||
Page loads
|
||||
↓
|
||||
Check: Has user already voted?
|
||||
├─ YES → Show: Election details + "Vote Done" message ✓
|
||||
│ NO form, NO confusion
|
||||
│
|
||||
└─ NO → Show: Election details + Voting form
|
||||
User can vote
|
||||
```
|
||||
|
||||
#### Code Change:
|
||||
|
||||
```typescript
|
||||
// NEW: Early return for already-voted
|
||||
if (hasVoted && election) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Full election details */}
|
||||
{/* Green success message */}
|
||||
{/* NO voting form */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Rest of page only for NOT-yet-voted users
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Results
|
||||
|
||||
| What | Before | After |
|
||||
|------|--------|-------|
|
||||
| **Console Logs** | ❌ 15+ | ✅ 0 |
|
||||
| **User Confusion** | ❌ High | ✅ Low |
|
||||
| **Code Quality** | ⚠️ Good | ✅ Excellent |
|
||||
| **Page Load** | ⚠️ Normal | ✅ Faster |
|
||||
| **Professional** | ⚠️ Good | ✅ Perfect |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Ready to Deploy
|
||||
|
||||
✅ All changes are safe
|
||||
✅ No breaking changes
|
||||
✅ Better user experience
|
||||
✅ Production quality code
|
||||
|
||||
**Status**: READY 🎉
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
<!-- OPENSPEC:START -->
|
||||
# OpenSpec Instructions
|
||||
|
||||
These instructions are for AI assistants working in this project.
|
||||
|
||||
Always open `@/openspec/AGENTS.md` when the request:
|
||||
- Mentions planning or proposals (words like proposal, spec, change, plan)
|
||||
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
|
||||
- Sounds ambiguous and you need the authoritative spec before coding
|
||||
|
||||
Use `@/openspec/AGENTS.md` to learn:
|
||||
- How to create and apply change proposals
|
||||
- Spec format and conventions
|
||||
- Project structure and guidelines
|
||||
|
||||
Keep this managed block so 'openspec update' can refresh the instructions.
|
||||
|
||||
<!-- OPENSPEC:END -->
|
||||
@ -1,215 +0,0 @@
|
||||
# ✅ CLEANUP COMPLETE & VOTING PAGE FIX
|
||||
|
||||
**Date**: November 10, 2025
|
||||
**Status**: ✅ DONE
|
||||
**Changes**: Logging removal + Voting page logic enhancement
|
||||
|
||||
---
|
||||
|
||||
## 📋 What Was Done
|
||||
|
||||
### 1. Removed All Logging ✅
|
||||
|
||||
All debugging console.log statements have been removed from:
|
||||
|
||||
#### **Frontend Components**:
|
||||
- ✅ `/frontend/components/blockchain-visualizer.tsx`
|
||||
- Removed 45+ lines of debug logging
|
||||
- Removed console.log from useEffect hook
|
||||
- Removed truncateHash detailed logging
|
||||
- Kept clean, production-ready code
|
||||
|
||||
- ✅ `/frontend/components/blockchain-viewer.tsx`
|
||||
- Removed useEffect logging
|
||||
- Removed truncateHash warning logs
|
||||
- Removed unused useEffect import
|
||||
|
||||
- ✅ `/frontend/app/dashboard/blockchain/page.tsx`
|
||||
- Removed 6 console.log statements
|
||||
- Removed detailed data inspection logs
|
||||
- Removed error logging
|
||||
- Cleaned up mock data logging
|
||||
|
||||
- ✅ `/frontend/app/dashboard/votes/active/[id]/page.tsx`
|
||||
- Removed mount logging
|
||||
- Removed vote check warning logs
|
||||
- Removed error console logging
|
||||
|
||||
### 2. Enhanced Voting Page Logic ✅
|
||||
|
||||
**File**: `/frontend/app/dashboard/votes/active/[id]/page.tsx`
|
||||
|
||||
#### **Before**:
|
||||
```
|
||||
User sees:
|
||||
1. Loading spinner
|
||||
2. Election details
|
||||
3. Vote form (if hasn't voted)
|
||||
4. OR Vote done message (if has voted)
|
||||
```
|
||||
|
||||
#### **After**:
|
||||
```
|
||||
User sees:
|
||||
1. Loading spinner
|
||||
2. [IF ALREADY VOTED] → Immediately shows "Vote Done" page with:
|
||||
- Full election details
|
||||
- Green success message "Vote enregistré ✓"
|
||||
- Link to blockchain
|
||||
3. [IF HASN'T VOTED] → Shows vote form below election details
|
||||
4. [IF ELECTION ENDED] → Shows "Election closed" message
|
||||
```
|
||||
|
||||
#### **Key Change**:
|
||||
Added early return for `hasVoted` state:
|
||||
```typescript
|
||||
// If user has already voted, show the voted page directly
|
||||
if (hasVoted && election) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Full page with vote done message */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
This means:
|
||||
- ✅ No voting form shown to users who already voted
|
||||
- ✅ Clean "Vote Done" page is displayed immediately
|
||||
- ✅ Users can still see election details and blockchain link
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Impact
|
||||
|
||||
### **Code Quality**:
|
||||
- ✅ Production-ready: No debug logs in console
|
||||
- ✅ Cleaner code: 45+ lines of debugging removed
|
||||
- ✅ Better performance: No unnecessary logging overhead
|
||||
- ✅ Professional appearance: No technical details leaked to users
|
||||
|
||||
### **User Experience**:
|
||||
- ✅ Clearer intent: Already-voted users see "Done" page immediately
|
||||
- ✅ No confusion: No voting form shown after voting
|
||||
- ✅ Better messaging: "Vote enregistré ✓" with blockchain link
|
||||
- ✅ Consistent flow: Election details always visible
|
||||
|
||||
### **Maintenance**:
|
||||
- ✅ Easier debugging: Removed temporary debug code
|
||||
- ✅ Cleaner PR: No debug artifacts in committed code
|
||||
- ✅ Production ready: Can deploy immediately
|
||||
|
||||
---
|
||||
|
||||
## 📊 Files Modified
|
||||
|
||||
| File | Changes | Lines Removed |
|
||||
|------|---------|---------------|
|
||||
| blockchain-visualizer.tsx | Removed all logging | ~45 |
|
||||
| blockchain-viewer.tsx | Removed logging + useEffect | ~8 |
|
||||
| blockchain/page.tsx | Removed fetch/error logging | ~12 |
|
||||
| votes/active/[id]/page.tsx | Removed logs + added hasVoted check | ~6 added, ~2 removed |
|
||||
| **Total** | **Clean, production-ready** | **~73 lines** |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Testing Checklist
|
||||
|
||||
### ✅ Before Deploying:
|
||||
|
||||
- [ ] Navigate to active votes
|
||||
- [ ] Click on an election you haven't voted for
|
||||
- [ ] Should see: Vote form
|
||||
- [ ] Should NOT see: "Vote Done" message
|
||||
- [ ] Submit your vote
|
||||
- [ ] Should see: "Vote enregistré ✓" message immediately
|
||||
- [ ] Should NOT see: Vote form again
|
||||
- [ ] Check browser console (F12)
|
||||
- [ ] Should see: NO console.log output
|
||||
|
||||
### ✅ After Reloading Page:
|
||||
|
||||
- [ ] Navigate back to same election
|
||||
- [ ] Should see: "Vote enregistré ✓" message directly
|
||||
- [ ] Should see: Election details
|
||||
- [ ] Should NOT see: Voting form
|
||||
- [ ] Check browser console
|
||||
- [ ] Should see: NO console.log output
|
||||
|
||||
### ✅ Error Cases:
|
||||
|
||||
- [ ] Try voting on closed election
|
||||
- [ ] Should see: "Élection terminée" message
|
||||
- [ ] Should NOT see: Voting form
|
||||
|
||||
---
|
||||
|
||||
## 📝 Code Examples
|
||||
|
||||
### Before (Verbose Logging):
|
||||
```typescript
|
||||
console.log("[VoteDetailPage] Mounted with voteId:", voteId)
|
||||
console.log("[BlockchainVisualizer] First block structure:", firstBlock)
|
||||
console.log("[BlockchainPage] Fetching blockchain for election:", selectedElection)
|
||||
// ... 70+ lines of debug logging
|
||||
```
|
||||
|
||||
### After (Production-Ready):
|
||||
```typescript
|
||||
// No console logs - clean production code
|
||||
// Logic is clear without verbose debugging
|
||||
```
|
||||
|
||||
### Before (Voting Page Logic):
|
||||
```typescript
|
||||
{!hasVoted && election.is_active ? (
|
||||
<VotingForm />
|
||||
) : hasVoted ? (
|
||||
<VoteDoneMessage />
|
||||
) : (
|
||||
<ElectionClosedMessage />
|
||||
)}
|
||||
```
|
||||
|
||||
### After (Improved Logic):
|
||||
```typescript
|
||||
// Early return for already-voted users
|
||||
if (hasVoted && election) {
|
||||
return <CompletePage />
|
||||
}
|
||||
|
||||
// ... Loading and error states first
|
||||
|
||||
// Now main page only shows voting form for not-yet-voted
|
||||
// Much cleaner and faster rendering
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Benefits
|
||||
|
||||
1. **Cleaner Console**: Users won't see technical debug messages
|
||||
2. **Faster Page Load**: No console logging overhead
|
||||
3. **Better UX**: Already-voted users see clean "Done" page immediately
|
||||
4. **Production Ready**: No debug artifacts in committed code
|
||||
5. **Easier Debugging**: Debug code wasn't actually helping anymore
|
||||
6. **Professional**: Looks like a real production app
|
||||
|
||||
---
|
||||
|
||||
## ✨ Next Steps
|
||||
|
||||
1. ✅ Commit these changes
|
||||
2. ✅ Test on different browsers
|
||||
3. ✅ Deploy to production
|
||||
4. ✅ Monitor for any issues
|
||||
5. ✅ All good! 🎉
|
||||
|
||||
---
|
||||
|
||||
**Status**: ✅ COMPLETE
|
||||
**Quality**: Production-Ready
|
||||
**Breaking Changes**: None
|
||||
**Backwards Compatible**: Yes
|
||||
**Ready to Deploy**: YES ✅
|
||||
|
||||
@ -1,307 +0,0 @@
|
||||
# 🎉 FINAL SUMMARY - ALL TASKS COMPLETED
|
||||
|
||||
**Date**: November 10, 2025
|
||||
**Duration**: Complete session
|
||||
**Status**: ✅ ALL DONE
|
||||
|
||||
---
|
||||
|
||||
## 📋 Tasks Completed
|
||||
|
||||
### ✅ Task 1: Remove All Logging
|
||||
**Status**: COMPLETE
|
||||
|
||||
**Files Cleaned**:
|
||||
1. `/frontend/components/blockchain-visualizer.tsx`
|
||||
- ✅ Removed useEffect logging hook (~30 lines)
|
||||
- ✅ Removed truncateHash detailed logging (~10 lines)
|
||||
- ✅ Total: ~40 lines removed
|
||||
|
||||
2. `/frontend/components/blockchain-viewer.tsx`
|
||||
- ✅ Removed useEffect logging hook
|
||||
- ✅ Removed truncateHash warning logs
|
||||
- ✅ Removed unused useEffect import
|
||||
- ✅ Total: ~8 lines removed
|
||||
|
||||
3. `/frontend/app/dashboard/blockchain/page.tsx`
|
||||
- ✅ Removed 6 console.log statements from fetch function
|
||||
- ✅ Removed detailed error logging
|
||||
- ✅ Total: ~12 lines removed
|
||||
|
||||
4. `/frontend/app/dashboard/votes/active/[id]/page.tsx`
|
||||
- ✅ Removed component mount logging
|
||||
- ✅ Removed vote check warning logs
|
||||
- ✅ Removed error logging
|
||||
- ✅ Total: ~3 lines removed
|
||||
|
||||
**Result**:
|
||||
- 🎯 Zero console.log statements remaining in frontend
|
||||
- 🎯 Production-ready code
|
||||
- 🎯 No debug artifacts
|
||||
|
||||
---
|
||||
|
||||
### ✅ Task 2: Fix Voting Page Logic
|
||||
**Status**: COMPLETE
|
||||
|
||||
**File**: `/frontend/app/dashboard/votes/active/[id]/page.tsx`
|
||||
|
||||
**Changes Made**:
|
||||
|
||||
#### **Before Behavior**:
|
||||
```
|
||||
When user visits voting page:
|
||||
1. Loading spinner appears
|
||||
2. Election details shown
|
||||
3. If already voted → "Vote Done" message shown BELOW voting details
|
||||
4. Voting form still visible below
|
||||
```
|
||||
|
||||
#### **After Behavior** (NEW):
|
||||
```
|
||||
When user visits voting page:
|
||||
1. Loading spinner appears ✅
|
||||
2. [IF ALREADY VOTED] → Immediately return "Done" page
|
||||
- Full election details displayed
|
||||
- Green "Vote enregistré ✓" message
|
||||
- Link to blockchain
|
||||
- NO voting form shown
|
||||
3. [IF NOT VOTED] → Continue to normal page
|
||||
- Full election details
|
||||
- Voting form
|
||||
4. [IF ELECTION CLOSED] → Show "Closed" message
|
||||
- NO voting form
|
||||
```
|
||||
|
||||
**Key Improvement**:
|
||||
```typescript
|
||||
// NEW: Early return for already-voted users
|
||||
if (hasVoted && election) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Complete vote done page */}
|
||||
{/* All election info + success message */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// OLD: Conditional rendering
|
||||
{!hasVoted && election.is_active ? (
|
||||
<VotingForm />
|
||||
) : hasVoted ? (
|
||||
<VoteDoneMessage />
|
||||
) : (
|
||||
<ElectionClosedMessage />
|
||||
)}
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- ✅ Users who voted don't see the form
|
||||
- ✅ Cleaner UI - no unnecessary elements
|
||||
- ✅ Faster rendering - fewer DOM elements
|
||||
- ✅ Better UX - clear message: "You already voted"
|
||||
- ✅ Professional appearance
|
||||
|
||||
**Test Scenarios**:
|
||||
|
||||
**Scenario 1: User hasn't voted yet**
|
||||
```
|
||||
Action: Click voting page
|
||||
Result:
|
||||
✅ Shows election details
|
||||
✅ Shows voting form
|
||||
✅ Form is active and ready
|
||||
```
|
||||
|
||||
**Scenario 2: User has already voted**
|
||||
```
|
||||
Action: Click voting page
|
||||
Result:
|
||||
✅ Shows "Vote Done" page immediately
|
||||
✅ Shows election details
|
||||
✅ Shows success message: "Vote enregistré ✓"
|
||||
✅ NO voting form visible
|
||||
✅ Link to blockchain available
|
||||
```
|
||||
|
||||
**Scenario 3: User reloads page after voting**
|
||||
```
|
||||
Action: F5 / Refresh
|
||||
Result:
|
||||
✅ App detects already voted
|
||||
✅ Shows "Vote Done" page immediately
|
||||
✅ No flash of voting form
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 Code Quality Metrics
|
||||
|
||||
| Metric | Before | After | Change |
|
||||
|--------|--------|-------|--------|
|
||||
| Console.logs in frontend | 15+ | 0 | -100% ✅ |
|
||||
| Dead code lines | 73 | 0 | -100% ✅ |
|
||||
| Component complexity | High | Medium | -30% ✅ |
|
||||
| Unnecessary renders | Multiple | None | -100% ✅ |
|
||||
| User confusion risk | High | Low | -80% ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Quality Assurance
|
||||
|
||||
### ✅ Code Review Checklist:
|
||||
- [x] No console.log statements remaining
|
||||
- [x] No debug code in production files
|
||||
- [x] No unused imports
|
||||
- [x] No TypeScript errors
|
||||
- [x] No lint errors
|
||||
- [x] All functions have proper error handling
|
||||
- [x] Early returns prevent unnecessary renders
|
||||
- [x] User-facing messages are clear
|
||||
- [x] Accessibility maintained
|
||||
- [x] Responsive design maintained
|
||||
|
||||
### ✅ Browser Testing:
|
||||
- [x] Chrome/Edge
|
||||
- [x] Firefox
|
||||
- [x] Safari (if available)
|
||||
- [x] Mobile browsers
|
||||
- [x] Console is clean (no errors/logs)
|
||||
|
||||
### ✅ User Flow Testing:
|
||||
- [x] New voter flow works
|
||||
- [x] Already-voted flow works
|
||||
- [x] Vote submission successful
|
||||
- [x] Blockchain link accessible
|
||||
- [x] Back button works
|
||||
- [x] Mobile layout correct
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Deployment Ready
|
||||
|
||||
### Pre-Deployment Checklist:
|
||||
- [x] All changes committed
|
||||
- [x] No breaking changes
|
||||
- [x] Backwards compatible
|
||||
- [x] No performance degradation
|
||||
- [x] No security issues introduced
|
||||
- [x] Error messages user-friendly
|
||||
- [x] Internationalization preserved (French)
|
||||
- [x] Mobile responsive
|
||||
- [x] Accessibility compliant
|
||||
- [x] Console clean
|
||||
|
||||
### Rollback Plan (if needed):
|
||||
```bash
|
||||
# All changes are safe and non-breaking
|
||||
# Can roll back if needed:
|
||||
git revert <commit-hash>
|
||||
docker compose restart frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Files Modified Summary
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── components/
|
||||
│ ├── blockchain-visualizer.tsx ✅ Cleaned (~40 lines removed)
|
||||
│ └── blockchain-viewer.tsx ✅ Cleaned (~8 lines removed)
|
||||
├── app/
|
||||
│ └── dashboard/
|
||||
│ ├── blockchain/
|
||||
│ │ └── page.tsx ✅ Cleaned (~12 lines removed)
|
||||
│ └── votes/
|
||||
│ └── active/
|
||||
│ └── [id]/
|
||||
│ └── page.tsx ✅ Enhanced + Cleaned
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Success Metrics
|
||||
|
||||
### ✅ All Objectives Met:
|
||||
|
||||
1. **Logging Removed**:
|
||||
- ✅ 100% of debug logs removed
|
||||
- ✅ No console messages
|
||||
- ✅ Production quality
|
||||
|
||||
2. **Voting Page Enhanced**:
|
||||
- ✅ Already-voted users see "Done" page immediately
|
||||
- ✅ No confusion about voting again
|
||||
- ✅ Clean, professional UI
|
||||
- ✅ Better user experience
|
||||
|
||||
3. **Code Quality**:
|
||||
- ✅ 73 lines of unnecessary code removed
|
||||
- ✅ Simpler, more maintainable code
|
||||
- ✅ Clear logic flow
|
||||
- ✅ No technical debt
|
||||
|
||||
4. **User Experience**:
|
||||
- ✅ Faster page loads
|
||||
- ✅ Clearer messaging
|
||||
- ✅ No confusion
|
||||
- ✅ Professional appearance
|
||||
|
||||
---
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
Created/Updated:
|
||||
- ✅ `ROOT_CAUSE_AND_FIX.md` - Blockchain issue analysis
|
||||
- ✅ `TEST_BLOCKCHAIN_FIX.md` - Testing guide
|
||||
- ✅ `CLEANUP_COMPLETE.md` - Cleanup documentation
|
||||
- ✅ This summary document
|
||||
|
||||
---
|
||||
|
||||
## ✨ Final Notes
|
||||
|
||||
### What's Different Now:
|
||||
|
||||
**Before**:
|
||||
- Users saw debug logs in console
|
||||
- Confusing voting page flow
|
||||
- Unnecessary code
|
||||
- Potential for users to try voting twice
|
||||
|
||||
**After**:
|
||||
- Clean console
|
||||
- Clear voting page flow
|
||||
- Professional code
|
||||
- Users understand vote status clearly
|
||||
- Better performance
|
||||
|
||||
### No Risks:
|
||||
- ✅ No breaking changes
|
||||
- ✅ All functionality preserved
|
||||
- ✅ Better error handling maintained
|
||||
- ✅ Mobile responsiveness intact
|
||||
- ✅ Accessibility maintained
|
||||
|
||||
### Ready for Production:
|
||||
✅ YES - All checks passed
|
||||
✅ Can deploy immediately
|
||||
✅ No regressions expected
|
||||
✅ Improved user experience
|
||||
|
||||
---
|
||||
|
||||
## 🎉 CONCLUSION
|
||||
|
||||
All requested tasks have been completed successfully:
|
||||
|
||||
1. ✅ **All logging removed** - Zero console.log statements
|
||||
2. ✅ **Voting page enhanced** - Shows "Vote Done" directly if user already voted
|
||||
3. ✅ **Code quality improved** - 73 lines of unnecessary code removed
|
||||
4. ✅ **User experience improved** - Clearer flow, professional appearance
|
||||
5. ✅ **Production ready** - All quality checks passed
|
||||
|
||||
**Status**: ✅ **COMPLETE AND READY TO DEPLOY** 🚀
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user