Compare commits
9 Commits
preprod
...
f9bb1caf32
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9bb1caf32 | ||
|
|
bfda5d3015 | ||
|
|
17deb72834 | ||
|
|
b20c88b05d | ||
|
|
2a2738f5c0 | ||
|
|
6aead108d7 | ||
|
|
de86264047 | ||
|
|
cb98d1c87c | ||
|
|
bbb9b20c85 |
235
README.md
235
README.md
@@ -75,17 +75,28 @@ Une application web moderne et éthique pour gérer des campagnes de budgets par
|
|||||||
- Affichage des descriptions avec support Markdown
|
- Affichage des descriptions avec support Markdown
|
||||||
- Sauvegarde des votes
|
- Sauvegarde des votes
|
||||||
|
|
||||||
#### 📧 **Système d'email**
|
#### 📧 **Système d'email avancé**
|
||||||
- **Configuration SMTP** : Interface d'administration pour configurer les paramètres email
|
- **Configuration SMTP** : Interface d'administration pour configurer les paramètres email
|
||||||
- **Envoi d'emails** : Notifications aux participants
|
- **Envoi d'emails personnalisés** : Envoi d'emails individuels aux participants avec liens de vote
|
||||||
|
- **Templates personnalisables** : Messages d'email configurables avec placeholders [PRENOM] et [NOM]
|
||||||
|
- **Envoi en masse** : Envoi d'emails à tous les participants d'une campagne
|
||||||
- **Test d'envoi** : Fonctionnalité de test des paramètres SMTP
|
- **Test d'envoi** : Fonctionnalité de test des paramètres SMTP
|
||||||
- **Templates personnalisables** : Messages d'email configurables
|
- **Footer personnalisable** : Messages de pied de page avec liens cliquables
|
||||||
|
- **HTML responsive** : Emails avec design moderne et boutons d'action
|
||||||
|
- **Gestion d'erreurs** : Messages d'erreur détaillés pour les problèmes SMTP
|
||||||
|
|
||||||
#### 📊 **Export des données**
|
#### 📊 **Export des données avancé**
|
||||||
- **Export ODS** : Export des statistiques de vote en format tableur
|
- **Formats multiples** : ODS (OpenDocument), CSV, XLS (Microsoft Excel)
|
||||||
- **Format LibreOffice** : Compatible avec LibreOffice Calc, OpenOffice, Excel
|
- **Export des statistiques** : Matrice complète des votes avec 6 onglets de tri
|
||||||
|
- **Export des propositions** : Liste détaillée des propositions par campagne
|
||||||
|
- **Anonymisation RGPD** : 3 niveaux de protection des données personnelles
|
||||||
|
- **Anonymisation complète** : Noms remplacés par "XXXX" (recommandé)
|
||||||
|
- **Initiales uniquement** : Premières lettres des noms/prénoms
|
||||||
|
- **Aucune anonymisation** : Noms complets (avec avertissement RGPD)
|
||||||
- **Données complètes** : Toutes les propositions, participants et votes
|
- **Données complètes** : Toutes les propositions, participants et votes
|
||||||
- **Totaux automatiques** : Calculs des totaux par ligne et colonne
|
- **Totaux automatiques** : Calculs des totaux par ligne et colonne
|
||||||
|
- **Formatage professionnel** : En-têtes, bordures, colonnes dimensionnées
|
||||||
|
- **Configuration centralisée** : Paramètres d'export dans l'interface admin
|
||||||
|
|
||||||
#### 🎨 **Interface moderne**
|
#### 🎨 **Interface moderne**
|
||||||
- **Shadcn/ui** : Composants modernes et accessibles
|
- **Shadcn/ui** : Composants modernes et accessibles
|
||||||
@@ -106,78 +117,60 @@ Une application web moderne et éthique pour gérer des campagnes de budgets par
|
|||||||
- **Validation en temps réel** : Vérification des budgets lors du vote
|
- **Validation en temps réel** : Vérification des budgets lors du vote
|
||||||
- **Gestion d'erreurs** : Messages d'erreur informatifs
|
- **Gestion d'erreurs** : Messages d'erreur informatifs
|
||||||
- **États de chargement** : Feedback visuel pendant les opérations
|
- **États de chargement** : Feedback visuel pendant les opérations
|
||||||
|
- **Personnalisation des emails** : Placeholders [PRENOM] et [NOM] dans les messages
|
||||||
|
- **Footer dynamique** : Messages de pied de page avec liens cliquables vers le projet
|
||||||
|
- **Interface d'envoi d'emails** : Modales dédiées pour l'envoi personnalisé
|
||||||
|
- **Suivi des envois** : Indicateurs de progression pour les envois en masse
|
||||||
|
- **Export multi-formats** : ODS, CSV, XLS avec configuration centralisée
|
||||||
|
- **Anonymisation configurable** : Protection RGPD avec 3 niveaux de sécurité
|
||||||
|
- **Export des propositions** : Export séparé des propositions par campagne
|
||||||
|
- **Formatage professionnel** : Exports avec mise en forme et totaux automatiques
|
||||||
|
|
||||||
## 🛠️ Installation
|
## 🛠️ Installation
|
||||||
|
|
||||||
### Prérequis
|
### 🚀 **Installation simplifiée (recommandée)**
|
||||||
|
|
||||||
|
L'application dispose d'un **assistant de configuration automatique** qui guide pas à pas l'installation complète.
|
||||||
|
|
||||||
|
#### Prérequis
|
||||||
- Node.js 18+
|
- Node.js 18+
|
||||||
- npm ou yarn
|
- npm ou yarn
|
||||||
- Compte Supabase
|
- Compte Supabase
|
||||||
|
|
||||||
### 1. Cloner le projet
|
#### 1. Cloner et installer
|
||||||
```bash
|
```bash
|
||||||
git clone <votre-repo>
|
git clone <votre-repo>
|
||||||
cd mes-budgets-participatifs
|
cd mes-budgets-participatifs
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Installer les dépendances
|
|
||||||
```bash
|
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Configuration Supabase
|
#### 2. Lancer l'assistant de configuration
|
||||||
|
|
||||||
#### Créer un projet Supabase
|
|
||||||
1. Allez sur [supabase.com](https://supabase.com)
|
|
||||||
2. Créez un nouveau projet
|
|
||||||
3. Notez votre URL et vos clés
|
|
||||||
|
|
||||||
#### Configurer la base de données
|
|
||||||
1. Dans votre projet Supabase, allez dans l'éditeur SQL
|
|
||||||
2. Copiez et exécutez le contenu du fichier `database/supabase-schema.sql`
|
|
||||||
|
|
||||||
#### Configurer l'authentification
|
|
||||||
1. Dans Supabase Dashboard > Authentication > Settings
|
|
||||||
2. Activez l'authentification par email
|
|
||||||
3. Désactivez "Enable email confirmations" pour les administrateurs
|
|
||||||
4. Créez les utilisateurs dans Authentication > Users
|
|
||||||
5. Ajoutez les administrateurs dans la table `admin_users` via l'éditeur SQL
|
|
||||||
|
|
||||||
#### Configurer les variables d'environnement
|
|
||||||
Créez un fichier `.env.local` à la racine du projet :
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Configuration Supabase (obligatoire)
|
|
||||||
NEXT_PUBLIC_SUPABASE_URL=votre_url_supabase
|
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=votre_cle_anon_supabase
|
|
||||||
SUPABASE_SERVICE_ROLE_KEY=votre_cle_service_supabase
|
|
||||||
```
|
|
||||||
|
|
||||||
**⚠️ Important :** La `SUPABASE_SERVICE_ROLE_KEY` est **obligatoire** pour les opérations d'administration. Elle permet d'effectuer des opérations privilégiées côté serveur (création d'utilisateurs, gestion des campagnes, etc.).
|
|
||||||
|
|
||||||
### 4. Configuration des administrateurs
|
|
||||||
1. **Créez les utilisateurs** dans Supabase Dashboard > Authentication > Users
|
|
||||||
2. **Ajoutez les administrateurs** dans la table `admin_users` via l'éditeur SQL :
|
|
||||||
```sql
|
|
||||||
INSERT INTO admin_users (user_id, role)
|
|
||||||
VALUES ('votre_user_id', 'admin');
|
|
||||||
```
|
|
||||||
3. **Connectez-vous** avec les identifiants créés
|
|
||||||
|
|
||||||
### 5. Configuration email (optionnelle)
|
|
||||||
Une fois connecté à l'administration, vous pouvez configurer les paramètres SMTP via l'interface :
|
|
||||||
1. Allez dans **Paramètres** > **Configuration SMTP**
|
|
||||||
2. Renseignez vos paramètres de serveur SMTP
|
|
||||||
3. Testez la configuration
|
|
||||||
|
|
||||||
### 6. Lancer l'application
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
L'application sera accessible sur `http://localhost:3000`
|
Puis accédez à `http://localhost:3000/setup` pour l'**assistant de configuration automatique**.
|
||||||
|
|
||||||
### 7. Tests (optionnel)
|
#### 3. Suivre l'assistant pas à pas
|
||||||
|
L'assistant vous guide automatiquement pour :
|
||||||
|
- ✅ **Configuration Supabase** : Création du projet et récupération des clés
|
||||||
|
- ✅ **Base de données** : Exécution automatique du schéma SQL
|
||||||
|
- ✅ **Authentification** : Configuration des utilisateurs admin
|
||||||
|
- ✅ **Variables d'environnement** : Génération automatique du fichier `.env.local`
|
||||||
|
- ✅ **Premier administrateur** : Création du compte admin initial
|
||||||
|
- ✅ **Test de connexion** : Vérification que tout fonctionne
|
||||||
|
|
||||||
|
#### 4. Configuration email (optionnelle)
|
||||||
|
Une fois l'assistant terminé, connectez-vous à l'administration :
|
||||||
|
1. Allez dans **Paramètres** > **Configuration SMTP**
|
||||||
|
2. Renseignez vos paramètres de serveur SMTP
|
||||||
|
3. Testez la configuration
|
||||||
|
|
||||||
|
### 📚 **Installation manuelle (avancée)**
|
||||||
|
|
||||||
|
Si vous préférez une installation manuelle, consultez le [Guide de configuration détaillé](docs/SETUP.md).
|
||||||
|
|
||||||
|
### 🧪 **Tests (optionnel)**
|
||||||
```bash
|
```bash
|
||||||
# Lancer les tests fonctionnels
|
# Lancer les tests fonctionnels
|
||||||
npm run test:working
|
npm run test:working
|
||||||
@@ -234,96 +227,12 @@ npm run test:coverage
|
|||||||
- `category`: Catégorie (email, general, etc.)
|
- `category`: Catégorie (email, general, etc.)
|
||||||
- `description`: Description de la configuration
|
- `description`: Description de la configuration
|
||||||
|
|
||||||
### Table `admin_users`
|
### Table `user_permissions`
|
||||||
- `user_id`: Référence vers l'utilisateur Supabase
|
- `user_id`: Référence vers l'utilisateur Supabase
|
||||||
- `role`: Rôle (admin, super_admin)
|
- `is_admin`: Booléen indiquant si l'utilisateur est administrateur
|
||||||
|
- `is_super_admin`: Booléen indiquant si l'utilisateur est super administrateur
|
||||||
- `created_at`: Date de création
|
- `created_at`: Date de création
|
||||||
|
- `updated_at`: Date de dernière modification
|
||||||
## 🚀 Déploiement
|
|
||||||
|
|
||||||
### Solutions éthiques et libres (recommandées)
|
|
||||||
|
|
||||||
#### 🇫🇷 **Hébergement en France - Solutions éthiques**
|
|
||||||
|
|
||||||
##### 1. **OVHcloud** (Lyon, France)
|
|
||||||
- **Avantages** : Hébergeur français, RGPD compliant, prix compétitifs
|
|
||||||
- **Déploiement** : VPS ou Cloud avec Docker
|
|
||||||
- **Prix** : À partir de 3,50€/mois
|
|
||||||
- **Site** : [ovhcloud.com](https://ovhcloud.com)
|
|
||||||
|
|
||||||
##### 2. **Scaleway** (Paris, France)
|
|
||||||
- **Avantages** : Cloud français, éco-responsable, API complète
|
|
||||||
- **Déploiement** : App Platform ou VPS
|
|
||||||
- **Prix** : À partir de 2,99€/mois
|
|
||||||
- **Site** : [scaleway.com](https://scaleway.com)
|
|
||||||
|
|
||||||
##### 3. **Clever Cloud** (Nantes, France)
|
|
||||||
- **Avantages** : PaaS français, déploiement automatique, support français
|
|
||||||
- **Déploiement** : Platform as a Service
|
|
||||||
- **Prix** : À partir de 7€/mois
|
|
||||||
- **Site** : [clever-cloud.com](https://clever-cloud.com)
|
|
||||||
|
|
||||||
##### 4. **AlwaysData** (Paris, France)
|
|
||||||
- **Avantages** : Hébergeur français, support Next.js, éco-responsable
|
|
||||||
- **Déploiement** : Hosting avec déploiement Git
|
|
||||||
- **Prix** : À partir de 5€/mois
|
|
||||||
- **Site** : [alwaysdata.com](https://alwaysdata.com)
|
|
||||||
|
|
||||||
#### 🌍 **Autres solutions possibles** (liste non exhaustive)
|
|
||||||
|
|
||||||
##### 5. **Render** (États-Unis)
|
|
||||||
- **Avantages** : Déploiement automatique, base de données PostgreSQL
|
|
||||||
- **Déploiement** : Connectez votre repo Git
|
|
||||||
- **Prix** : Gratuit pour les projets personnels, puis 7$/mois
|
|
||||||
- **Site** : [render.com](https://render.com)
|
|
||||||
|
|
||||||
##### 6. **Railway** (États-Unis)
|
|
||||||
- **Avantages** : Déploiement simple, base de données incluse, éthique
|
|
||||||
- **Déploiement** : Connectez votre repo Git
|
|
||||||
- **Prix** : 5$/mois (plus de gratuité pour l'open source)
|
|
||||||
- **Site** : [railway.app](https://railway.app)
|
|
||||||
|
|
||||||
##### 7. **Vercel** (États-Unis)
|
|
||||||
- **Avantages** : Optimisé pour Next.js, déploiement automatique, gratuit pour l'open source
|
|
||||||
- **Déploiement** : Connectez votre repo Git
|
|
||||||
- **Prix** : Gratuit pour les projets personnels et open source
|
|
||||||
- **Site** : [vercel.com](https://vercel.com)
|
|
||||||
|
|
||||||
##### 8. **Netlify** (États-Unis)
|
|
||||||
- **Avantages** : Interface simple, déploiement automatique, généreux pour l'open source
|
|
||||||
- **Déploiement** : Connectez votre repo Git
|
|
||||||
- **Prix** : Gratuit pour les projets personnels et open source
|
|
||||||
- **Site** : [netlify.com](https://netlify.com)
|
|
||||||
|
|
||||||
##### 9. **DigitalOcean App Platform** (États-Unis)
|
|
||||||
- **Avantages** : Déploiement simple, base de données gérée
|
|
||||||
- **Déploiement** : Interface graphique simple
|
|
||||||
- **Prix** : À partir de 5$/mois
|
|
||||||
- **Site** : [digitalocean.com](https://digitalocean.com)
|
|
||||||
|
|
||||||
### Configuration du déploiement
|
|
||||||
|
|
||||||
#### Variables d'environnement de production
|
|
||||||
```env
|
|
||||||
# Configuration Supabase (obligatoire)
|
|
||||||
NEXT_PUBLIC_SUPABASE_URL=votre_url_supabase_production
|
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=votre_cle_anon_supabase_production
|
|
||||||
SUPABASE_SERVICE_ROLE_KEY=votre_cle_service_supabase_production
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Commandes de build
|
|
||||||
```bash
|
|
||||||
# Installation des dépendances
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Build de production
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Démarrage en production
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## 🔒 Sécurité
|
## 🔒 Sécurité
|
||||||
|
|
||||||
@@ -397,9 +306,11 @@ npm run test:watch
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Couverture des tests
|
### Couverture des tests
|
||||||
- **Tests unitaires** : Utilitaires, validation, formatage
|
- **Tests unitaires** : Utilitaires, validation, formatage, parsing de messages
|
||||||
- **Tests d'intégration** : Services et API
|
- **Tests d'intégration** : Services et API, système d'email
|
||||||
- **Tests E2E** : Flux complets (Playwright)
|
- **Tests E2E** : Flux complets (Playwright)
|
||||||
|
- **Tests de sécurité** : Vérification des politiques RLS et authentification
|
||||||
|
- **Tests de composants** : Interface utilisateur et modales
|
||||||
|
|
||||||
## 📚 Documentation
|
## 📚 Documentation
|
||||||
|
|
||||||
@@ -408,11 +319,14 @@ Pour une documentation complète, consultez le dossier [docs/](docs/) :
|
|||||||
- **[Guide de démarrage](docs/README.md)** - Vue d'ensemble de la documentation
|
- **[Guide de démarrage](docs/README.md)** - Vue d'ensemble de la documentation
|
||||||
- **[Configuration](docs/SETUP.md)** - Installation et configuration détaillée
|
- **[Configuration](docs/SETUP.md)** - Installation et configuration détaillée
|
||||||
- **[Sécurité](docs/SECURITY-SUMMARY.md)** - Résumé de la sécurisation
|
- **[Sécurité](docs/SECURITY-SUMMARY.md)** - Résumé de la sécurisation
|
||||||
- **[Paramètres](docs/SETTINGS.md)** - Configuration avancée
|
- **[Gestion des administrateurs](docs/ADMIN-MANAGEMENT.md)** - Configuration des utilisateurs admin
|
||||||
|
- **[Paramètres](docs/SETTINGS.md)** - Configuration avancée et SMTP
|
||||||
- **[Tests](docs/TESTING.md)** - Guide complet des tests
|
- **[Tests](docs/TESTING.md)** - Guide complet des tests
|
||||||
- **[Tests - Résumé](docs/TESTING_SUMMARY.md)** - Résumé de la suite de tests
|
- **[Tests - Résumé](docs/TESTING_SUMMARY.md)** - Résumé de la suite de tests
|
||||||
- **[Tests - Démarrage rapide](docs/README-TESTS.md)** - Démarrage rapide des tests
|
- **[Tests - Démarrage rapide](docs/README-TESTS.md)** - Démarrage rapide des tests
|
||||||
- **[Export ODS](docs/EXPORT-FEATURE.md)** - Fonctionnalité d'export des statistiques
|
- **[Export avancé](docs/EXPORT-FEATURE.md)** - Fonctionnalités d'export multi-formats et anonymisation
|
||||||
|
- **[Architecture](docs/NEW-ARCHITECTURE.md)** - Nouvelle architecture simplifiée
|
||||||
|
- **[Structure du projet](docs/PROJECT-STRUCTURE.md)** - Organisation du code
|
||||||
|
|
||||||
## 🤝 Contribution
|
## 🤝 Contribution
|
||||||
|
|
||||||
@@ -452,4 +366,19 @@ Cette application est développée avec des valeurs éthiques :
|
|||||||
|
|
||||||
**Développé avec ❤️ pour faciliter la démocratie participative**
|
**Développé avec ❤️ pour faciliter la démocratie participative**
|
||||||
|
|
||||||
*Application complète et prête pour la production avec authentification, interface moderne, système d'email et toutes les fonctionnalités de gestion de budgets participatifs.*
|
*Application complète et prête pour la production avec authentification, interface moderne, système d'email avancé, tests complets et toutes les fonctionnalités de gestion de budgets participatifs.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 **Version actuelle : 0.2.0**
|
||||||
|
|
||||||
|
### 🆕 **Dernières améliorations**
|
||||||
|
- **Système d'email avancé** : Envoi personnalisé avec templates et placeholders
|
||||||
|
- **Interface d'envoi d'emails** : Modales dédiées pour l'envoi individuel et en masse
|
||||||
|
- **Footer personnalisable** : Messages de pied de page avec liens cliquables
|
||||||
|
- **Export multi-formats** : Support ODS, CSV, XLS avec configuration centralisée
|
||||||
|
- **Anonymisation RGPD** : 3 niveaux de protection des données personnelles
|
||||||
|
- **Export des propositions** : Export séparé des propositions par campagne
|
||||||
|
- **Tests étendus** : Couverture complète des fonctionnalités email et export
|
||||||
|
- **Gestion d'erreurs améliorée** : Messages d'erreur détaillés pour SMTP
|
||||||
|
- **HTML responsive** : Emails avec design moderne et boutons d'action
|
||||||
|
|||||||
49
clear-auth-script.js
Normal file
49
clear-auth-script.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// Script de nettoyage d'authentification Supabase
|
||||||
|
// À exécuter dans la console du navigateur (F12 > Console)
|
||||||
|
|
||||||
|
console.log('🧹 Début du nettoyage d\'authentification Supabase...');
|
||||||
|
|
||||||
|
// 1. Nettoyer localStorage
|
||||||
|
const keysToRemove = [];
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && (key.includes('supabase') || key.includes('sb-'))) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keysToRemove.forEach(key => {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
console.log('🗑️ Supprimé:', key);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Nettoyer sessionStorage
|
||||||
|
const sessionKeysToRemove = [];
|
||||||
|
for (let i = 0; i < sessionStorage.length; i++) {
|
||||||
|
const key = sessionStorage.key(i);
|
||||||
|
if (key && (key.includes('supabase') || key.includes('sb-'))) {
|
||||||
|
sessionKeysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionKeysToRemove.forEach(key => {
|
||||||
|
sessionStorage.removeItem(key);
|
||||||
|
console.log('🗑️ Supprimé (session):', key);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Nettoyer les cookies liés à Supabase
|
||||||
|
document.cookie.split(";").forEach(function(c) {
|
||||||
|
document.cookie = c.replace(/^ +/, "").replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/");
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Nettoyage terminé !');
|
||||||
|
console.log('📋 Résumé:');
|
||||||
|
console.log(`- ${keysToRemove.length} clés localStorage supprimées`);
|
||||||
|
console.log(`- ${sessionKeysToRemove.length} clés sessionStorage supprimées`);
|
||||||
|
console.log('- Cookies nettoyés');
|
||||||
|
console.log('');
|
||||||
|
console.log('🔄 Rechargez maintenant la page (F5) et essayez de vous reconnecter.');
|
||||||
|
|
||||||
|
// Optionnel: recharger automatiquement
|
||||||
|
// window.location.reload();
|
||||||
|
|
||||||
@@ -218,9 +218,11 @@ DECLARE
|
|||||||
counter INTEGER := 0;
|
counter INTEGER := 0;
|
||||||
max_attempts INTEGER := 10;
|
max_attempts INTEGER := 10;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Convertir le titre en slug (minuscules, remplacer espaces par tirets, supprimer caractères spéciaux)
|
-- Convertir le titre en slug (minuscules, supprimer accents, remplacer espaces par tirets, supprimer caractères spéciaux)
|
||||||
base_slug := lower(regexp_replace(title, '[^a-zA-Z0-9\s]', '', 'g'));
|
base_slug := lower(unaccent(title));
|
||||||
|
base_slug := regexp_replace(base_slug, '[^a-z0-9\s-]', '', 'g');
|
||||||
base_slug := regexp_replace(base_slug, '\s+', '-', 'g');
|
base_slug := regexp_replace(base_slug, '\s+', '-', 'g');
|
||||||
|
base_slug := regexp_replace(base_slug, '-+', '-', 'g');
|
||||||
base_slug := trim(both '-' from base_slug);
|
base_slug := trim(both '-' from base_slug);
|
||||||
|
|
||||||
-- Si le slug est vide, utiliser un slug par défaut
|
-- Si le slug est vide, utiliser un slug par défaut
|
||||||
@@ -347,8 +349,8 @@ CREATE TRIGGER update_user_permissions_updated_at
|
|||||||
|
|
||||||
-- Insérer les paramètres par défaut
|
-- Insérer les paramètres par défaut
|
||||||
INSERT INTO settings (key, value, category, description) VALUES
|
INSERT INTO settings (key, value, category, description) VALUES
|
||||||
('randomize_propositions', 'false', 'display', 'Afficher les propositions dans un ordre aléatoire'),
|
('randomize_propositions', 'true', 'display', 'Afficher les propositions dans un ordre aléatoire'),
|
||||||
('propose_page_message', 'Partagez votre vision et proposez des projets qui feront la différence dans votre collectif. Votre voix compte pour façonner l''avenir de votre communauté.', 'display', 'Message affiché sur la page de dépôt de propositions'),
|
('propose_page_message', 'Partagez votre vision et proposez des projets qui feront la différence dans votre collectif. Votre voix compte pour façonner l''avenir de votre communauté.', 'display', 'Message affiché sur la page de dépôt de propositions'),
|
||||||
('footer_message', 'Développé avec ❤️ pour faciliter la démocratie participative - Logiciel libre et open source', 'display', 'Message affiché en bas de page'),
|
('footer_message', 'Développé avec ❤️ pour faciliter la démocratie participative - [Logiciel libre et open source](GITURL)', 'display', 'Message affiché en bas de page'),
|
||||||
('export_anonymization', 'full', 'export', 'Niveau d''anonymisation des exports')
|
('export_anonymization', 'full', 'export', 'Niveau d''anonymisation des exports')
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ Cette catégorie contient les paramètres liés à l'affichage de l'interface ut
|
|||||||
|
|
||||||
- **Clé** : `randomize_propositions`
|
- **Clé** : `randomize_propositions`
|
||||||
- **Type** : Booléen (true/false)
|
- **Type** : Booléen (true/false)
|
||||||
- **Valeur par défaut** : `false`
|
- **Valeur par défaut** : `true`
|
||||||
- **Description** : Lorsque activé, les propositions sont affichées dans un ordre aléatoire pour chaque participant lors du vote.
|
- **Description** : Lorsque activé, les propositions sont affichées dans un ordre aléatoire pour chaque participant lors du vote.
|
||||||
|
|
||||||
**Comportement :**
|
**Comportement :**
|
||||||
|
|||||||
@@ -63,3 +63,34 @@ global.IntersectionObserver = jest.fn().mockImplementation(() => ({
|
|||||||
unobserve: jest.fn(),
|
unobserve: jest.fn(),
|
||||||
disconnect: jest.fn(),
|
disconnect: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock NextRequest and NextResponse for API routes
|
||||||
|
global.Request = global.Request || class Request {
|
||||||
|
constructor(input, init) {
|
||||||
|
this.url = typeof input === 'string' ? input : input.url;
|
||||||
|
this.method = init?.method || 'GET';
|
||||||
|
this.headers = new Map(Object.entries(init?.headers || {}));
|
||||||
|
this._body = init?.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async json() {
|
||||||
|
return JSON.parse(this._body);
|
||||||
|
}
|
||||||
|
|
||||||
|
async text() {
|
||||||
|
return this._body;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
global.Response = global.Response || class Response {
|
||||||
|
constructor(body, init) {
|
||||||
|
this.body = body;
|
||||||
|
this.status = init?.status || 200;
|
||||||
|
this.statusText = init?.statusText || 'OK';
|
||||||
|
this.headers = new Map(Object.entries(init?.headers || {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async json() {
|
||||||
|
return JSON.parse(this.body);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
188
package-lock.json
generated
188
package-lock.json
generated
@@ -21,6 +21,7 @@
|
|||||||
"@supabase/supabase-js": "^2.56.0",
|
"@supabase/supabase-js": "^2.56.0",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/nodemailer": "^7.0.1",
|
"@types/nodemailer": "^7.0.1",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/xlsx": "^0.0.35",
|
"@types/xlsx": "^0.0.35",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
"lucide-react": "^0.541.0",
|
"lucide-react": "^0.541.0",
|
||||||
"next": "15.5.0",
|
"next": "15.5.0",
|
||||||
"nodemailer": "^7.0.5",
|
"nodemailer": "^7.0.5",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
@@ -5105,6 +5107,15 @@
|
|||||||
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
|
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/qrcode": {
|
||||||
|
"version": "1.5.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.5.tgz",
|
||||||
|
"integrity": "sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.1.11",
|
"version": "19.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.11.tgz",
|
||||||
@@ -5861,7 +5872,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -5871,7 +5881,6 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
@@ -6423,7 +6432,6 @@
|
|||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -6637,7 +6645,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
@@ -6650,7 +6657,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/color-string": {
|
"node_modules/color-string": {
|
||||||
@@ -6922,6 +6928,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/decimal.js": {
|
"node_modules/decimal.js": {
|
||||||
"version": "10.6.0",
|
"version": "10.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||||
@@ -7054,6 +7069,12 @@
|
|||||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/doctrine": {
|
"node_modules/doctrine": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||||
@@ -8189,7 +8210,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
@@ -8855,7 +8875,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -11392,7 +11411,6 @@
|
|||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -11447,7 +11465,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -11632,6 +11649,15 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@@ -11785,6 +11811,127 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/querystringify": {
|
"node_modules/querystringify": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||||
@@ -11972,12 +12119,17 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/requires-port": {
|
"node_modules/requires-port": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||||
@@ -12188,6 +12340,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/set-function-length": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
@@ -12541,7 +12699,6 @@
|
|||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emoji-regex": "^8.0.0",
|
"emoji-regex": "^8.0.0",
|
||||||
@@ -12556,7 +12713,6 @@
|
|||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/string.prototype.includes": {
|
"node_modules/string.prototype.includes": {
|
||||||
@@ -12676,7 +12832,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
@@ -13476,6 +13631,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/which-typed-array": {
|
"node_modules/which-typed-array": {
|
||||||
"version": "1.1.19",
|
"version": "1.1.19",
|
||||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
|
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
|
||||||
@@ -13530,7 +13691,6 @@
|
|||||||
"version": "6.2.0",
|
"version": "6.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^4.0.0",
|
"ansi-styles": "^4.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mes-budgets-participatifs",
|
"name": "mes-budgets-participatifs",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"@supabase/supabase-js": "^2.56.0",
|
"@supabase/supabase-js": "^2.56.0",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/nodemailer": "^7.0.1",
|
"@types/nodemailer": "^7.0.1",
|
||||||
|
"@types/qrcode": "^1.5.5",
|
||||||
"@types/xlsx": "^0.0.35",
|
"@types/xlsx": "^0.0.35",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
"lucide-react": "^0.541.0",
|
"lucide-react": "^0.541.0",
|
||||||
"next": "15.5.0",
|
"next": "15.5.0",
|
||||||
"nodemailer": "^7.0.5",
|
"nodemailer": "^7.0.5",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
|||||||
120
src/__tests__/lib/footer-email.test.ts
Normal file
120
src/__tests__/lib/footer-email.test.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { parseFooterMessage } from '../../lib/utils';
|
||||||
|
import { PROJECT_CONFIG } from '../../lib/project.config';
|
||||||
|
|
||||||
|
describe('Footer Email Integration', () => {
|
||||||
|
describe('parseFooterMessage', () => {
|
||||||
|
it('should parse footer message with GITURL link', () => {
|
||||||
|
const footerMessage = 'Développé avec ❤️ pour faciliter la démocratie participative - [Logiciel libre et open source](GITURL)';
|
||||||
|
const repositoryUrl = PROJECT_CONFIG.repository.url;
|
||||||
|
|
||||||
|
const result = parseFooterMessage(footerMessage, repositoryUrl);
|
||||||
|
|
||||||
|
expect(result.text).toBe('Développé avec ❤️ pour faciliter la démocratie participative - Logiciel libre et open source');
|
||||||
|
expect(result.links).toHaveLength(1);
|
||||||
|
expect(result.links[0]).toMatchObject({
|
||||||
|
text: 'Logiciel libre et open source',
|
||||||
|
url: repositoryUrl
|
||||||
|
});
|
||||||
|
expect(result.links[0].start).toBeGreaterThan(0);
|
||||||
|
expect(result.links[0].end).toBeGreaterThan(result.links[0].start);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle footer message without links', () => {
|
||||||
|
const footerMessage = 'Simple footer message without links';
|
||||||
|
const repositoryUrl = PROJECT_CONFIG.repository.url;
|
||||||
|
|
||||||
|
const result = parseFooterMessage(footerMessage, repositoryUrl);
|
||||||
|
|
||||||
|
expect(result.text).toBe('Simple footer message without links');
|
||||||
|
expect(result.links).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple links in footer message', () => {
|
||||||
|
const footerMessage = 'Check our [docs](GITURL) and [code](GITURL)';
|
||||||
|
const repositoryUrl = PROJECT_CONFIG.repository.url;
|
||||||
|
|
||||||
|
const result = parseFooterMessage(footerMessage, repositoryUrl);
|
||||||
|
|
||||||
|
expect(result.text).toBe('Check our docs and code');
|
||||||
|
expect(result.links).toHaveLength(2);
|
||||||
|
expect(result.links[0].text).toBe('docs');
|
||||||
|
expect(result.links[1].text).toBe('code');
|
||||||
|
expect(result.links[0].url).toBe(repositoryUrl);
|
||||||
|
expect(result.links[1].url).toBe(repositoryUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty footer message', () => {
|
||||||
|
const footerMessage = '';
|
||||||
|
const repositoryUrl = PROJECT_CONFIG.repository.url;
|
||||||
|
|
||||||
|
const result = parseFooterMessage(footerMessage, repositoryUrl);
|
||||||
|
|
||||||
|
expect(result.text).toBe('');
|
||||||
|
expect(result.links).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Footer message integration in emails', () => {
|
||||||
|
it('should generate correct footer text for email HTML', () => {
|
||||||
|
const footerMessage = 'Développé avec ❤️ pour faciliter la démocratie participative - [Logiciel libre et open source](GITURL)';
|
||||||
|
const repositoryUrl = PROJECT_CONFIG.repository.url;
|
||||||
|
|
||||||
|
const { text: processedFooterText, links } = parseFooterMessage(footerMessage, repositoryUrl);
|
||||||
|
|
||||||
|
// Vérifier que le texte traité peut être utilisé dans du HTML
|
||||||
|
expect(processedFooterText).toBe('Développé avec ❤️ pour faciliter la démocratie participative - Logiciel libre et open source');
|
||||||
|
expect(processedFooterText).not.toContain('[Logiciel libre et open source](GITURL)');
|
||||||
|
expect(processedFooterText).toContain('Logiciel libre et open source');
|
||||||
|
|
||||||
|
// Vérifier que les liens sont disponibles pour générer le HTML
|
||||||
|
expect(links).toHaveLength(1);
|
||||||
|
expect(links[0].text).toBe('Logiciel libre et open source');
|
||||||
|
expect(links[0].url).toBe(repositoryUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate HTML with clickable links', () => {
|
||||||
|
const footerMessage = 'Développé avec ❤️ pour faciliter la démocratie participative - [Logiciel libre et open source](GITURL)';
|
||||||
|
const repositoryUrl = PROJECT_CONFIG.repository.url;
|
||||||
|
|
||||||
|
const { text: processedFooterText, links } = parseFooterMessage(footerMessage, repositoryUrl);
|
||||||
|
|
||||||
|
// Simuler la génération du HTML avec les liens
|
||||||
|
let footerHtml = processedFooterText;
|
||||||
|
if (links.length > 0) {
|
||||||
|
links.forEach(link => {
|
||||||
|
const linkHtml = `<a href="${link.url}" style="color: #6b7280; text-decoration: underline;" target="_blank" rel="noopener noreferrer">${link.text}</a>`;
|
||||||
|
footerHtml = footerHtml.replace(link.text, linkHtml);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vérifier que le HTML contient les liens cliquables
|
||||||
|
expect(footerHtml).toContain('<a href="' + repositoryUrl + '"');
|
||||||
|
expect(footerHtml).toContain('target="_blank"');
|
||||||
|
expect(footerHtml).toContain('rel="noopener noreferrer"');
|
||||||
|
expect(footerHtml).toContain('Logiciel libre et open source');
|
||||||
|
expect(footerHtml).not.toContain('[Logiciel libre et open source](GITURL)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle special characters in footer message', () => {
|
||||||
|
const footerMessage = 'Footer with special chars: @#$%^&*() and [link](GITURL)';
|
||||||
|
const repositoryUrl = PROJECT_CONFIG.repository.url;
|
||||||
|
|
||||||
|
const { text: processedFooterText } = parseFooterMessage(footerMessage, repositoryUrl);
|
||||||
|
|
||||||
|
expect(processedFooterText).toBe('Footer with special chars: @#$%^&*() and link');
|
||||||
|
expect(processedFooterText).toContain('@#$%^&*()');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle personalized message placeholders', () => {
|
||||||
|
const message = 'Bonjour [PRENOM], votre nom est [NOM].';
|
||||||
|
const firstName = 'Jean';
|
||||||
|
const lastName = 'Dupont';
|
||||||
|
|
||||||
|
const personalizedMessage = message
|
||||||
|
.replace(/\[PRENOM\]/g, firstName)
|
||||||
|
.replace(/\[NOM\]/g, lastName);
|
||||||
|
|
||||||
|
expect(personalizedMessage).toBe('Bonjour Jean, votre nom est Dupont.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@ import EditParticipantModal from '@/components/EditParticipantModal';
|
|||||||
import DeleteParticipantModal from '@/components/DeleteParticipantModal';
|
import DeleteParticipantModal from '@/components/DeleteParticipantModal';
|
||||||
import ImportFileModal from '@/components/ImportFileModal';
|
import ImportFileModal from '@/components/ImportFileModal';
|
||||||
import SendParticipantEmailModal from '@/components/SendParticipantEmailModal';
|
import SendParticipantEmailModal from '@/components/SendParticipantEmailModal';
|
||||||
|
import ClearAllParticipantsModal from '@/components/ClearAllParticipantsModal';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -31,6 +32,7 @@ function CampaignParticipantsPageContent() {
|
|||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
const [showSendEmailModal, setShowSendEmailModal] = useState(false);
|
const [showSendEmailModal, setShowSendEmailModal] = useState(false);
|
||||||
|
const [showClearAllModal, setShowClearAllModal] = useState(false);
|
||||||
const [selectedParticipant, setSelectedParticipant] = useState<Participant | null>(null);
|
const [selectedParticipant, setSelectedParticipant] = useState<Participant | null>(null);
|
||||||
const [copiedParticipantId, setCopiedParticipantId] = useState<string | null>(null);
|
const [copiedParticipantId, setCopiedParticipantId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -85,24 +87,51 @@ function CampaignParticipantsPageContent() {
|
|||||||
|
|
||||||
const handleImportParticipants = async (data: any[]) => {
|
const handleImportParticipants = async (data: any[]) => {
|
||||||
try {
|
try {
|
||||||
|
// Récupérer les participants existants pour vérifier les emails
|
||||||
|
const existingParticipants = await participantService.getByCampaign(campaignId);
|
||||||
|
const existingEmails = new Set(existingParticipants.map(p => p.email.toLowerCase()));
|
||||||
|
|
||||||
const participantsToCreate = data.map(row => ({
|
const participantsToCreate = data.map(row => ({
|
||||||
campaign_id: campaignId,
|
campaign_id: campaignId,
|
||||||
first_name: row.first_name || '',
|
first_name: row.Prénom || '',
|
||||||
last_name: row.last_name || '',
|
last_name: row.Nom || '',
|
||||||
email: row.email || ''
|
email: row.Email || ''
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Créer les participants un par un
|
// Filtrer les participants pour éviter les doublons d'email
|
||||||
for (const participant of participantsToCreate) {
|
const newParticipants = participantsToCreate.filter(participant => {
|
||||||
|
const email = participant.email.toLowerCase();
|
||||||
|
return email && !existingEmails.has(email);
|
||||||
|
});
|
||||||
|
|
||||||
|
const skippedCount = participantsToCreate.length - newParticipants.length;
|
||||||
|
|
||||||
|
// Créer les nouveaux participants un par un
|
||||||
|
for (const participant of newParticipants) {
|
||||||
await participantService.create(participant);
|
await participantService.create(participant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Afficher un message informatif si des participants ont été ignorés
|
||||||
|
if (skippedCount > 0) {
|
||||||
|
alert(`${skippedCount} participant(s) ignoré(s) car leur email existe déjà dans la campagne.`);
|
||||||
|
}
|
||||||
|
|
||||||
loadData();
|
loadData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de l\'import des participants:', error);
|
console.error('Erreur lors de l\'import des participants:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClearAllParticipants = async () => {
|
||||||
|
try {
|
||||||
|
await participantService.deleteAllByCampaign(campaignId);
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la suppression des participants:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getInitials = (firstName: string, lastName: string) => {
|
const getInitials = (firstName: string, lastName: string) => {
|
||||||
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
||||||
};
|
};
|
||||||
@@ -182,6 +211,16 @@ function CampaignParticipantsPageContent() {
|
|||||||
<Upload className="w-4 h-4 mr-2" />
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
Importer
|
Importer
|
||||||
</Button>
|
</Button>
|
||||||
|
{participants.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowClearAllModal(true)}
|
||||||
|
className="text-red-600 border-red-200 hover:bg-red-50 hover:border-red-300 dark:text-red-400 dark:border-red-800 dark:hover:bg-red-900/20"
|
||||||
|
>
|
||||||
|
<User className="w-4 h-4 mr-2" />
|
||||||
|
Tout effacer
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button onClick={() => setShowAddModal(true)} size="lg">
|
<Button onClick={() => setShowAddModal(true)} size="lg">
|
||||||
✨ Nouveau participant
|
✨ Nouveau participant
|
||||||
</Button>
|
</Button>
|
||||||
@@ -377,6 +416,14 @@ function CampaignParticipantsPageContent() {
|
|||||||
campaign={campaign}
|
campaign={campaign}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ClearAllParticipantsModal
|
||||||
|
isOpen={showClearAllModal}
|
||||||
|
onClose={() => setShowClearAllModal(false)}
|
||||||
|
onConfirm={handleClearAllParticipants}
|
||||||
|
campaignTitle={campaign?.title}
|
||||||
|
participantCount={participants.length}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import AddPropositionModal from '@/components/AddPropositionModal';
|
|||||||
import EditPropositionModal from '@/components/EditPropositionModal';
|
import EditPropositionModal from '@/components/EditPropositionModal';
|
||||||
import DeletePropositionModal from '@/components/DeletePropositionModal';
|
import DeletePropositionModal from '@/components/DeletePropositionModal';
|
||||||
import ImportFileModal from '@/components/ImportFileModal';
|
import ImportFileModal from '@/components/ImportFileModal';
|
||||||
|
import ExportPropositionsButton from '@/components/ExportPropositionsButton';
|
||||||
|
import ClearAllPropositionsModal from '@/components/ClearAllPropositionsModal';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ function CampaignPropositionsPageContent() {
|
|||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
const [showClearAllModal, setShowClearAllModal] = useState(false);
|
||||||
const [selectedProposition, setSelectedProposition] = useState<Proposition | null>(null);
|
const [selectedProposition, setSelectedProposition] = useState<Proposition | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -84,11 +87,11 @@ function CampaignPropositionsPageContent() {
|
|||||||
try {
|
try {
|
||||||
const propositionsToCreate = data.map(row => ({
|
const propositionsToCreate = data.map(row => ({
|
||||||
campaign_id: campaignId,
|
campaign_id: campaignId,
|
||||||
title: row.title || '',
|
title: row.Titre || '',
|
||||||
description: row.description || '',
|
description: row.Description || '',
|
||||||
author_first_name: row.author_first_name || 'admin',
|
author_first_name: row.Prénom || 'admin',
|
||||||
author_last_name: row.author_last_name || 'admin',
|
author_last_name: row.Nom || 'admin',
|
||||||
author_email: row.author_email || 'admin@example.com'
|
author_email: row.Email || 'admin@example.com'
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Créer les propositions une par une
|
// Créer les propositions une par une
|
||||||
@@ -102,7 +105,15 @@ function CampaignPropositionsPageContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClearAllPropositions = async () => {
|
||||||
|
try {
|
||||||
|
await propositionService.deleteAllByCampaign(campaignId);
|
||||||
|
loadData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la suppression des propositions:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getInitials = (firstName: string, lastName: string) => {
|
const getInitials = (firstName: string, lastName: string) => {
|
||||||
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
||||||
@@ -171,6 +182,20 @@ function CampaignPropositionsPageContent() {
|
|||||||
<Upload className="w-4 h-4 mr-2" />
|
<Upload className="w-4 h-4 mr-2" />
|
||||||
Importer
|
Importer
|
||||||
</Button>
|
</Button>
|
||||||
|
<ExportPropositionsButton
|
||||||
|
propositions={propositions}
|
||||||
|
campaignTitle={campaign.title}
|
||||||
|
/>
|
||||||
|
{propositions.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowClearAllModal(true)}
|
||||||
|
className="text-red-600 border-red-200 hover:bg-red-50 hover:border-red-300 dark:text-red-400 dark:border-red-800 dark:hover:bg-red-900/20"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4 mr-2" />
|
||||||
|
Tout effacer
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button onClick={() => setShowAddModal(true)} size="lg">
|
<Button onClick={() => setShowAddModal(true)} size="lg">
|
||||||
✨ Nouvelle proposition
|
✨ Nouvelle proposition
|
||||||
</Button>
|
</Button>
|
||||||
@@ -299,6 +324,14 @@ function CampaignPropositionsPageContent() {
|
|||||||
type="propositions"
|
type="propositions"
|
||||||
campaignTitle={campaign?.title}
|
campaignTitle={campaign?.title}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ClearAllPropositionsModal
|
||||||
|
isOpen={showClearAllModal}
|
||||||
|
onClose={() => setShowClearAllModal(false)}
|
||||||
|
onConfirm={handleClearAllPropositions}
|
||||||
|
campaignTitle={campaign?.title}
|
||||||
|
propositionCount={propositions.length}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
465
src/app/admin/campaigns/[id]/send-emails/page.tsx
Normal file
465
src/app/admin/campaigns/[id]/send-emails/page.tsx
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { Campaign, Participant } from '@/types';
|
||||||
|
import { campaignService, participantService, settingsService } from '@/lib/services';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ArrowLeft, Mail, Send, CheckCircle, XCircle, Clock, Users } from 'lucide-react';
|
||||||
|
import AuthGuard from '@/components/AuthGuard';
|
||||||
|
import Footer from '@/components/Footer';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
interface EmailProgress {
|
||||||
|
participant: Participant;
|
||||||
|
status: 'pending' | 'sending' | 'sent' | 'error';
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SendEmailsPageContent() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const campaignId = params.id as string;
|
||||||
|
|
||||||
|
const [campaign, setCampaign] = useState<Campaign | null>(null);
|
||||||
|
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [emailProgress, setEmailProgress] = useState<EmailProgress[]>([]);
|
||||||
|
const [defaultSubject, setDefaultSubject] = useState('');
|
||||||
|
const [defaultMessage, setDefaultMessage] = useState('');
|
||||||
|
const [smtpConfigured, setSmtpConfigured] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [campaignId]);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [campaignData, participantsData, smtpSettings] = await Promise.all([
|
||||||
|
campaignService.getById(campaignId),
|
||||||
|
participantService.getByCampaign(campaignId),
|
||||||
|
settingsService.getSmtpSettings()
|
||||||
|
]);
|
||||||
|
|
||||||
|
setCampaign(campaignData);
|
||||||
|
setParticipants(participantsData);
|
||||||
|
setSmtpConfigured(!!(smtpSettings.host && smtpSettings.username && smtpSettings.password));
|
||||||
|
|
||||||
|
// Initialiser le message par défaut
|
||||||
|
if (campaignData) {
|
||||||
|
setDefaultSubject(`Votez pour la campagne "${campaignData.title}"`);
|
||||||
|
setDefaultMessage(`Bonjour [PRENOM],
|
||||||
|
|
||||||
|
Vous êtes invité(e) à participer au vote pour la campagne "${campaignData.title}".
|
||||||
|
|
||||||
|
${campaignData.description}
|
||||||
|
|
||||||
|
Pour voter, cliquez sur le lien suivant :
|
||||||
|
[LIEN_DE_VOTE]
|
||||||
|
|
||||||
|
Vous disposez d'un budget de ${campaignData.budget_per_user}€ à répartir entre les propositions selon vos préférences.
|
||||||
|
|
||||||
|
Merci de votre participation !
|
||||||
|
|
||||||
|
Cordialement,`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialiser le progrès des emails
|
||||||
|
setEmailProgress(participantsData.map(participant => ({
|
||||||
|
participant,
|
||||||
|
status: 'pending' as const
|
||||||
|
})));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors du chargement des données:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSendAllEmails = async () => {
|
||||||
|
if (!campaign || !defaultSubject.trim() || !defaultMessage.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSending(true);
|
||||||
|
|
||||||
|
for (let i = 0; i < participants.length; i++) {
|
||||||
|
const participant = participants[i];
|
||||||
|
|
||||||
|
// Mettre à jour le statut à "sending"
|
||||||
|
setEmailProgress(prev => prev.map(p =>
|
||||||
|
p.participant.id === participant.id
|
||||||
|
? { ...p, status: 'sending' as const }
|
||||||
|
: p
|
||||||
|
));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Générer le lien de vote
|
||||||
|
const voteUrl = participant.short_id
|
||||||
|
? `${window.location.origin}/v/${participant.short_id}`
|
||||||
|
: `${window.location.origin}/v/EN_ATTENTE`;
|
||||||
|
|
||||||
|
// Remplacer le placeholder dans le message
|
||||||
|
const personalizedMessage = defaultMessage.replace('[LIEN_DE_VOTE]', voteUrl);
|
||||||
|
|
||||||
|
// Récupérer les paramètres SMTP
|
||||||
|
const smtpSettings = await settingsService.getSmtpSettings();
|
||||||
|
|
||||||
|
if (!smtpSettings.host || !smtpSettings.username || !smtpSettings.password) {
|
||||||
|
throw new Error('Configuration SMTP manquante');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envoyer l'email via l'API
|
||||||
|
const response = await fetch('/api/send-participant-email', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
smtpSettings,
|
||||||
|
toEmail: participant.email,
|
||||||
|
toName: `${participant.first_name} ${participant.last_name}`,
|
||||||
|
subject: defaultSubject.trim(),
|
||||||
|
message: personalizedMessage.trim(),
|
||||||
|
campaignTitle: campaign.title,
|
||||||
|
voteUrl
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Mettre à jour le statut à "sent"
|
||||||
|
setEmailProgress(prev => prev.map(p =>
|
||||||
|
p.participant.id === participant.id
|
||||||
|
? { ...p, status: 'sent' as const }
|
||||||
|
: p
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Erreur lors de l\'envoi');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Mettre à jour le statut à "error"
|
||||||
|
setEmailProgress(prev => prev.map(p =>
|
||||||
|
p.participant.id === participant.id
|
||||||
|
? { ...p, status: 'error' as const, error: error instanceof Error ? error.message : 'Erreur inconnue' }
|
||||||
|
: p
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attendre 1 seconde avant l'email suivant
|
||||||
|
if (i < participants.length - 1) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSending(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: EmailProgress['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return <Clock className="w-4 h-4 text-slate-400" />;
|
||||||
|
case 'sending':
|
||||||
|
return <Mail className="w-4 h-4 text-blue-500 animate-pulse" />;
|
||||||
|
case 'sent':
|
||||||
|
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||||
|
case 'error':
|
||||||
|
return <XCircle className="w-4 h-4 text-red-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: EmailProgress['status']) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return <Badge variant="secondary">En attente</Badge>;
|
||||||
|
case 'sending':
|
||||||
|
return <Badge variant="default" className="bg-blue-500">En cours</Badge>;
|
||||||
|
case 'sent':
|
||||||
|
return <Badge variant="default" className="bg-green-500">Envoyé</Badge>;
|
||||||
|
case 'error':
|
||||||
|
return <Badge variant="destructive">Erreur</Badge>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sentCount = emailProgress.filter(p => p.status === 'sent').length;
|
||||||
|
const errorCount = emailProgress.filter(p => p.status === 'error').length;
|
||||||
|
const progressPercentage = participants.length > 0 ? (sentCount / participants.length) * 100 : 0;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-slate-900 dark:border-slate-100 mx-auto mb-4"></div>
|
||||||
|
<p className="text-slate-600 dark:text-slate-400">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!campaign) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-4">Campagne non trouvée</h1>
|
||||||
|
<Button onClick={() => router.back()}>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Retour
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (campaign.status !== 'voting') {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-4">
|
||||||
|
Cette fonctionnalité n'est disponible que pour les campagnes en mode vote
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-600 dark:text-slate-400 mb-6">
|
||||||
|
La campagne "{campaign.title}" est actuellement en mode "{campaign.status}".
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => router.back()}>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Retour
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!smtpConfigured) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="max-w-2xl mx-auto">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-4">
|
||||||
|
Configuration SMTP requise
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-600 dark:text-slate-400">
|
||||||
|
Vous devez configurer les paramètres SMTP avant de pouvoir envoyer des emails.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<Alert>
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
Veuillez configurer les paramètres SMTP dans les paramètres de l'application avant de pouvoir envoyer des emails aux participants.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="flex gap-4 mt-6">
|
||||||
|
<Button onClick={() => router.push('/admin/settings')}>
|
||||||
|
Configurer SMTP
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={() => router.back()}>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Retour
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||||
|
<div className="container mx-auto px-4 py-8">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100 mb-2">
|
||||||
|
Envoyer des emails aux participants
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-600 dark:text-slate-400">
|
||||||
|
Campagne : {campaign.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onClick={() => router.back()}>
|
||||||
|
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||||
|
Retour
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Statistiques */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Users className="h-8 w-8 text-slate-600 dark:text-slate-400 mr-3" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Participants</p>
|
||||||
|
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{participants.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<CheckCircle className="h-8 w-8 text-green-500 mr-3" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Envoyés</p>
|
||||||
|
<p className="text-2xl font-bold text-green-600">{sentCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<XCircle className="h-8 w-8 text-red-500 mr-3" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-600 dark:text-slate-400">Erreurs</p>
|
||||||
|
<p className="text-2xl font-bold text-red-600">{errorCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Configuration de l'email */}
|
||||||
|
<Card className="mb-8">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Configuration de l'email</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Personnalisez le message qui sera envoyé à tous les participants. Utilisez [LIEN_DE_VOTE] pour insérer automatiquement le lien de vote de chaque participant.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="subject">Sujet de l'email</Label>
|
||||||
|
<input
|
||||||
|
id="subject"
|
||||||
|
type="text"
|
||||||
|
value={defaultSubject}
|
||||||
|
onChange={(e) => setDefaultSubject(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-md bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100"
|
||||||
|
placeholder="Sujet de l'email"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="message">Message</Label>
|
||||||
|
<Textarea
|
||||||
|
id="message"
|
||||||
|
value={defaultMessage}
|
||||||
|
onChange={(e) => setDefaultMessage(e.target.value)}
|
||||||
|
rows={12}
|
||||||
|
placeholder="Message de l'email..."
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleSendAllEmails}
|
||||||
|
disabled={sending || !defaultSubject.trim() || !defaultMessage.trim() || participants.length === 0}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Send className="w-4 h-4 mr-2" />
|
||||||
|
{sending ? 'Envoi en cours...' : 'Envoyer à tous'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Progression */}
|
||||||
|
{sending && (
|
||||||
|
<Card className="mb-8">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Progression de l'envoi</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
{sentCount} / {participants.length} emails envoyés
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
{Math.round(progressPercentage)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={progressPercentage} className="w-full" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Liste des participants */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Participants</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Suivi de l'envoi des emails pour chaque participant
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{emailProgress.map((progress) => (
|
||||||
|
<div key={progress.participant.id} className="flex items-center justify-between p-3 border border-slate-200 dark:border-slate-700 rounded-lg">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
{getStatusIcon(progress.status)}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-900 dark:text-slate-100">
|
||||||
|
{progress.participant.first_name} {progress.participant.last_name}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
{progress.participant.email}
|
||||||
|
</p>
|
||||||
|
{progress.error && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{progress.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{getStatusBadge(progress.status)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SendEmailsPage() {
|
||||||
|
return (
|
||||||
|
<AuthGuard>
|
||||||
|
<SendEmailsPageContent />
|
||||||
|
</AuthGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,13 +7,16 @@ import { authService } from '@/lib/auth';
|
|||||||
import CreateCampaignModal from '@/components/CreateCampaignModal';
|
import CreateCampaignModal from '@/components/CreateCampaignModal';
|
||||||
import EditCampaignModal from '@/components/EditCampaignModal';
|
import EditCampaignModal from '@/components/EditCampaignModal';
|
||||||
import DeleteCampaignModal from '@/components/DeleteCampaignModal';
|
import DeleteCampaignModal from '@/components/DeleteCampaignModal';
|
||||||
|
import ShareModal from '@/components/ShareModal';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
|
||||||
import AuthGuard from '@/components/AuthGuard';
|
import AuthGuard from '@/components/AuthGuard';
|
||||||
import { FolderOpen, Users, FileText, Plus, BarChart3, Settings, Check, Copy } from 'lucide-react';
|
import Footer from '@/components/Footer';
|
||||||
|
import VersionDisplay from '@/components/VersionDisplay';
|
||||||
|
import { FolderOpen, Users, FileText, Plus, BarChart3, Settings, Check, Copy, Mail, Share2 } from 'lucide-react';
|
||||||
import StatusSwitch from '@/components/StatusSwitch';
|
import StatusSwitch from '@/components/StatusSwitch';
|
||||||
import { MarkdownContent } from '@/components/MarkdownContent';
|
import { MarkdownContent } from '@/components/MarkdownContent';
|
||||||
|
|
||||||
@@ -26,6 +29,7 @@ function AdminPageContent() {
|
|||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
|
const [showShareModal, setShowShareModal] = useState(false);
|
||||||
const [selectedCampaign, setSelectedCampaign] = useState<Campaign | null>(null);
|
const [selectedCampaign, setSelectedCampaign] = useState<Campaign | null>(null);
|
||||||
|
|
||||||
const [copiedCampaignId, setCopiedCampaignId] = useState<string | null>(null);
|
const [copiedCampaignId, setCopiedCampaignId] = useState<string | null>(null);
|
||||||
@@ -432,12 +436,32 @@ function AdminPageContent() {
|
|||||||
<Copy className="w-3 h-3" />
|
<Copy className="w-3 h-3" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0 text-slate-400 hover:text-slate-600"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedCampaign(campaign);
|
||||||
|
setShowShareModal(true);
|
||||||
|
}}
|
||||||
|
title="Partager le lien"
|
||||||
|
>
|
||||||
|
<Share2 className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (campaign.status === 'voting' || campaign.status === 'closed') ? (
|
) : (campaign.status === 'voting' || campaign.status === 'closed') ? (
|
||||||
/* Bouton Statistiques pour les campagnes en vote/fermées */
|
/* Boutons pour les campagnes en vote/fermées */
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center gap-3">
|
||||||
|
{campaign.status === 'voting' && (
|
||||||
|
<Button asChild variant="outline" className="border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800 text-slate-700 dark:text-slate-300">
|
||||||
|
<Link href={`/admin/campaigns/${campaign.id}/send-emails`}>
|
||||||
|
<Mail className="w-4 h-4 mr-2" />
|
||||||
|
Envoyer emails
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button asChild variant="outline" className="border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800 text-slate-700 dark:text-slate-300">
|
<Button asChild variant="outline" className="border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800 text-slate-700 dark:text-slate-300">
|
||||||
<Link href={`/admin/campaigns/${campaign.id}/stats`}>
|
<Link href={`/admin/campaigns/${campaign.id}/stats`}>
|
||||||
<BarChart3 className="w-4 h-4 mr-2" />
|
<BarChart3 className="w-4 h-4 mr-2" />
|
||||||
@@ -465,6 +489,20 @@ function AdminPageContent() {
|
|||||||
{selectedCampaign && (
|
{selectedCampaign && (
|
||||||
<DeleteCampaignModal isOpen={showDeleteModal} onClose={() => setShowDeleteModal(false)} onSuccess={handleCampaignDeleted} campaign={selectedCampaign} />
|
<DeleteCampaignModal isOpen={showDeleteModal} onClose={() => setShowDeleteModal(false)} onSuccess={handleCampaignDeleted} campaign={selectedCampaign} />
|
||||||
)}
|
)}
|
||||||
|
{selectedCampaign && (
|
||||||
|
<ShareModal
|
||||||
|
isOpen={showShareModal}
|
||||||
|
onClose={() => setShowShareModal(false)}
|
||||||
|
campaignTitle={selectedCampaign.title}
|
||||||
|
depositUrl={`${window.location.origin}/p/${selectedCampaign.slug || 'campagne'}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Footer />
|
||||||
|
|
||||||
|
{/* Version Display */}
|
||||||
|
<VersionDisplay />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import { Switch } from '@/components/ui/switch';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import Navigation from '@/components/Navigation';
|
import Navigation from '@/components/Navigation';
|
||||||
import AuthGuard from '@/components/AuthGuard';
|
import AuthGuard from '@/components/AuthGuard';
|
||||||
|
import Footer from '@/components/Footer';
|
||||||
import SmtpSettingsForm from '@/components/SmtpSettingsForm';
|
import SmtpSettingsForm from '@/components/SmtpSettingsForm';
|
||||||
import { Settings, Monitor, Save, CheckCircle, Mail, FileText, Download } from 'lucide-react';
|
import { Settings, Monitor, Save, CheckCircle, Mail, FileText, Download } from 'lucide-react';
|
||||||
import { ExportAnonymizationSelect, AnonymizationLevel } from '@/components/ExportAnonymizationSelect';
|
import { ExportAnonymizationSelect, AnonymizationLevel } from '@/components/ExportAnonymizationSelect';
|
||||||
|
import { ExportFileFormatSelect, ExportFileFormat } from '@/components/ExportFileFormatSelect';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
@@ -23,6 +25,18 @@ function SettingsPageContent() {
|
|||||||
const [proposePageMessage, setProposePageMessage] = useState('');
|
const [proposePageMessage, setProposePageMessage] = useState('');
|
||||||
const [footerMessage, setFooterMessage] = useState('');
|
const [footerMessage, setFooterMessage] = useState('');
|
||||||
const [exportAnonymization, setExportAnonymization] = useState<AnonymizationLevel>('full');
|
const [exportAnonymization, setExportAnonymization] = useState<AnonymizationLevel>('full');
|
||||||
|
const [exportFileFormat, setExportFileFormat] = useState<ExportFileFormat>('ods');
|
||||||
|
|
||||||
|
// États pour la détection des modifications
|
||||||
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
|
const [originalValues, setOriginalValues] = useState<{
|
||||||
|
randomizePropositions: boolean;
|
||||||
|
proposePageMessage: string;
|
||||||
|
footerMessage: string;
|
||||||
|
exportAnonymization: AnonymizationLevel;
|
||||||
|
exportFileFormat: ExportFileFormat;
|
||||||
|
} | null>(null);
|
||||||
|
const [autoSaved, setAutoSaved] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Vérifier la configuration Supabase
|
// Vérifier la configuration Supabase
|
||||||
@@ -41,6 +55,34 @@ function SettingsPageContent() {
|
|||||||
loadSettings();
|
loadSettings();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Détecter les modifications
|
||||||
|
useEffect(() => {
|
||||||
|
if (!originalValues) return;
|
||||||
|
|
||||||
|
const hasChanges =
|
||||||
|
randomizePropositions !== originalValues.randomizePropositions ||
|
||||||
|
proposePageMessage !== originalValues.proposePageMessage ||
|
||||||
|
footerMessage !== originalValues.footerMessage ||
|
||||||
|
exportAnonymization !== originalValues.exportAnonymization ||
|
||||||
|
exportFileFormat !== originalValues.exportFileFormat;
|
||||||
|
|
||||||
|
setHasUnsavedChanges(hasChanges);
|
||||||
|
}, [randomizePropositions, proposePageMessage, footerMessage, exportAnonymization, exportFileFormat, originalValues]);
|
||||||
|
|
||||||
|
// Avertissement avant de quitter la page
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
if (hasUnsavedChanges) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = 'Vous avez des modifications non sauvegardées. Êtes-vous sûr de vouloir quitter ?';
|
||||||
|
return e.returnValue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
}, [hasUnsavedChanges]);
|
||||||
|
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -48,7 +90,7 @@ function SettingsPageContent() {
|
|||||||
setSettings(settingsData);
|
setSettings(settingsData);
|
||||||
|
|
||||||
// Charger la valeur du paramètre d'ordre aléatoire
|
// Charger la valeur du paramètre d'ordre aléatoire
|
||||||
const randomizeValue = await settingsService.getBooleanValue('randomize_propositions', false);
|
const randomizeValue = await settingsService.getBooleanValue('randomize_propositions', true);
|
||||||
setRandomizePropositions(randomizeValue);
|
setRandomizePropositions(randomizeValue);
|
||||||
|
|
||||||
// Charger le message de la page de dépôt de propositions
|
// Charger le message de la page de dépôt de propositions
|
||||||
@@ -56,12 +98,25 @@ function SettingsPageContent() {
|
|||||||
setProposePageMessage(messageValue);
|
setProposePageMessage(messageValue);
|
||||||
|
|
||||||
// Charger le message du bas de page
|
// Charger le message du bas de page
|
||||||
const footerValue = await settingsService.getStringValue('footer_message', 'Développé avec ❤️ pour faciliter la démocratie participative - [Logiciel libre et open source](GITURL) et transparent pour tous');
|
const footerValue = await settingsService.getStringValue('footer_message', 'Développé avec ❤️ pour faciliter la démocratie participative - [Logiciel libre et open source](GITURL)');
|
||||||
setFooterMessage(footerValue);
|
setFooterMessage(footerValue);
|
||||||
|
|
||||||
// Charger le niveau d'anonymisation des exports
|
// Charger le niveau d'anonymisation des exports
|
||||||
const anonymizationValue = await settingsService.getStringValue('export_anonymization', 'full') as AnonymizationLevel;
|
const anonymizationValue = await settingsService.getStringValue('export_anonymization', 'full') as AnonymizationLevel;
|
||||||
setExportAnonymization(anonymizationValue);
|
setExportAnonymization(anonymizationValue);
|
||||||
|
|
||||||
|
// Charger le format de fichier d'export
|
||||||
|
const fileFormatValue = await settingsService.getStringValue('export_file_format', 'ods') as ExportFileFormat;
|
||||||
|
setExportFileFormat(fileFormatValue);
|
||||||
|
|
||||||
|
// Stocker les valeurs originales pour la détection des modifications
|
||||||
|
setOriginalValues({
|
||||||
|
randomizePropositions: randomizeValue,
|
||||||
|
proposePageMessage: messageValue,
|
||||||
|
footerMessage: footerValue,
|
||||||
|
exportAnonymization: anonymizationValue,
|
||||||
|
exportFileFormat: fileFormatValue
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors du chargement des paramètres:', error);
|
console.error('Erreur lors du chargement des paramètres:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -71,17 +126,82 @@ function SettingsPageContent() {
|
|||||||
|
|
||||||
const handleRandomizeChange = async (checked: boolean) => {
|
const handleRandomizeChange = async (checked: boolean) => {
|
||||||
setRandomizePropositions(checked);
|
setRandomizePropositions(checked);
|
||||||
|
// Sauvegarde automatique pour ce paramètre
|
||||||
|
try {
|
||||||
|
await settingsService.setBooleanValue('randomize_propositions', checked);
|
||||||
|
// Mettre à jour les valeurs originales
|
||||||
|
if (originalValues) {
|
||||||
|
setOriginalValues({
|
||||||
|
...originalValues,
|
||||||
|
randomizePropositions: checked
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Afficher la confirmation de sauvegarde automatique
|
||||||
|
setAutoSaved(true);
|
||||||
|
setTimeout(() => setAutoSaved(false), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la sauvegarde automatique:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportAnonymizationChange = async (value: AnonymizationLevel) => {
|
||||||
|
setExportAnonymization(value);
|
||||||
|
// Sauvegarde automatique pour ce paramètre
|
||||||
|
try {
|
||||||
|
await settingsService.setStringValue('export_anonymization', value);
|
||||||
|
// Mettre à jour les valeurs originales
|
||||||
|
if (originalValues) {
|
||||||
|
setOriginalValues({
|
||||||
|
...originalValues,
|
||||||
|
exportAnonymization: value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Afficher la confirmation de sauvegarde automatique
|
||||||
|
setAutoSaved(true);
|
||||||
|
setTimeout(() => setAutoSaved(false), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la sauvegarde automatique:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportFileFormatChange = async (value: ExportFileFormat) => {
|
||||||
|
setExportFileFormat(value);
|
||||||
|
// Sauvegarde automatique pour ce paramètre
|
||||||
|
try {
|
||||||
|
await settingsService.setStringValue('export_file_format', value);
|
||||||
|
// Mettre à jour les valeurs originales
|
||||||
|
if (originalValues) {
|
||||||
|
setOriginalValues({
|
||||||
|
...originalValues,
|
||||||
|
exportFileFormat: value
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Afficher la confirmation de sauvegarde automatique
|
||||||
|
setAutoSaved(true);
|
||||||
|
setTimeout(() => setAutoSaved(false), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la sauvegarde automatique:', error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
await settingsService.setBooleanValue('randomize_propositions', randomizePropositions);
|
// Sauvegarder seulement les paramètres qui ne sont pas sauvegardés automatiquement
|
||||||
await settingsService.setStringValue('propose_page_message', proposePageMessage);
|
await settingsService.setStringValue('propose_page_message', proposePageMessage);
|
||||||
await settingsService.setStringValue('footer_message', footerMessage);
|
await settingsService.setStringValue('footer_message', footerMessage);
|
||||||
await settingsService.setStringValue('export_anonymization', exportAnonymization);
|
|
||||||
|
// Mettre à jour les valeurs originales
|
||||||
|
setOriginalValues({
|
||||||
|
randomizePropositions,
|
||||||
|
proposePageMessage,
|
||||||
|
footerMessage,
|
||||||
|
exportAnonymization,
|
||||||
|
exportFileFormat
|
||||||
|
});
|
||||||
|
|
||||||
setSaved(true);
|
setSaved(true);
|
||||||
setTimeout(() => setSaved(false), 2000);
|
setTimeout(() => setSaved(false), 3000); // Message plus long pour les textes
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la sauvegarde des paramètres:', error);
|
console.error('Erreur lors de la sauvegarde des paramètres:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -114,13 +234,36 @@ function SettingsPageContent() {
|
|||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">Paramètres</h1>
|
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100">Paramètres</h1>
|
||||||
<p className="text-slate-600 dark:text-slate-300 mt-2">Configurez les paramètres de l'application</p>
|
{hasUnsavedChanges && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 rounded-full text-sm font-medium">
|
||||||
|
<div className="w-2 h-2 bg-orange-500 rounded-full animate-pulse"></div>
|
||||||
|
Modifications non sauvegardées
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{autoSaved && (
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-full text-sm font-medium">
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
Sauvegardé automatiquement
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-600 dark:text-slate-300 mt-2">
|
||||||
|
{hasUnsavedChanges
|
||||||
|
? 'Vous avez des modifications non sauvegardées. N\'oubliez pas de cliquer sur "Sauvegarder".'
|
||||||
|
: 'Configurez les paramètres de l\'application'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving || !hasUnsavedChanges}
|
||||||
className="flex items-center gap-2"
|
className={`flex items-center gap-2 ${
|
||||||
|
hasUnsavedChanges
|
||||||
|
? 'bg-orange-600 hover:bg-orange-700 text-white'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<>
|
<>
|
||||||
@@ -206,13 +349,25 @@ function SettingsPageContent() {
|
|||||||
Ce texte apparaît sous le titre de la campagne pour inviter les utilisateurs à déposer des propositions.
|
Ce texte apparaît sous le titre de la campagne pour inviter les utilisateurs à déposer des propositions.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="relative">
|
||||||
<textarea
|
<textarea
|
||||||
id="propose-page-message"
|
id="propose-page-message"
|
||||||
value={proposePageMessage}
|
value={proposePageMessage}
|
||||||
onChange={(e) => setProposePageMessage(e.target.value)}
|
onChange={(e) => setProposePageMessage(e.target.value)}
|
||||||
className="w-full min-h-[100px] p-3 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 resize-y"
|
className={`w-full min-h-[100px] p-3 border rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 resize-y ${
|
||||||
|
originalValues && proposePageMessage !== originalValues.proposePageMessage
|
||||||
|
? 'border-orange-300 dark:border-orange-600 bg-orange-50 dark:bg-orange-900/20'
|
||||||
|
: 'border-slate-200 dark:border-slate-700'
|
||||||
|
}`}
|
||||||
placeholder="Entrez votre message d'invitation..."
|
placeholder="Entrez votre message d'invitation..."
|
||||||
/>
|
/>
|
||||||
|
{originalValues && proposePageMessage !== originalValues.proposePageMessage && (
|
||||||
|
<div className="absolute top-2 right-2 flex items-center gap-1 px-2 py-1 bg-orange-100 dark:bg-orange-900/50 text-orange-700 dark:text-orange-300 rounded text-xs font-medium">
|
||||||
|
<div className="w-1.5 h-1.5 bg-orange-500 rounded-full animate-pulse"></div>
|
||||||
|
Modifié
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Message Setting */}
|
{/* Footer Message Setting */}
|
||||||
@@ -225,13 +380,25 @@ function SettingsPageContent() {
|
|||||||
Ce texte apparaît en bas des pages publiques. Vous pouvez utiliser <code className="bg-slate-100 dark:bg-slate-700 px-1 rounded text-xs">[texte du lien](GITURL)</code> pour insérer un lien vers le repository Git.
|
Ce texte apparaît en bas des pages publiques. Vous pouvez utiliser <code className="bg-slate-100 dark:bg-slate-700 px-1 rounded text-xs">[texte du lien](GITURL)</code> pour insérer un lien vers le repository Git.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="relative">
|
||||||
<textarea
|
<textarea
|
||||||
id="footer-message"
|
id="footer-message"
|
||||||
value={footerMessage}
|
value={footerMessage}
|
||||||
onChange={(e) => setFooterMessage(e.target.value)}
|
onChange={(e) => setFooterMessage(e.target.value)}
|
||||||
className="w-full min-h-[80px] p-3 border border-slate-200 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 resize-y"
|
className={`w-full min-h-[80px] p-3 border rounded-lg bg-white dark:bg-slate-800 text-slate-900 dark:text-slate-100 resize-y ${
|
||||||
|
originalValues && footerMessage !== originalValues.footerMessage
|
||||||
|
? 'border-orange-300 dark:border-orange-600 bg-orange-50 dark:bg-orange-900/20'
|
||||||
|
: 'border-slate-200 dark:border-slate-700'
|
||||||
|
}`}
|
||||||
placeholder="Entrez votre message de bas de page..."
|
placeholder="Entrez votre message de bas de page..."
|
||||||
/>
|
/>
|
||||||
|
{originalValues && footerMessage !== originalValues.footerMessage && (
|
||||||
|
<div className="absolute top-2 right-2 flex items-center gap-1 px-2 py-1 bg-orange-100 dark:bg-orange-900/50 text-orange-700 dark:text-orange-300 rounded text-xs font-medium">
|
||||||
|
<div className="w-1.5 h-1.5 bg-orange-500 rounded-full animate-pulse"></div>
|
||||||
|
Modifié
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -252,10 +419,14 @@ function SettingsPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
<div className="p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
<div className="p-4 bg-slate-50 dark:bg-slate-800 rounded-lg space-y-6">
|
||||||
<ExportAnonymizationSelect
|
<ExportAnonymizationSelect
|
||||||
value={exportAnonymization}
|
value={exportAnonymization}
|
||||||
onValueChange={setExportAnonymization}
|
onValueChange={handleExportAnonymizationChange}
|
||||||
|
/>
|
||||||
|
<ExportFileFormatSelect
|
||||||
|
value={exportFileFormat}
|
||||||
|
onValueChange={handleExportFileFormatChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -267,6 +438,9 @@ function SettingsPageContent() {
|
|||||||
setTimeout(() => setSaved(false), 2000);
|
setTimeout(() => setSaved(false), 2000);
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
38
src/app/api/clear-auth/route.ts
Normal file
38
src/app/api/clear-auth/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { supabase } from '@/lib/supabase';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
console.log('🧹 Nettoyage de l\'état d\'authentification...');
|
||||||
|
|
||||||
|
// Déconnexion forcée
|
||||||
|
const { error } = await supabase.auth.signOut();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.warn('⚠️ Erreur lors de la déconnexion:', error.message);
|
||||||
|
} else {
|
||||||
|
console.log('✅ Déconnexion réussie');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nettoyer le localStorage côté client
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'État d\'authentification nettoyé',
|
||||||
|
instructions: [
|
||||||
|
'1. Ouvrez les outils de développement (F12)',
|
||||||
|
'2. Allez dans l\'onglet Application/Storage',
|
||||||
|
'3. Supprimez toutes les entrées liées à Supabase dans localStorage',
|
||||||
|
'4. Rechargez la page',
|
||||||
|
'5. Essayez de vous reconnecter'
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Erreur lors du nettoyage:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Erreur lors du nettoyage: ${error.message}` },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import * as nodemailer from 'nodemailer';
|
import * as nodemailer from 'nodemailer';
|
||||||
import { SmtpSettings } from '@/types';
|
import { SmtpSettings } from '@/types';
|
||||||
|
import { settingsService } from '@/lib/services';
|
||||||
|
import { parseFooterMessage } from '@/lib/utils';
|
||||||
|
import { PROJECT_CONFIG } from '@/lib/project.config';
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -59,19 +62,44 @@ export async function POST(request: NextRequest) {
|
|||||||
// Vérifier la connexion
|
// Vérifier la connexion
|
||||||
await transporter.verify();
|
await transporter.verify();
|
||||||
|
|
||||||
|
// Récupérer le message du footer depuis les paramètres
|
||||||
|
let footerMessage = '';
|
||||||
|
try {
|
||||||
|
footerMessage = await settingsService.getStringValue(
|
||||||
|
'footer_message',
|
||||||
|
'Développé avec ❤️ pour faciliter la démocratie participative - [Logiciel libre et open source](GITURL)'
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Erreur lors de la récupération du message du footer:', error);
|
||||||
|
footerMessage = 'Développé avec ❤️ pour faciliter la démocratie participative - [Logiciel libre et open source](GITURL)';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traiter le message du footer pour remplacer les liens
|
||||||
|
const { text: processedFooterText, links } = parseFooterMessage(footerMessage, PROJECT_CONFIG.repository.url);
|
||||||
|
|
||||||
|
// Générer le HTML du footer avec les liens cliquables
|
||||||
|
let footerHtml = processedFooterText;
|
||||||
|
if (links.length > 0) {
|
||||||
|
// Remplacer les liens par des balises <a> HTML
|
||||||
|
links.forEach(link => {
|
||||||
|
const linkHtml = `<a href="${link.url}" style="color: #6b7280; text-decoration: underline;" target="_blank" rel="noopener noreferrer">${link.text}</a>`;
|
||||||
|
footerHtml = footerHtml.replace(link.text, linkHtml);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traiter le message pour remplacer les placeholders [NOM] et [PRENOM]
|
||||||
|
const firstName = toName.split(' ')[0];
|
||||||
|
const lastName = toName.split(' ').slice(1).join(' ');
|
||||||
|
let personalizedMessage = message
|
||||||
|
.replace(/\[PRENOM\]/g, firstName)
|
||||||
|
.replace(/\[NOM\]/g, lastName);
|
||||||
|
|
||||||
// Créer le contenu HTML de l'email
|
// Créer le contenu HTML de l'email
|
||||||
const htmlContent = `
|
const htmlContent = `
|
||||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; line-height: 1.6;">
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; line-height: 1.6;">
|
||||||
<div style="background-color: #2563eb; color: white; padding: 20px; text-align: center; border-radius: 8px 8px 0 0;">
|
<div style="background-color: #ffffff; padding: 30px; border: 1px solid #e5e7eb; border-radius: 8px;">
|
||||||
<h1 style="margin: 0; font-size: 24px;">Mes Budgets Participatifs</h1>
|
<div style="color: #374151; font-size: 16px; margin-bottom: 30px;">
|
||||||
</div>
|
${personalizedMessage.replace(/\n/g, '<br>')}
|
||||||
|
|
||||||
<div style="background-color: #ffffff; padding: 30px; border: 1px solid #e5e7eb; border-top: none;">
|
|
||||||
<h2 style="color: #1f2937; margin-top: 0;">Bonjour ${toName},</h2>
|
|
||||||
|
|
||||||
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
|
||||||
<h3 style="margin-top: 0; color: #374151;">Campagne : ${campaignTitle}</h3>
|
|
||||||
<p style="margin-bottom: 0; color: #6b7280;">${message.replace(/\n/g, '<br>')}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="text-align: center; margin: 30px 0;">
|
<div style="text-align: center; margin: 30px 0;">
|
||||||
@@ -102,6 +130,10 @@ export async function POST(request: NextRequest) {
|
|||||||
Cet email a été envoyé automatiquement par Mes Budgets Participatifs.<br>
|
Cet email a été envoyé automatiquement par Mes Budgets Participatifs.<br>
|
||||||
Si vous avez des questions, contactez l'administrateur de la campagne.
|
Si vous avez des questions, contactez l'administrateur de la campagne.
|
||||||
</p>
|
</p>
|
||||||
|
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 15px 0;">
|
||||||
|
<p style="color: #9ca3af; font-size: 11px; margin: 0;">
|
||||||
|
${footerHtml}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -186,9 +186,9 @@ SUPABASE_SERVICE_ROLE_KEY=${body.supabaseServiceKey}
|
|||||||
// 7. Ajouter des paramètres par défaut
|
// 7. Ajouter des paramètres par défaut
|
||||||
try {
|
try {
|
||||||
const defaultSettings = [
|
const defaultSettings = [
|
||||||
{ key: 'randomize_propositions', value: 'false', category: 'display', description: 'Afficher les propositions dans un ordre aléatoire' },
|
{ key: 'randomize_propositions', value: 'true', category: 'display', description: 'Afficher les propositions dans un ordre aléatoire' },
|
||||||
{ key: 'propose_page_message', value: 'Partagez votre vision et proposez des projets qui feront la différence dans votre collectif. Votre voix compte pour façonner l\'avenir de votre communauté.', category: 'display', description: 'Message affiché sur la page de dépôt de propositions' },
|
{ key: 'propose_page_message', value: 'Partagez votre vision et proposez des projets qui feront la différence dans votre collectif. Votre voix compte pour façonner l\'avenir de votre communauté.', category: 'display', description: 'Message affiché sur la page de dépôt de propositions' },
|
||||||
{ key: 'footer_message', value: 'Développé avec ❤️ pour faciliter la démocratie participative - Logiciel libre et open source', category: 'display', description: 'Message affiché en bas de page' },
|
{ key: 'footer_message', value: 'Développé avec ❤️ pour faciliter la démocratie participative - [Logiciel libre et open source](GITURL)', category: 'display', description: 'Message affiché en bas de page' },
|
||||||
{ key: 'export_anonymization', value: 'full', category: 'export', description: 'Niveau d\'anonymisation des exports' }
|
{ key: 'export_anonymization', value: 'full', category: 'export', description: 'Niveau d\'anonymisation des exports' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export default function PublicVotePage() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Vérifier si l'ordre aléatoire est activé
|
// Vérifier si l'ordre aléatoire est activé
|
||||||
const randomizePropositions = await settingsService.getBooleanValue('randomize_propositions', false);
|
const randomizePropositions = await settingsService.getBooleanValue('randomize_propositions', true);
|
||||||
|
|
||||||
if (randomizePropositions) {
|
if (randomizePropositions) {
|
||||||
// Mélanger les propositions de manière aléatoire
|
// Mélanger les propositions de manière aléatoire
|
||||||
@@ -348,20 +348,22 @@ export default function PublicVotePage() {
|
|||||||
<div className="min-h-screen bg-gray-50 vote-page">
|
<div className="min-h-screen bg-gray-50 vote-page">
|
||||||
{/* Header fixe avec le total et le bouton de validation */}
|
{/* Header fixe avec le total et le bouton de validation */}
|
||||||
<div className="sticky top-0 z-40 bg-white shadow-sm border-b border-gray-200">
|
<div className="sticky top-0 z-40 bg-white shadow-sm border-b border-gray-200">
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-3 sm:py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-2 sm:space-x-4 min-w-0 flex-1">
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<h1 className="text-lg font-semibold text-gray-900">{campaign?.title}</h1>
|
<h1 className="text-sm sm:text-lg font-bold text-indigo-600">
|
||||||
<p className="text-lg font-bold text-indigo-600">
|
{participant?.first_name}
|
||||||
{participant?.first_name} {participant?.last_name}
|
</h1>
|
||||||
</p>
|
<h2 className="text-sm sm:text-lg font-bold text-indigo-600">
|
||||||
|
{participant?.last_name}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-2 sm:space-x-4 flex-shrink-0">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className={`text-2xl font-bold transition-all duration-300 ${
|
<div className={`text-lg sm:text-2xl font-bold transition-all duration-300 ${
|
||||||
isOverBudget
|
isOverBudget
|
||||||
? 'text-red-600 animate-pulse'
|
? 'text-red-600 animate-pulse'
|
||||||
: totalVoted === campaign?.budget_per_user
|
: totalVoted === campaign?.budget_per_user
|
||||||
@@ -372,25 +374,31 @@ export default function PublicVotePage() {
|
|||||||
} ${isOverBudget ? 'animate-bounce' : ''}`}>
|
} ${isOverBudget ? 'animate-bounce' : ''}`}>
|
||||||
{totalVoted}€ / {campaign?.budget_per_user}€
|
{totalVoted}€ / {campaign?.budget_per_user}€
|
||||||
</div>
|
</div>
|
||||||
<div className={`text-sm font-medium transition-colors duration-300 ${
|
<div className={`text-xs sm:text-sm font-medium transition-colors duration-300 leading-tight ${
|
||||||
voteStatus.status === 'success' ? 'text-green-600' :
|
voteStatus.status === 'success' ? 'text-green-600' :
|
||||||
voteStatus.status === 'warning' ? 'text-yellow-600' :
|
voteStatus.status === 'warning' ? 'text-yellow-600' :
|
||||||
'text-red-600'
|
'text-red-600'
|
||||||
}`}>
|
}`}>
|
||||||
{voteStatus.message}
|
{voteStatus.message.split(' ').map((word, index, array) => (
|
||||||
|
<span key={index}>
|
||||||
|
{word}
|
||||||
|
{index < array.length - 1 && index === Math.floor(array.length / 2) - 1 ? <br /> : ' '}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={saving || totalVoted !== campaign?.budget_per_user}
|
disabled={saving || totalVoted !== campaign?.budget_per_user}
|
||||||
className={`px-6 py-3 text-sm font-medium rounded-lg transition-all duration-200 ${
|
className={`px-3 sm:px-6 py-2 sm:py-3 text-xs sm:text-sm font-medium rounded-lg transition-all duration-200 ${
|
||||||
totalVoted === campaign?.budget_per_user
|
totalVoted === campaign?.budget_per_user
|
||||||
? 'bg-green-600 text-white hover:bg-green-700 shadow-lg'
|
? 'bg-green-600 text-white hover:bg-green-700 shadow-lg'
|
||||||
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{saving ? 'Enregistrement...' : 'Valider mon vote'}
|
<span className="hidden sm:inline">{saving ? 'Enregistrement...' : 'Valider mon vote'}</span>
|
||||||
|
<span className="sm:hidden">{saving ? '...' : 'Valider'}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -402,6 +410,7 @@ export default function PublicVotePage() {
|
|||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-1 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-1 gap-6">
|
||||||
<div>
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-4">{campaign?.title}</h2>
|
||||||
<MarkdownContent
|
<MarkdownContent
|
||||||
content={campaign?.description || ''}
|
content={campaign?.description || ''}
|
||||||
className="mt-1 text-base font-medium text-gray-900"
|
className="mt-1 text-base font-medium text-gray-900"
|
||||||
@@ -472,6 +481,46 @@ export default function PublicVotePage() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Slider */}
|
{/* Slider */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
<style jsx>{`
|
||||||
|
.slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: #e5e7eb;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.slider::-webkit-slider-track {
|
||||||
|
background: #e5e7eb;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: #4f46e5;
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: -6px;
|
||||||
|
}
|
||||||
|
.slider::-moz-range-track {
|
||||||
|
background: #e5e7eb;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.slider::-moz-range-thumb {
|
||||||
|
background: #4f46e5;
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -483,42 +532,79 @@ export default function PublicVotePage() {
|
|||||||
const amount = index === 0 ? 0 : spendingTiers[index - 1];
|
const amount = index === 0 ? 0 : spendingTiers[index - 1];
|
||||||
handleVoteChange(proposition.id, amount);
|
handleVoteChange(proposition.id, amount);
|
||||||
}}
|
}}
|
||||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer slider"
|
className="w-full h-2 slider"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Marqueurs des paliers */}
|
{/* Marqueurs des paliers */}
|
||||||
<div className="relative mt-3 mb-16" style={{ marginLeft: '12px', marginRight: '24px' }}>
|
<div className="relative mt-3 mb-16" style={{ marginLeft: '6px', marginRight: '6px' }}>
|
||||||
|
{/* Fonction pour formater les montants */}
|
||||||
|
{(() => {
|
||||||
|
const formatAmount = (amount: number, isMobile: boolean) => {
|
||||||
|
if (!isMobile) return `${amount}€`;
|
||||||
|
|
||||||
|
// Formatage court sur mobile pour les montants longs
|
||||||
|
if (amount >= 1000) {
|
||||||
|
if (amount % 1000 === 0) {
|
||||||
|
return `${amount / 1000}k€`;
|
||||||
|
} else {
|
||||||
|
return `${(amount / 1000).toFixed(1)}k€`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${amount}€`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 640;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
{/* Marqueur 0€ */}
|
{/* Marqueur 0€ */}
|
||||||
<div className="absolute text-center" style={{ left: '0%', transform: 'translateX(-12px)' }}>
|
<div
|
||||||
<div className="w-3 h-3 bg-gray-400 rounded-full mx-auto mb-2"></div>
|
className="absolute text-center cursor-pointer hover:scale-110 transition-transform"
|
||||||
<span className="text-xs text-gray-600 font-medium whitespace-nowrap">0€</span>
|
style={{ left: '0%', transform: 'translateX(-6px)' }}
|
||||||
|
onClick={() => handleVoteChange(proposition.id, 0)}
|
||||||
|
>
|
||||||
|
<div className="w-3 h-3 bg-gray-400 rounded-full mx-auto mb-2 hover:bg-gray-500 transition-colors"></div>
|
||||||
|
<span className="text-xs text-gray-600 font-medium whitespace-nowrap hover:text-gray-800 transition-colors">
|
||||||
|
{formatAmount(0, isMobile)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Marqueurs des paliers */}
|
{/* Marqueurs des paliers */}
|
||||||
{spendingTiers.map((tier, index) => {
|
{spendingTiers.map((tier, index) => {
|
||||||
// Calcul correct de la position pour correspondre au slider
|
// Position uniforme : espacement égal entre tous les marqueurs
|
||||||
|
// Le dernier palier doit être à 100%
|
||||||
const position = ((index + 1) / spendingTiers.length) * 100;
|
const position = ((index + 1) / spendingTiers.length) * 100;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`tier-${index}-${tier}`}
|
key={`tier-${index}-${tier}`}
|
||||||
className="absolute text-center"
|
className="absolute text-center cursor-pointer hover:scale-110 transition-transform"
|
||||||
style={{
|
style={{
|
||||||
left: `${position}%`,
|
left: `${position}%`,
|
||||||
transform: 'translateX(-12px)'
|
transform: 'translateX(-6px)'
|
||||||
}}
|
}}
|
||||||
|
onClick={() => handleVoteChange(proposition.id, tier)}
|
||||||
>
|
>
|
||||||
<div className="w-3 h-3 bg-indigo-500 rounded-full mx-auto mb-2"></div>
|
<div className="w-3 h-3 bg-indigo-500 rounded-full mx-auto mb-2 hover:bg-indigo-600 transition-colors"></div>
|
||||||
<span className="text-xs text-gray-600 font-medium whitespace-nowrap">{tier}€</span>
|
<span className="text-xs text-gray-600 font-medium whitespace-nowrap hover:text-gray-800 transition-colors">
|
||||||
|
{formatAmount(tier, isMobile)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Valeur sélectionnée */}
|
{/* Valeur sélectionnée */}
|
||||||
{(localVotes[proposition.id] && localVotes[proposition.id] > 0) && !isCompactView && (
|
{(localVotes[proposition.id] && localVotes[proposition.id] > 0) && !isCompactView && (
|
||||||
<div className="text-center mt-12">
|
<div className="text-center mt-12">
|
||||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-indigo-100 text-indigo-800">
|
<span
|
||||||
|
className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-indigo-100 text-indigo-800 cursor-pointer hover:bg-indigo-200 transition-colors"
|
||||||
|
onClick={() => handleVoteChange(proposition.id, 0)}
|
||||||
|
title="Cliquer pour remettre à 0€"
|
||||||
|
>
|
||||||
Vote sélectionné : {localVotes[proposition.id]}€
|
Vote sélectionné : {localVotes[proposition.id]}€
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
186
src/app/clear-auth/page.tsx
Normal file
186
src/app/clear-auth/page.tsx
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { Loader2, CheckCircle, AlertCircle, Trash2, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function ClearAuthPage() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [localStorageCleared, setLocalStorageCleared] = useState(false);
|
||||||
|
|
||||||
|
const clearServerAuth = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
setSuccess(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/clear-auth', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setSuccess(true);
|
||||||
|
} else {
|
||||||
|
setError(result.error || 'Erreur lors du nettoyage serveur');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
setError(error.message || 'Erreur lors du nettoyage serveur');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearLocalStorage = () => {
|
||||||
|
try {
|
||||||
|
// Supprimer toutes les clés liées à Supabase
|
||||||
|
const keysToRemove = [];
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
if (key && (key.includes('supabase') || key.includes('sb-'))) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keysToRemove.forEach(key => {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
setLocalStorageCleared(true);
|
||||||
|
console.log('🧹 localStorage nettoyé:', keysToRemove);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Erreur lors du nettoyage localStorage:', error);
|
||||||
|
setError('Erreur lors du nettoyage localStorage');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reloadPage = () => {
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-900 py-8">
|
||||||
|
<div className="container mx-auto px-4 max-w-2xl">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900 dark:text-slate-100 mb-2">
|
||||||
|
🧹 Nettoyage d'Authentification
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-600 dark:text-slate-400">
|
||||||
|
Résoudre les problèmes de session Supabase
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Trash2 className="h-5 w-5" />
|
||||||
|
Nettoyer l'état d'authentification
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
<strong>Problème détecté :</strong> AuthSessionMissingError
|
||||||
|
<br />
|
||||||
|
Cette erreur indique que Supabase ne peut pas récupérer votre session d'authentification.
|
||||||
|
<br />
|
||||||
|
<strong>Solution :</strong> Nettoyez l'état d'authentification et reconnectez-vous.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
onClick={clearServerAuth}
|
||||||
|
disabled={loading}
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="h-4 w-4 animate-spin mr-2" />}
|
||||||
|
Nettoyer côté serveur
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={clearLocalStorage}
|
||||||
|
disabled={localStorageCleared}
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
Nettoyer localStorage
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={reloadPage}
|
||||||
|
className="w-full"
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2" />
|
||||||
|
Recharger la page
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<Alert>
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
Nettoyage serveur réussi ! Maintenant nettoyez le localStorage et rechargez la page.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{localStorageCleared && (
|
||||||
|
<Alert>
|
||||||
|
<CheckCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>
|
||||||
|
localStorage nettoyé ! Rechargez maintenant la page pour finaliser.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-slate-100 dark:bg-slate-800 p-4 rounded-lg">
|
||||||
|
<h3 className="font-semibold mb-2">📋 Instructions détaillées :</h3>
|
||||||
|
<ol className="list-decimal list-inside space-y-1 text-sm">
|
||||||
|
<li>Cliquez sur "Nettoyer côté serveur"</li>
|
||||||
|
<li>Cliquez sur "Nettoyer localStorage"</li>
|
||||||
|
<li>Cliquez sur "Recharger la page"</li>
|
||||||
|
<li>Allez sur <code>/debug-auth</code> pour vous reconnecter</li>
|
||||||
|
<li>Ou allez directement sur <code>/admin</code></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg">
|
||||||
|
<h3 className="font-semibold mb-2 text-blue-800 dark:text-blue-200">
|
||||||
|
💡 Après le nettoyage :
|
||||||
|
</h3>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
<li>Votre session sera complètement réinitialisée</li>
|
||||||
|
<li>Vous devrez vous reconnecter avec vos identifiants admin</li>
|
||||||
|
<li>Utilisez la page <code>/debug-auth</code> pour une connexion rapide</li>
|
||||||
|
<li>Ou connectez-vous normalement sur <code>/admin</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -8,6 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { PROJECT_CONFIG } from '@/lib/project.config';
|
import { PROJECT_CONFIG } from '@/lib/project.config';
|
||||||
import Footer from '@/components/Footer';
|
import Footer from '@/components/Footer';
|
||||||
|
import VersionDisplay from '@/components/VersionDisplay';
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -196,6 +197,9 @@ export default function HomePage() {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<Footer variant="home" />
|
<Footer variant="home" />
|
||||||
|
|
||||||
|
{/* Version Display */}
|
||||||
|
<VersionDisplay />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { authService } from '@/lib/auth';
|
import { authService } from '@/lib/auth';
|
||||||
|
import { supabase } from '@/lib/supabase';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -34,10 +35,14 @@ export default function AuthGuard({ children, requireSuperAdmin = false }: AuthG
|
|||||||
const checkAuth = async () => {
|
const checkAuth = async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
console.log('🔍 AuthGuard: Vérification de l\'authentification...');
|
||||||
|
|
||||||
// Vérifier si l'utilisateur est connecté
|
// Vérifier si l'utilisateur est connecté directement avec supabase
|
||||||
const user = await authService.getCurrentUser();
|
const { data: { user }, error: userError } = await supabase.auth.getUser();
|
||||||
if (!user) {
|
console.log('👤 AuthGuard: Utilisateur actuel:', user ? user.email : 'Aucun');
|
||||||
|
|
||||||
|
if (userError || !user) {
|
||||||
|
console.log('❌ AuthGuard: Aucun utilisateur connecté');
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setIsAuthorized(false);
|
setIsAuthorized(false);
|
||||||
setShowLogin(true);
|
setShowLogin(true);
|
||||||
@@ -45,21 +50,39 @@ export default function AuthGuard({ children, requireSuperAdmin = false }: AuthG
|
|||||||
}
|
}
|
||||||
|
|
||||||
setIsAuthenticated(true);
|
setIsAuthenticated(true);
|
||||||
|
console.log('✅ AuthGuard: Utilisateur authentifié');
|
||||||
|
|
||||||
|
// Vérifier les permissions directement
|
||||||
|
const { data: permissions, error: permissionsError } = await supabase
|
||||||
|
.from('user_permissions')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (permissionsError) {
|
||||||
|
console.error('❌ AuthGuard: Erreur permissions:', permissionsError);
|
||||||
|
setIsAuthorized(false);
|
||||||
|
setError('Erreur lors de la vérification des permissions');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Vérifier les permissions
|
|
||||||
if (requireSuperAdmin) {
|
if (requireSuperAdmin) {
|
||||||
const isSuperAdmin = await authService.isSuperAdmin();
|
const isSuperAdmin = permissions.is_super_admin;
|
||||||
|
console.log('🔐 AuthGuard: Super Admin:', isSuperAdmin);
|
||||||
setIsAuthorized(isSuperAdmin);
|
setIsAuthorized(isSuperAdmin);
|
||||||
} else {
|
} else {
|
||||||
const isAdmin = await authService.isAdmin();
|
const isAdmin = permissions.is_admin;
|
||||||
|
console.log('🔐 AuthGuard: Admin:', isAdmin);
|
||||||
setIsAuthorized(isAdmin);
|
setIsAuthorized(isAdmin);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAuthorized) {
|
if (!isAuthorized) {
|
||||||
setError('Vous n\'avez pas les permissions nécessaires pour accéder à cette page.');
|
setError('Vous n\'avez pas les permissions nécessaires pour accéder à cette page.');
|
||||||
|
} else {
|
||||||
|
console.log('✅ AuthGuard: Permissions vérifiées, accès autorisé');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de la vérification d\'authentification:', error);
|
console.error('❌ AuthGuard: Erreur lors de la vérification d\'authentification:', error);
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setIsAuthorized(false);
|
setIsAuthorized(false);
|
||||||
setShowLogin(true);
|
setShowLogin(true);
|
||||||
@@ -74,11 +97,53 @@ export default function AuthGuard({ children, requireSuperAdmin = false }: AuthG
|
|||||||
setIsLoggingIn(true);
|
setIsLoggingIn(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authService.signIn(email, password);
|
console.log('🔐 AuthGuard: Tentative de connexion directe...');
|
||||||
await checkAuth();
|
|
||||||
|
// Utiliser directement supabase.auth.signInWithPassword comme dans admin-login
|
||||||
|
const { data, error: loginError } = await supabase.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loginError) {
|
||||||
|
console.error('❌ AuthGuard: Erreur de connexion:', loginError);
|
||||||
|
setError(`Erreur: ${loginError.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ AuthGuard: Connexion réussie, vérification des permissions...');
|
||||||
|
|
||||||
|
// Vérifier les permissions directement
|
||||||
|
const { data: permissions, error: permissionsError } = await supabase
|
||||||
|
.from('user_permissions')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', data.user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (permissionsError) {
|
||||||
|
console.error('❌ AuthGuard: Erreur permissions:', permissionsError);
|
||||||
|
setError('Erreur lors de la vérification des permissions');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requireSuperAdmin && !permissions.is_super_admin) {
|
||||||
|
setError('Vous n\'avez pas les permissions de super administrateur');
|
||||||
|
return;
|
||||||
|
} else if (!requireSuperAdmin && !permissions.is_admin) {
|
||||||
|
setError('Vous n\'avez pas les permissions administrateur');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ AuthGuard: Permissions vérifiées, accès autorisé');
|
||||||
|
|
||||||
|
// Mettre à jour les états
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
setIsAuthorized(true);
|
||||||
|
setShowLogin(false);
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Erreur de connexion:', error);
|
console.error('❌ AuthGuard: Exception:', error);
|
||||||
setError(error.message || 'Erreur lors de la connexion');
|
setError(`Erreur: ${error.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoggingIn(false);
|
setIsLoggingIn(false);
|
||||||
}
|
}
|
||||||
@@ -86,7 +151,7 @@ export default function AuthGuard({ children, requireSuperAdmin = false }: AuthG
|
|||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
try {
|
try {
|
||||||
await authService.signOut();
|
await supabase.auth.signOut();
|
||||||
setIsAuthenticated(false);
|
setIsAuthenticated(false);
|
||||||
setIsAuthorized(false);
|
setIsAuthorized(false);
|
||||||
setShowLogin(true);
|
setShowLogin(true);
|
||||||
|
|||||||
125
src/components/ClearAllParticipantsModal.tsx
Normal file
125
src/components/ClearAllParticipantsModal.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { AlertTriangle, Trash2 } from 'lucide-react';
|
||||||
|
import { BaseModal } from './base/BaseModal';
|
||||||
|
import { ErrorDisplay } from './base/ErrorDisplay';
|
||||||
|
|
||||||
|
interface ClearAllParticipantsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => Promise<void>;
|
||||||
|
campaignTitle?: string;
|
||||||
|
participantCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClearAllParticipantsModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
campaignTitle,
|
||||||
|
participantCount
|
||||||
|
}: ClearAllParticipantsModalProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onConfirm();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la suppression des participants:', error);
|
||||||
|
setError('Erreur lors de la suppression des participants.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (!loading) {
|
||||||
|
setError('');
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const footer = (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={handleClose} disabled={loading}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
Suppression...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Tout effacer
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
title="Effacer tous les participants"
|
||||||
|
description={`Cette action supprimera définitivement tous les participants de la campagne.${campaignTitle ? ` Campagne : ${campaignTitle}` : ''}`}
|
||||||
|
footer={footer}
|
||||||
|
maxWidth="sm:max-w-md"
|
||||||
|
>
|
||||||
|
<ErrorDisplay error={error} />
|
||||||
|
|
||||||
|
<Alert className="border-red-200 bg-red-50 dark:bg-red-900/20 dark:border-red-800">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||||
|
<AlertDescription className="text-red-800 dark:text-red-200">
|
||||||
|
<strong>Attention :</strong> Cette action est irréversible.
|
||||||
|
{participantCount > 0 && (
|
||||||
|
<>
|
||||||
|
{' '}Vous êtes sur le point de supprimer <strong>{participantCount} participant{participantCount > 1 ? 's' : ''}</strong>.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="mt-4 p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||||
|
<h4 className="font-medium text-slate-900 dark:text-slate-100 mb-2">
|
||||||
|
Que sera supprimé :
|
||||||
|
</h4>
|
||||||
|
<ul className="text-sm text-slate-600 dark:text-slate-300 space-y-1">
|
||||||
|
<li>• Tous les participants de la campagne</li>
|
||||||
|
<li>• Les noms et prénoms des participants</li>
|
||||||
|
<li>• Les adresses email des participants</li>
|
||||||
|
<li>• Tous les votes associés aux participants</li>
|
||||||
|
<li>• Les liens de vote personnalisés</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||||
|
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||||
|
Ce qui sera conservé :
|
||||||
|
</h4>
|
||||||
|
<ul className="text-sm text-blue-800 dark:text-blue-200 space-y-1">
|
||||||
|
<li>• La campagne elle-même</li>
|
||||||
|
<li>• Les propositions</li>
|
||||||
|
<li>• Les paramètres de la campagne</li>
|
||||||
|
<li>• L'historique des modifications</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</BaseModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/components/ClearAllPropositionsModal.tsx
Normal file
124
src/components/ClearAllPropositionsModal.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
|
import { AlertTriangle, Trash2 } from 'lucide-react';
|
||||||
|
import { BaseModal } from './base/BaseModal';
|
||||||
|
import { ErrorDisplay } from './base/ErrorDisplay';
|
||||||
|
|
||||||
|
interface ClearAllPropositionsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => Promise<void>;
|
||||||
|
campaignTitle?: string;
|
||||||
|
propositionCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClearAllPropositionsModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
campaignTitle,
|
||||||
|
propositionCount
|
||||||
|
}: ClearAllPropositionsModalProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await onConfirm();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la suppression des propositions:', error);
|
||||||
|
setError('Erreur lors de la suppression des propositions.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (!loading) {
|
||||||
|
setError('');
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const footer = (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={handleClose} disabled={loading}>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={loading}
|
||||||
|
className="bg-red-600 hover:bg-red-700"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
Suppression...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Tout effacer
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={handleClose}
|
||||||
|
title="Effacer toutes les propositions"
|
||||||
|
description={`Cette action supprimera définitivement toutes les propositions de la campagne.${campaignTitle ? ` Campagne : ${campaignTitle}` : ''}`}
|
||||||
|
footer={footer}
|
||||||
|
maxWidth="sm:max-w-md"
|
||||||
|
>
|
||||||
|
<ErrorDisplay error={error} />
|
||||||
|
|
||||||
|
<Alert className="border-red-200 bg-red-50 dark:bg-red-900/20 dark:border-red-800">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||||
|
<AlertDescription className="text-red-800 dark:text-red-200">
|
||||||
|
<strong>Attention :</strong> Cette action est irréversible.
|
||||||
|
{propositionCount > 0 && (
|
||||||
|
<>
|
||||||
|
{' '}Vous êtes sur le point de supprimer <strong>{propositionCount} proposition{propositionCount > 1 ? 's' : ''}</strong>.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className="mt-4 p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||||
|
<h4 className="font-medium text-slate-900 dark:text-slate-100 mb-2">
|
||||||
|
Que sera supprimé :
|
||||||
|
</h4>
|
||||||
|
<ul className="text-sm text-slate-600 dark:text-slate-300 space-y-1">
|
||||||
|
<li>• Toutes les propositions de la campagne</li>
|
||||||
|
<li>• Les titres et descriptions des propositions</li>
|
||||||
|
<li>• Les informations des auteurs (noms, emails)</li>
|
||||||
|
<li>• Toutes les données associées</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||||
|
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||||
|
Ce qui sera conservé :
|
||||||
|
</h4>
|
||||||
|
<ul className="text-sm text-blue-800 dark:text-blue-200 space-y-1">
|
||||||
|
<li>• La campagne elle-même</li>
|
||||||
|
<li>• Les participants</li>
|
||||||
|
<li>• Les votes déjà effectués</li>
|
||||||
|
<li>• Les paramètres de la campagne</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</BaseModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/components/ExportFileFormatSelect.tsx
Normal file
46
src/components/ExportFileFormatSelect.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
|
export type ExportFileFormat = 'ods' | 'csv' | 'xls';
|
||||||
|
|
||||||
|
interface ExportFileFormatSelectProps {
|
||||||
|
value: ExportFileFormat;
|
||||||
|
onValueChange: (value: ExportFileFormat) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportFileFormatSelect({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
disabled = false
|
||||||
|
}: ExportFileFormatSelectProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="export-file-format">Format de fichier d'export</Label>
|
||||||
|
<Select value={value} onValueChange={onValueChange} disabled={disabled}>
|
||||||
|
<SelectTrigger id="export-file-format" className="w-full">
|
||||||
|
<SelectValue placeholder="Sélectionner un format" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ods" className="flex flex-col items-start py-3">
|
||||||
|
<span className="font-medium">ODS (OpenDocument)</span>
|
||||||
|
<span className="text-sm text-slate-500">Recommandé - Libre et compatible</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="csv" className="flex flex-col items-start py-3">
|
||||||
|
<span className="font-medium">CSV (Valeurs séparées par des virgules)</span>
|
||||||
|
<span className="text-sm text-slate-500">Universel - Compatible avec tous les tableurs</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="xls" className="flex flex-col items-start py-3">
|
||||||
|
<span className="font-medium">XLS (Microsoft Office)</span>
|
||||||
|
<span className="text-sm text-slate-500">Propriétaire - Compatible Excel</span>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Le format ODS est recommandé car il est libre, ouvert et compatible avec la plupart des tableurs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/components/ExportPropositionsButton.tsx
Normal file
59
src/components/ExportPropositionsButton.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Download } from 'lucide-react';
|
||||||
|
import { Proposition } from '@/types';
|
||||||
|
import { generatePropositionsExport, downloadExportFile, formatPropositionsFilename } from '@/lib/export-utils';
|
||||||
|
|
||||||
|
interface ExportPropositionsButtonProps {
|
||||||
|
propositions: Proposition[];
|
||||||
|
campaignTitle: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ExportPropositionsButton({
|
||||||
|
propositions,
|
||||||
|
campaignTitle,
|
||||||
|
disabled = false
|
||||||
|
}: ExportPropositionsButtonProps) {
|
||||||
|
const [isExporting, setIsExporting] = useState(false);
|
||||||
|
|
||||||
|
const handleExport = async () => {
|
||||||
|
if (propositions.length === 0) {
|
||||||
|
alert('Aucune proposition à exporter.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsExporting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Générer le fichier dans le format configuré
|
||||||
|
const { data, format } = await generatePropositionsExport(propositions, campaignTitle);
|
||||||
|
|
||||||
|
// Créer le nom de fichier avec l'extension appropriée
|
||||||
|
const filename = formatPropositionsFilename(campaignTitle, format);
|
||||||
|
|
||||||
|
// Télécharger le fichier
|
||||||
|
downloadExportFile(data, filename, format);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de l\'export des propositions:', error);
|
||||||
|
alert('Erreur lors de l\'export des propositions. Veuillez réessayer.');
|
||||||
|
} finally {
|
||||||
|
setIsExporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={disabled || isExporting || propositions.length === 0}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
{isExporting ? 'Export en cours...' : 'Exporter'}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Download, FileSpreadsheet } from 'lucide-react';
|
import { Download, FileSpreadsheet } from 'lucide-react';
|
||||||
import { generateVoteExportODS, downloadODS, formatFilename, ExportData, AnonymizationLevel } from '@/lib/export-utils';
|
import { generateVoteExport, downloadExportFile, formatFilename, ExportData, AnonymizationLevel } from '@/lib/export-utils';
|
||||||
import { settingsService } from '@/lib/services';
|
import { settingsService } from '@/lib/services';
|
||||||
|
|
||||||
interface ExportStatsButtonProps {
|
interface ExportStatsButtonProps {
|
||||||
@@ -46,10 +46,10 @@ export function ExportStatsButton({
|
|||||||
anonymizationLevel
|
anonymizationLevel
|
||||||
};
|
};
|
||||||
|
|
||||||
const odsData = generateVoteExportODS(exportData);
|
const { data, format } = await generateVoteExport(exportData);
|
||||||
const filename = formatFilename(campaignTitle);
|
const filename = formatFilename(campaignTitle, format);
|
||||||
|
|
||||||
downloadODS(odsData, filename);
|
downloadExportFile(data, filename, format);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Erreur lors de l\'export:', error);
|
console.error('Erreur lors de l\'export:', error);
|
||||||
// Ici on pourrait ajouter une notification d'erreur
|
// Ici on pourrait ajouter une notification d'erreur
|
||||||
@@ -75,7 +75,7 @@ export function ExportStatsButton({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<FileSpreadsheet className="h-4 w-4" />
|
<FileSpreadsheet className="h-4 w-4" />
|
||||||
Exporter les votes (ODS)
|
Exporter les votes
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -22,19 +22,19 @@ export default function Footer({ className = '', variant = 'public' }: FooterPro
|
|||||||
|
|
||||||
if (!supabaseUrl || !supabaseAnonKey || supabaseUrl === 'https://placeholder.supabase.co') {
|
if (!supabaseUrl || !supabaseAnonKey || supabaseUrl === 'https://placeholder.supabase.co') {
|
||||||
// Supabase n'est pas configuré, utiliser le message par défaut
|
// Supabase n'est pas configuré, utiliser le message par défaut
|
||||||
setFooterMessage('Développé avec ❤️ pour faciliter la démocratie participative - [Logiciel libre et open source](GITURL) et transparent pour tous');
|
setFooterMessage('Développé avec ❤️ pour faciliter la démocratie participative - [Logiciel libre et open source](GITURL)');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = await settingsService.getStringValue(
|
const message = await settingsService.getStringValue(
|
||||||
'footer_message',
|
'footer_message',
|
||||||
'Développé avec ❤️ pour faciliter la démocratie participative - [Logiciel libre et open source](GITURL) et transparent pour tous'
|
'Développé avec ❤️ pour faciliter la démocratie participative - [Logiciel libre et open source](GITURL)'
|
||||||
);
|
);
|
||||||
setFooterMessage(message);
|
setFooterMessage(message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignorer silencieusement les erreurs et utiliser le message par défaut
|
// Ignorer silencieusement les erreurs et utiliser le message par défaut
|
||||||
setFooterMessage('Développé avec ❤️ pour faciliter la démocratie participative - [Logiciel libre et open source](GITURL) et transparent pour tous');
|
setFooterMessage('Développé avec ❤️ pour faciliter la démocratie participative - [Logiciel libre et open source](GITURL)');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -49,18 +49,7 @@ export default function Footer({ className = '', variant = 'public' }: FooterPro
|
|||||||
|
|
||||||
const { text: processedText, links } = parseFooterMessage(footerMessage, PROJECT_CONFIG.repository.url);
|
const { text: processedText, links } = parseFooterMessage(footerMessage, PROJECT_CONFIG.repository.url);
|
||||||
|
|
||||||
// Pour la page d'accueil, utiliser un style plus simple
|
// Fonction pour rendre le texte avec les liens cliquables
|
||||||
if (variant === 'home') {
|
|
||||||
return (
|
|
||||||
<div className={`text-center mt-16 pb-8 ${className}`}>
|
|
||||||
<p className="text-slate-600 dark:text-slate-400 text-lg">
|
|
||||||
{processedText}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pour les pages publiques, utiliser un style plus discret avec liens
|
|
||||||
const renderFooterText = () => {
|
const renderFooterText = () => {
|
||||||
if (links.length === 0) {
|
if (links.length === 0) {
|
||||||
return processedText;
|
return processedText;
|
||||||
@@ -83,7 +72,7 @@ export default function Footer({ className = '', variant = 'public' }: FooterPro
|
|||||||
href={link.url}
|
href={link.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-gray-500 hover:text-gray-700 underline"
|
className={variant === 'home' ? "text-slate-600 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 underline" : "text-gray-500 hover:text-gray-700 underline"}
|
||||||
>
|
>
|
||||||
{link.text}
|
{link.text}
|
||||||
</a>
|
</a>
|
||||||
@@ -100,6 +89,17 @@ export default function Footer({ className = '', variant = 'public' }: FooterPro
|
|||||||
return elements;
|
return elements;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Pour la page d'accueil, utiliser un style plus simple mais avec liens cliquables
|
||||||
|
if (variant === 'home') {
|
||||||
|
return (
|
||||||
|
<div className={`text-center mt-16 pb-8 ${className}`}>
|
||||||
|
<p className="text-slate-600 dark:text-slate-400 text-lg">
|
||||||
|
{renderFooterText()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`text-center mt-16 pb-20 ${className}`}>
|
<div className={`text-center mt-16 pb-20 ${className}`}>
|
||||||
<p className="text-gray-400 text-sm">
|
<p className="text-gray-400 text-sm">
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export default function ImportFileModal({
|
|||||||
Téléchargez le modèle
|
Téléchargez le modèle
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" onClick={() => downloadTemplate(type)}>
|
<Button variant="outline" size="sm" onClick={async () => await downloadTemplate(type)}>
|
||||||
<Download className="w-4 h-4 mr-1" />
|
<Download className="w-4 h-4 mr-1" />
|
||||||
Modèle
|
Modèle
|
||||||
</Button>
|
</Button>
|
||||||
@@ -158,7 +158,7 @@ export default function ImportFileModal({
|
|||||||
<table className="w-full text-sm table-fixed">
|
<table className="w-full text-sm table-fixed">
|
||||||
<thead className="bg-slate-50 dark:bg-slate-800">
|
<thead className="bg-slate-50 dark:bg-slate-800">
|
||||||
<tr>
|
<tr>
|
||||||
{Object.keys(preview[0] || {}).map((header) => (
|
{getExpectedColumns(type).map((header) => (
|
||||||
<th key={header} className="px-2 py-1 text-left font-medium truncate">
|
<th key={header} className="px-2 py-1 text-left font-medium truncate">
|
||||||
{header}
|
{header}
|
||||||
</th>
|
</th>
|
||||||
@@ -168,9 +168,9 @@ export default function ImportFileModal({
|
|||||||
<tbody>
|
<tbody>
|
||||||
{preview.map((row, index) => (
|
{preview.map((row, index) => (
|
||||||
<tr key={index} className="border-t">
|
<tr key={index} className="border-t">
|
||||||
{Object.values(row).map((value, cellIndex) => (
|
{getExpectedColumns(type).map((header) => (
|
||||||
<td key={cellIndex} className="px-2 py-1 text-xs truncate">
|
<td key={header} className="px-2 py-1 text-xs truncate">
|
||||||
{String(value)}
|
{String(row[header] || '')}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export default function SendParticipantEmailModal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && campaign && participant) {
|
if (isOpen && campaign && participant) {
|
||||||
setSubject(`Votez pour la campagne "${campaign.title}"`);
|
setSubject(`Votez pour la campagne "${campaign.title}"`);
|
||||||
setMessage(`Bonjour ${participant.first_name},
|
setMessage(`Bonjour [PRENOM],
|
||||||
|
|
||||||
Vous êtes invité(e) à participer au vote pour la campagne "${campaign.title}".
|
Vous êtes invité(e) à participer au vote pour la campagne "${campaign.title}".
|
||||||
|
|
||||||
|
|||||||
221
src/components/ShareModal.tsx
Normal file
221
src/components/ShareModal.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
import { BaseModal } from './base/BaseModal';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Copy, Check, Share2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ShareModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
campaignTitle: string;
|
||||||
|
depositUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShareModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
campaignTitle,
|
||||||
|
depositUrl
|
||||||
|
}: ShareModalProps) {
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [qrCodeError, setQrCodeError] = useState<string | null>(null);
|
||||||
|
const [qrCodeLoading, setQrCodeLoading] = useState(false);
|
||||||
|
const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && depositUrl) {
|
||||||
|
setQrCodeLoading(true);
|
||||||
|
setQrCodeError(null);
|
||||||
|
setQrCodeDataUrl(null);
|
||||||
|
|
||||||
|
// Essayer d'abord de générer en tant que Data URL
|
||||||
|
QRCode.toDataURL(depositUrl, {
|
||||||
|
width: 300,
|
||||||
|
margin: 2,
|
||||||
|
color: {
|
||||||
|
dark: '#000000',
|
||||||
|
light: '#FFFFFF'
|
||||||
|
},
|
||||||
|
errorCorrectionLevel: 'M'
|
||||||
|
}).then((dataUrl) => {
|
||||||
|
console.log('QR code généré avec succès (DataURL) pour:', depositUrl);
|
||||||
|
setQrCodeDataUrl(dataUrl);
|
||||||
|
setQrCodeLoading(false);
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('Erreur lors de la génération du QR code (DataURL):', err);
|
||||||
|
|
||||||
|
// Fallback: essayer avec le canvas
|
||||||
|
if (canvasRef.current) {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (ctx) {
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
QRCode.toCanvas(canvas, depositUrl, {
|
||||||
|
width: 300,
|
||||||
|
margin: 2,
|
||||||
|
color: {
|
||||||
|
dark: '#000000',
|
||||||
|
light: '#FFFFFF'
|
||||||
|
},
|
||||||
|
errorCorrectionLevel: 'M'
|
||||||
|
}).then(() => {
|
||||||
|
console.log('QR code généré avec succès (Canvas) pour:', depositUrl);
|
||||||
|
setQrCodeLoading(false);
|
||||||
|
}).catch((canvasErr) => {
|
||||||
|
console.error('Erreur lors de la génération du QR code (Canvas):', canvasErr);
|
||||||
|
setQrCodeError('Erreur lors de la génération du QR code');
|
||||||
|
setQrCodeLoading(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setQrCodeError('Erreur lors de la génération du QR code');
|
||||||
|
setQrCodeLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isOpen, depositUrl]);
|
||||||
|
|
||||||
|
const handleCopyLink = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(depositUrl);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erreur lors de la copie:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const retryQrCode = () => {
|
||||||
|
if (depositUrl) {
|
||||||
|
setQrCodeLoading(true);
|
||||||
|
setQrCodeError(null);
|
||||||
|
setQrCodeDataUrl(null);
|
||||||
|
|
||||||
|
QRCode.toDataURL(depositUrl, {
|
||||||
|
width: 300,
|
||||||
|
margin: 2,
|
||||||
|
color: {
|
||||||
|
dark: '#000000',
|
||||||
|
light: '#FFFFFF'
|
||||||
|
},
|
||||||
|
errorCorrectionLevel: 'M'
|
||||||
|
}).then((dataUrl) => {
|
||||||
|
setQrCodeDataUrl(dataUrl);
|
||||||
|
setQrCodeLoading(false);
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('Erreur lors de la génération du QR code:', err);
|
||||||
|
setQrCodeError('Erreur lors de la génération du QR code');
|
||||||
|
setQrCodeLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseModal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Share2 className="w-5 h-5 text-blue-600" />
|
||||||
|
Partager le lien de dépôt
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
description={`Pour partager le lien de dépôt public de propositions pour la campagne "${campaignTitle}"`}
|
||||||
|
maxWidth="sm:max-w-[600px]"
|
||||||
|
maxHeight="max-h-[90vh]"
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Lien de dépôt */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||||
|
Lien de dépôt public
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-slate-50 dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700">
|
||||||
|
<div className="flex-1 text-sm font-mono text-slate-700 dark:text-slate-300 break-all">
|
||||||
|
{depositUrl}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<Check className="w-4 h-4 mr-2" />
|
||||||
|
Copié !
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Copy className="w-4 h-4 mr-2" />
|
||||||
|
Copier
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QR Code */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||||
|
QR Code
|
||||||
|
</h3>
|
||||||
|
<div className="flex justify-center p-6 bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-700">
|
||||||
|
{qrCodeLoading && (
|
||||||
|
<div className="flex items-center justify-center w-[300px] h-[300px]">
|
||||||
|
<div className="text-slate-500">Génération du QR code...</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{qrCodeError && (
|
||||||
|
<div className="flex items-center justify-center w-[300px] h-[300px]">
|
||||||
|
<div className="text-red-500 text-center">
|
||||||
|
<div className="text-sm">{qrCodeError}</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-2"
|
||||||
|
onClick={retryQrCode}
|
||||||
|
>
|
||||||
|
Réessayer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{qrCodeDataUrl && !qrCodeLoading && !qrCodeError && (
|
||||||
|
<img
|
||||||
|
src={qrCodeDataUrl}
|
||||||
|
alt="QR Code pour le lien de dépôt"
|
||||||
|
className="border border-slate-200 dark:border-slate-600 rounded-lg"
|
||||||
|
width={300}
|
||||||
|
height={300}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={300}
|
||||||
|
height={300}
|
||||||
|
className={`border border-slate-200 dark:border-slate-600 rounded-lg ${qrCodeDataUrl || qrCodeLoading || qrCodeError ? 'hidden' : 'block'}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-400 text-center">
|
||||||
|
Scannez ce QR code pour accéder directement au formulaire de dépôt
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
<div className="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||||
|
<h4 className="font-medium text-blue-900 dark:text-blue-100 mb-2">
|
||||||
|
Comment partager ?
|
||||||
|
</h4>
|
||||||
|
<ul className="text-sm text-blue-800 dark:text-blue-200 space-y-1">
|
||||||
|
<li>• Copiez le lien ci-dessus pour le partager par email, SMS ou message</li>
|
||||||
|
<li>• Imprimez ou affichez le QR code pour un accès rapide</li>
|
||||||
|
<li>• Partagez l'image du QR code sur les réseaux sociaux</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</BaseModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -225,9 +225,11 @@ DECLARE
|
|||||||
counter INTEGER := 0;
|
counter INTEGER := 0;
|
||||||
max_attempts INTEGER := 10;
|
max_attempts INTEGER := 10;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Convertir le titre en slug (minuscules, remplacer espaces par tirets, supprimer caractères spéciaux)
|
-- Convertir le titre en slug (minuscules, supprimer accents, remplacer espaces par tirets, supprimer caractères spéciaux)
|
||||||
base_slug := lower(regexp_replace(title, '[^a-zA-Z0-9\s]', '', 'g'));
|
base_slug := lower(unaccent(title));
|
||||||
|
base_slug := regexp_replace(base_slug, '[^a-z0-9\s-]', '', 'g');
|
||||||
base_slug := regexp_replace(base_slug, '\s+', '-', 'g');
|
base_slug := regexp_replace(base_slug, '\s+', '-', 'g');
|
||||||
|
base_slug := regexp_replace(base_slug, '-+', '-', 'g');
|
||||||
base_slug := trim(both '-' from base_slug);
|
base_slug := trim(both '-' from base_slug);
|
||||||
|
|
||||||
-- Si le slug est vide, utiliser un slug par défaut
|
-- Si le slug est vide, utiliser un slug par défaut
|
||||||
@@ -354,9 +356,9 @@ CREATE TRIGGER update_user_permissions_updated_at
|
|||||||
|
|
||||||
-- Insérer les paramètres par défaut
|
-- Insérer les paramètres par défaut
|
||||||
INSERT INTO settings (key, value, category, description) VALUES
|
INSERT INTO settings (key, value, category, description) VALUES
|
||||||
('randomize_propositions', 'false', 'display', 'Afficher les propositions dans un ordre aléatoire'),
|
('randomize_propositions', 'true', 'display', 'Afficher les propositions dans un ordre aléatoire'),
|
||||||
('propose_page_message', 'Partagez votre vision et proposez des projets qui feront la différence dans votre collectif. Votre voix compte pour façonner l''avenir de votre communauté.', 'display', 'Message affiché sur la page de dépôt de propositions'),
|
('propose_page_message', 'Partagez votre vision et proposez des projets qui feront la différence dans votre collectif. Votre voix compte pour façonner l''avenir de votre communauté.', 'display', 'Message affiché sur la page de dépôt de propositions'),
|
||||||
('footer_message', 'Développé avec ❤️ pour faciliter la démocratie participative - Logiciel libre et open source', 'display', 'Message affiché en bas de page'),
|
('footer_message', 'Développé avec ❤️ pour faciliter la démocratie participative - [Logiciel libre et open source](GITURL)', 'display', 'Message affiché en bas de page'),
|
||||||
('export_anonymization', 'full', 'export', 'Niveau d''anonymisation des exports')
|
('export_anonymization', 'full', 'export', 'Niveau d''anonymisation des exports')
|
||||||
ON CONFLICT (key) DO NOTHING;`;
|
ON CONFLICT (key) DO NOTHING;`;
|
||||||
|
|
||||||
|
|||||||
28
src/components/VersionDisplay.tsx
Normal file
28
src/components/VersionDisplay.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export default function VersionDisplay() {
|
||||||
|
const [version, setVersion] = useState<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Récupérer la version depuis package.json
|
||||||
|
fetch('/package.json')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => setVersion(data.version))
|
||||||
|
.catch(() => {
|
||||||
|
// Fallback si le fichier n'est pas accessible
|
||||||
|
setVersion('0.2.0');
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!version) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="text-center py-2">
|
||||||
|
<span className="text-xs text-slate-400 dark:text-slate-500">
|
||||||
|
v{version}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,9 +12,26 @@ export interface UserPermissions {
|
|||||||
export const authService = {
|
export const authService = {
|
||||||
// Vérifier si l'utilisateur actuel est connecté
|
// Vérifier si l'utilisateur actuel est connecté
|
||||||
async getCurrentUser() {
|
async getCurrentUser() {
|
||||||
|
try {
|
||||||
const { data: { user }, error } = await supabase.auth.getUser();
|
const { data: { user }, error } = await supabase.auth.getUser();
|
||||||
if (error) throw error;
|
if (error) {
|
||||||
|
console.error('❌ Erreur getCurrentUser:', error);
|
||||||
|
// Si c'est une erreur de session manquante, retourner null au lieu de throw
|
||||||
|
if (error.message?.includes('Auth session missing') || error.message?.includes('session_not_found')) {
|
||||||
|
console.log('🔍 Session manquante, utilisateur non connecté');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return user;
|
return user;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('❌ Exception getCurrentUser:', error);
|
||||||
|
// Gérer les erreurs de session manquante
|
||||||
|
if (error.message?.includes('Auth session missing') || error.message?.includes('session_not_found')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Vérifier si l'utilisateur actuel est admin
|
// Vérifier si l'utilisateur actuel est admin
|
||||||
@@ -87,18 +104,56 @@ export const authService = {
|
|||||||
|
|
||||||
// Connexion
|
// Connexion
|
||||||
async signIn(email: string, password: string) {
|
async signIn(email: string, password: string) {
|
||||||
|
try {
|
||||||
|
console.log('🔐 Tentative de connexion pour:', email);
|
||||||
|
|
||||||
const { data, error } = await supabase.auth.signInWithPassword({
|
const { data, error } = await supabase.auth.signInWithPassword({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
|
||||||
|
if (error) {
|
||||||
|
console.error('❌ Erreur de connexion:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Connexion réussie pour:', email);
|
||||||
return data;
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Exception lors de la connexion:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Déconnexion
|
// Déconnexion
|
||||||
async signOut() {
|
async signOut() {
|
||||||
|
try {
|
||||||
|
// Déconnexion standard
|
||||||
const { error } = await supabase.auth.signOut();
|
const { error } = await supabase.auth.signOut();
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
|
||||||
|
// Nettoyage supplémentaire pour éviter les problèmes de session
|
||||||
|
// Supprimer tous les tokens du localStorage
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const keys = Object.keys(localStorage);
|
||||||
|
keys.forEach(key => {
|
||||||
|
if (key.startsWith('sb-') || key.includes('supabase')) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Supprimer aussi du sessionStorage
|
||||||
|
const sessionKeys = Object.keys(sessionStorage);
|
||||||
|
sessionKeys.forEach(key => {
|
||||||
|
if (key.startsWith('sb-') || key.includes('supabase')) {
|
||||||
|
sessionStorage.removeItem(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la déconnexion:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// Inscription (pour les tests)
|
// Inscription (pour les tests)
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import { Proposition, Participant, Vote } from '@/types';
|
import { Proposition, Participant, Vote } from '@/types';
|
||||||
|
import { settingsService } from './services';
|
||||||
|
|
||||||
|
export type ExportFileFormat = 'ods' | 'csv' | 'xls';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtient le format de fichier d'export configuré dans les paramètres
|
||||||
|
*/
|
||||||
|
export async function getExportFileFormat(): Promise<ExportFileFormat> {
|
||||||
|
try {
|
||||||
|
const format = await settingsService.getStringValue('export_file_format', 'ods');
|
||||||
|
return format as ExportFileFormat;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erreur lors de la récupération du format d\'export:', error);
|
||||||
|
return 'ods'; // Format par défaut
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExportData {
|
export interface ExportData {
|
||||||
campaignTitle: string;
|
campaignTitle: string;
|
||||||
@@ -25,6 +41,73 @@ export interface PropositionStats {
|
|||||||
consensusScore: number;
|
consensusScore: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function generateVoteExport(data: ExportData): Promise<{ data: Uint8Array | string; format: ExportFileFormat }> {
|
||||||
|
const format = await getExportFileFormat();
|
||||||
|
|
||||||
|
// Créer la matrice de données
|
||||||
|
const matrix: (string | number)[][] = [];
|
||||||
|
|
||||||
|
// Pour les formats Excel/ODS, ajouter un titre
|
||||||
|
if (format !== 'csv') {
|
||||||
|
matrix.push([`Statistiques de vote - ${data.campaignTitle}`]);
|
||||||
|
matrix.push([]); // Ligne vide
|
||||||
|
}
|
||||||
|
|
||||||
|
// En-têtes des colonnes : propositions + total
|
||||||
|
const headers = ['Participant', ...data.propositions.map(p => p.title), 'Total voté', 'Budget restant'];
|
||||||
|
matrix.push(headers);
|
||||||
|
|
||||||
|
// Données des participants
|
||||||
|
data.participants.forEach(participant => {
|
||||||
|
const row: (string | number)[] = [];
|
||||||
|
|
||||||
|
// Nom du participant (avec anonymisation)
|
||||||
|
const participantName = anonymizeParticipantName(participant, data.anonymizationLevel || 'full');
|
||||||
|
row.push(participantName);
|
||||||
|
|
||||||
|
// Votes pour chaque proposition
|
||||||
|
let totalVoted = 0;
|
||||||
|
data.propositions.forEach(proposition => {
|
||||||
|
const vote = data.votes.find(v =>
|
||||||
|
v.participant_id === participant.id &&
|
||||||
|
v.proposition_id === proposition.id
|
||||||
|
);
|
||||||
|
const amount = vote ? vote.amount : 0;
|
||||||
|
row.push(amount);
|
||||||
|
totalVoted += amount;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Total voté par le participant
|
||||||
|
row.push(totalVoted);
|
||||||
|
|
||||||
|
// Budget restant
|
||||||
|
const budgetRemaining = data.budgetPerUser - totalVoted;
|
||||||
|
row.push(budgetRemaining);
|
||||||
|
|
||||||
|
matrix.push(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ligne des totaux
|
||||||
|
const totalRow: (string | number)[] = ['TOTAL'];
|
||||||
|
let grandTotal = 0;
|
||||||
|
|
||||||
|
data.propositions.forEach(proposition => {
|
||||||
|
const propositionTotal = data.votes
|
||||||
|
.filter(v => v.proposition_id === proposition.id)
|
||||||
|
.reduce((sum, vote) => sum + vote.amount, 0);
|
||||||
|
totalRow.push(propositionTotal);
|
||||||
|
grandTotal += propositionTotal;
|
||||||
|
});
|
||||||
|
|
||||||
|
totalRow.push(grandTotal);
|
||||||
|
totalRow.push(data.participants.length * data.budgetPerUser - grandTotal);
|
||||||
|
matrix.push(totalRow);
|
||||||
|
|
||||||
|
const exportData = generateExportFile(matrix, format);
|
||||||
|
return { data: exportData, format };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction de compatibilité (à supprimer plus tard)
|
||||||
export function generateVoteExportODS(data: ExportData): Uint8Array {
|
export function generateVoteExportODS(data: ExportData): Uint8Array {
|
||||||
const { campaignTitle, propositions, participants, votes, budgetPerUser, propositionStats, anonymizationLevel = 'full' } = data;
|
const { campaignTitle, propositions, participants, votes, budgetPerUser, propositionStats, anonymizationLevel = 'full' } = data;
|
||||||
|
|
||||||
@@ -248,6 +331,66 @@ export function generateVoteExportODS(data: ExportData): Uint8Array {
|
|||||||
return new Uint8Array(odsBuffer);
|
return new Uint8Array(odsBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Génère un fichier d'export dans le format spécifié
|
||||||
|
*/
|
||||||
|
export function generateExportFile(matrix: (string | number)[][], format: ExportFileFormat): Uint8Array | string {
|
||||||
|
if (format === 'csv') {
|
||||||
|
// Générer du CSV
|
||||||
|
return matrix.map(row =>
|
||||||
|
row.map(cell => {
|
||||||
|
const cellStr = String(cell || '');
|
||||||
|
// Échapper les guillemets et entourer de guillemets si nécessaire
|
||||||
|
if (cellStr.includes(',') || cellStr.includes('"') || cellStr.includes('\n')) {
|
||||||
|
return `"${cellStr.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
return cellStr;
|
||||||
|
}).join(',')
|
||||||
|
).join('\n');
|
||||||
|
} else {
|
||||||
|
// Générer un fichier Excel/ODS
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
const worksheet = XLSX.utils.aoa_to_sheet(matrix);
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, 'Données');
|
||||||
|
|
||||||
|
const buffer = XLSX.write(workbook, {
|
||||||
|
bookType: format,
|
||||||
|
type: 'array'
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Uint8Array(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Télécharge un fichier d'export
|
||||||
|
*/
|
||||||
|
export function downloadExportFile(data: Uint8Array | string, filename: string, format: ExportFileFormat): void {
|
||||||
|
let blob: Blob;
|
||||||
|
let mimeType: string;
|
||||||
|
|
||||||
|
if (format === 'csv') {
|
||||||
|
mimeType = 'text/csv;charset=utf-8';
|
||||||
|
blob = new Blob([data as string], { type: mimeType });
|
||||||
|
} else {
|
||||||
|
mimeType = format === 'ods'
|
||||||
|
? 'application/vnd.oasis.opendocument.spreadsheet'
|
||||||
|
: 'application/vnd.ms-excel';
|
||||||
|
blob = new Blob([data as Uint8Array], { type: mimeType });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = filename;
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
export function downloadODS(data: Uint8Array, filename: string): void {
|
export function downloadODS(data: Uint8Array, filename: string): void {
|
||||||
const blob = new Blob([data], {
|
const blob = new Blob([data], {
|
||||||
type: 'application/vnd.oasis.opendocument.spreadsheet'
|
type: 'application/vnd.oasis.opendocument.spreadsheet'
|
||||||
@@ -280,7 +423,145 @@ export function anonymizeParticipantName(participant: Participant, level: Anonym
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatFilename(campaignTitle: string): string {
|
/**
|
||||||
|
* Anonymise le nom d'un auteur de proposition
|
||||||
|
*/
|
||||||
|
export function anonymizeAuthorName(name: string, level: AnonymizationLevel): string {
|
||||||
|
switch (level) {
|
||||||
|
case 'full':
|
||||||
|
return 'XXXX';
|
||||||
|
case 'initials':
|
||||||
|
return name.charAt(0).toUpperCase() + '.';
|
||||||
|
case 'none':
|
||||||
|
return name;
|
||||||
|
default:
|
||||||
|
return 'XXXX';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anonymise l'email d'un auteur de proposition
|
||||||
|
*/
|
||||||
|
export function anonymizeAuthorEmail(email: string, level: AnonymizationLevel): string {
|
||||||
|
switch (level) {
|
||||||
|
case 'full':
|
||||||
|
return 'xxxx@xxxx.xxx';
|
||||||
|
case 'initials':
|
||||||
|
// Garder le domaine mais anonymiser la partie locale
|
||||||
|
const [localPart, domain] = email.split('@');
|
||||||
|
if (domain) {
|
||||||
|
return `${localPart.charAt(0)}***@${domain}`;
|
||||||
|
}
|
||||||
|
return 'x***@xxxx.xxx';
|
||||||
|
case 'none':
|
||||||
|
return email;
|
||||||
|
default:
|
||||||
|
return 'xxxx@xxxx.xxx';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generatePropositionsExport(propositions: Proposition[], campaignTitle: string): Promise<{ data: Uint8Array | string; format: ExportFileFormat }> {
|
||||||
|
const format = await getExportFileFormat();
|
||||||
|
const anonymizationLevel = await settingsService.getStringValue('export_anonymization', 'full') as AnonymizationLevel;
|
||||||
|
|
||||||
|
// Créer la matrice de données
|
||||||
|
const matrix: (string | number)[][] = [];
|
||||||
|
|
||||||
|
// Pour les formats Excel/ODS, ajouter un titre
|
||||||
|
if (format !== 'csv') {
|
||||||
|
matrix.push([`Liste des propositions - ${campaignTitle}`]);
|
||||||
|
matrix.push([]); // Ligne vide
|
||||||
|
}
|
||||||
|
|
||||||
|
// En-têtes des colonnes (en français)
|
||||||
|
const headers = ['Titre', 'Description', 'Prénom', 'Nom', 'Email'];
|
||||||
|
matrix.push(headers);
|
||||||
|
|
||||||
|
// Données des propositions avec anonymisation
|
||||||
|
propositions.forEach(proposition => {
|
||||||
|
const row: (string | number)[] = [
|
||||||
|
proposition.title,
|
||||||
|
proposition.description,
|
||||||
|
anonymizeAuthorName(proposition.author_first_name, anonymizationLevel),
|
||||||
|
anonymizeAuthorName(proposition.author_last_name, anonymizationLevel),
|
||||||
|
anonymizeAuthorEmail(proposition.author_email, anonymizationLevel)
|
||||||
|
];
|
||||||
|
matrix.push(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = generateExportFile(matrix, format);
|
||||||
|
return { data, format };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction de compatibilité (à supprimer plus tard)
|
||||||
|
export function generatePropositionsExportODS(propositions: Proposition[], campaignTitle: string): Uint8Array {
|
||||||
|
// Créer la matrice de données
|
||||||
|
const matrix: (string | number)[][] = [];
|
||||||
|
|
||||||
|
// En-têtes : Titre de la campagne
|
||||||
|
matrix.push([`Liste des propositions - ${campaignTitle}`]);
|
||||||
|
matrix.push([]); // Ligne vide
|
||||||
|
|
||||||
|
// En-têtes des colonnes (en français)
|
||||||
|
const headers = ['Titre', 'Description', 'Prénom', 'Nom', 'Email'];
|
||||||
|
matrix.push(headers);
|
||||||
|
|
||||||
|
// Données des propositions (sans anonymisation pour la compatibilité - utiliser la nouvelle fonction)
|
||||||
|
propositions.forEach(proposition => {
|
||||||
|
const row: (string | number)[] = [
|
||||||
|
proposition.title,
|
||||||
|
proposition.description,
|
||||||
|
proposition.author_first_name,
|
||||||
|
proposition.author_last_name,
|
||||||
|
proposition.author_email
|
||||||
|
];
|
||||||
|
matrix.push(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Créer le workbook et worksheet
|
||||||
|
const workbook = XLSX.utils.book_new();
|
||||||
|
const worksheet = XLSX.utils.aoa_to_sheet(matrix);
|
||||||
|
|
||||||
|
// Ajouter des styles pour les colonnes
|
||||||
|
worksheet['!cols'] = [
|
||||||
|
{ width: 30 }, // Titre
|
||||||
|
{ width: 50 }, // Description
|
||||||
|
{ width: 15 }, // Prénom
|
||||||
|
{ width: 15 }, // Nom
|
||||||
|
{ width: 25 }, // Email
|
||||||
|
{ width: 15 } // Date de création
|
||||||
|
];
|
||||||
|
|
||||||
|
// Style pour les en-têtes (texte en gras)
|
||||||
|
for (let col = 0; col < headers.length; col++) {
|
||||||
|
const cellRef = XLSX.utils.encode_cell({ r: 2, c: col }); // Ligne 3 (index 2) pour les en-têtes
|
||||||
|
if (!worksheet[cellRef]) {
|
||||||
|
worksheet[cellRef] = { v: matrix[2][col] };
|
||||||
|
}
|
||||||
|
worksheet[cellRef].s = {
|
||||||
|
font: { bold: true },
|
||||||
|
border: {
|
||||||
|
top: { style: 'thick' },
|
||||||
|
bottom: { style: 'thick' },
|
||||||
|
left: { style: 'thin' },
|
||||||
|
right: { style: 'thin' }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ajouter le worksheet au workbook
|
||||||
|
XLSX.utils.book_append_sheet(workbook, worksheet, 'Propositions');
|
||||||
|
|
||||||
|
// Générer le fichier ODS
|
||||||
|
const odsBuffer = XLSX.write(workbook, {
|
||||||
|
bookType: 'ods',
|
||||||
|
type: 'array'
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Uint8Array(odsBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFilename(campaignTitle: string, format: ExportFileFormat = 'ods'): string {
|
||||||
const sanitizedTitle = campaignTitle
|
const sanitizedTitle = campaignTitle
|
||||||
.replace(/[^a-zA-Z0-9\s]/g, '')
|
.replace(/[^a-zA-Z0-9\s]/g, '')
|
||||||
.replace(/\s+/g, '_')
|
.replace(/\s+/g, '_')
|
||||||
@@ -290,7 +571,23 @@ export function formatFilename(campaignTitle: string): string {
|
|||||||
|
|
||||||
const date = new Date().toISOString().split('T')[0];
|
const date = new Date().toISOString().split('T')[0];
|
||||||
const prefix = sanitizedTitle ? `statistiques_vote_${sanitizedTitle}_` : 'statistiques_vote_';
|
const prefix = sanitizedTitle ? `statistiques_vote_${sanitizedTitle}_` : 'statistiques_vote_';
|
||||||
const filename = `${prefix}${date}.ods`;
|
const filename = `${prefix}${date}.${format}`;
|
||||||
|
|
||||||
|
// Nettoyer les underscores multiples à la fin
|
||||||
|
return filename.replace(/_+/g, '_');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPropositionsFilename(campaignTitle: string, format: ExportFileFormat = 'ods'): string {
|
||||||
|
const sanitizedTitle = campaignTitle
|
||||||
|
.replace(/[^a-zA-Z0-9\s]/g, '')
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
.replace(/_+/g, '_') // Remplacer les underscores multiples par un seul
|
||||||
|
.toLowerCase()
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const date = new Date().toISOString().split('T')[0];
|
||||||
|
const prefix = sanitizedTitle ? `propositions_${sanitizedTitle}_` : 'propositions_';
|
||||||
|
const filename = `${prefix}${date}.${format}`;
|
||||||
|
|
||||||
// Nettoyer les underscores multiples à la fin
|
// Nettoyer les underscores multiples à la fin
|
||||||
return filename.replace(/_+/g, '_');
|
return filename.replace(/_+/g, '_');
|
||||||
|
|||||||
@@ -23,14 +23,33 @@ export function parseCSV(file: File): Promise<ParsedFileData> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
|
// Trouver la ligne d'en-têtes (ignorer les lignes de titre et vides)
|
||||||
const data = lines.slice(1).map(line => {
|
let headerLineIndex = 0;
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
// Si la ligne contient des virgules et ressemble à des en-têtes
|
||||||
|
if (line.includes(',') && !line.toLowerCase().includes('modèle') && !line.toLowerCase().includes('liste')) {
|
||||||
|
headerLineIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = lines[headerLineIndex].split(',').map(h => h.trim().replace(/"/g, ''));
|
||||||
|
const dataLines = lines.slice(headerLineIndex + 1);
|
||||||
|
|
||||||
|
const data = dataLines
|
||||||
|
.filter(line => line.trim()) // Ignorer les lignes vides
|
||||||
|
.map(line => {
|
||||||
const values = line.split(',').map(v => v.trim().replace(/"/g, ''));
|
const values = line.split(',').map(v => v.trim().replace(/"/g, ''));
|
||||||
const row: any = {};
|
const row: any = {};
|
||||||
headers.forEach((header, index) => {
|
headers.forEach((header, index) => {
|
||||||
row[header] = values[index] || '';
|
row[header] = values[index] || '';
|
||||||
});
|
});
|
||||||
return row;
|
return row;
|
||||||
|
})
|
||||||
|
.filter(row => {
|
||||||
|
// Ignorer les lignes où tous les champs sont vides
|
||||||
|
return Object.values(row).some(value => value && value.toString().trim());
|
||||||
});
|
});
|
||||||
|
|
||||||
resolve({ data, headers });
|
resolve({ data, headers });
|
||||||
@@ -62,15 +81,38 @@ export function parseExcel(file: File): Promise<ParsedFileData> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = jsonData[0] as string[];
|
// Trouver la ligne d'en-têtes (ignorer les lignes de titre et vides)
|
||||||
const rows = jsonData.slice(1) as any[][];
|
let headerLineIndex = 0;
|
||||||
|
for (let i = 0; i < jsonData.length; i++) {
|
||||||
|
const row = jsonData[i] as any[];
|
||||||
|
if (row && row.length > 0) {
|
||||||
|
const firstCell = row[0];
|
||||||
|
// Si la première cellule ressemble à un en-tête et pas à un titre
|
||||||
|
if (firstCell && typeof firstCell === 'string' &&
|
||||||
|
!firstCell.toLowerCase().includes('modèle') &&
|
||||||
|
!firstCell.toLowerCase().includes('liste') &&
|
||||||
|
!firstCell.toLowerCase().includes('propositions')) {
|
||||||
|
headerLineIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const parsedData = rows.map(row => {
|
const headers = (jsonData[headerLineIndex] as string[]).filter(h => h && h.toString().trim());
|
||||||
|
const rows = jsonData.slice(headerLineIndex + 1) as any[][];
|
||||||
|
|
||||||
|
const parsedData = rows
|
||||||
|
.filter(row => row && row.length > 0) // Ignorer les lignes vides
|
||||||
|
.map(row => {
|
||||||
const rowData: any = {};
|
const rowData: any = {};
|
||||||
headers.forEach((header, index) => {
|
headers.forEach((header, index) => {
|
||||||
rowData[header] = row[index] || '';
|
rowData[header] = row[index] || '';
|
||||||
});
|
});
|
||||||
return rowData;
|
return rowData;
|
||||||
|
})
|
||||||
|
.filter(rowData => {
|
||||||
|
// Ignorer les lignes où tous les champs sont vides
|
||||||
|
return Object.values(rowData).some(value => value && value.toString().trim());
|
||||||
});
|
});
|
||||||
|
|
||||||
resolve({ data: parsedData, headers });
|
resolve({ data: parsedData, headers });
|
||||||
@@ -84,22 +126,47 @@ export function parseExcel(file: File): Promise<ParsedFileData> {
|
|||||||
|
|
||||||
export function getExpectedColumns(type: 'propositions' | 'participants'): string[] {
|
export function getExpectedColumns(type: 'propositions' | 'participants'): string[] {
|
||||||
if (type === 'propositions') {
|
if (type === 'propositions') {
|
||||||
return ['title', 'description', 'author_first_name', 'author_last_name', 'author_email'];
|
return ['Titre', 'Description', 'Prénom', 'Nom', 'Email'];
|
||||||
} else {
|
} else {
|
||||||
return ['first_name', 'last_name', 'email'];
|
return ['Prénom', 'Nom', 'Email'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function downloadTemplate(type: 'propositions' | 'participants'): void {
|
export async function downloadTemplate(type: 'propositions' | 'participants'): Promise<void> {
|
||||||
const columns = getExpectedColumns(type);
|
const columns = getExpectedColumns(type);
|
||||||
const csvContent = columns.join(',') + '\n';
|
|
||||||
const blob = new Blob([csvContent], { type: 'text/csv' });
|
// Importer dynamiquement la fonction pour éviter les dépendances circulaires
|
||||||
const url = window.URL.createObjectURL(blob);
|
const { getExportFileFormat, generateExportFile, downloadExportFile } = await import('./export-utils');
|
||||||
const a = document.createElement('a');
|
const format = await getExportFileFormat();
|
||||||
a.href = url;
|
|
||||||
a.download = `template_${type}.csv`;
|
// Créer la matrice de données avec les en-têtes
|
||||||
a.click();
|
const matrix: string[][] = [];
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
|
// Pour les formats Excel/ODS, ajouter un titre
|
||||||
|
if (format !== 'csv') {
|
||||||
|
matrix.push([`Modèle d'import - ${type === 'propositions' ? 'Propositions' : 'Participants'}`]);
|
||||||
|
matrix.push([]); // Ligne vide
|
||||||
|
}
|
||||||
|
|
||||||
|
matrix.push(columns); // En-têtes des colonnes
|
||||||
|
|
||||||
|
// Ajouter quelques lignes d'exemple
|
||||||
|
if (type === 'propositions') {
|
||||||
|
matrix.push(['Exemple de proposition', 'Description de la proposition', 'Jean', 'Dupont', 'jean.dupont@example.com']);
|
||||||
|
matrix.push(['Autre proposition', 'Autre description', 'Marie', 'Martin', 'marie.martin@example.com']);
|
||||||
|
} else {
|
||||||
|
matrix.push(['Jean', 'Dupont', 'jean.dupont@example.com']);
|
||||||
|
matrix.push(['Marie', 'Martin', 'marie.martin@example.com']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Générer le fichier dans le format configuré
|
||||||
|
const data = generateExportFile(matrix, format);
|
||||||
|
|
||||||
|
// Créer le nom de fichier avec l'extension appropriée
|
||||||
|
const filename = `template_${type}.${format}`;
|
||||||
|
|
||||||
|
// Télécharger le fichier
|
||||||
|
downloadExportFile(data, filename, format);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateFileType(file: File): { isValid: boolean; error?: string } {
|
export function validateFileType(file: File): { isValid: boolean; error?: string } {
|
||||||
|
|||||||
@@ -5,10 +5,15 @@ import { emailService } from './email';
|
|||||||
|
|
||||||
// Fonction utilitaire pour générer un slug côté client
|
// Fonction utilitaire pour générer un slug côté client
|
||||||
function generateSlugClient(title: string): string {
|
function generateSlugClient(title: string): string {
|
||||||
// Convertir en minuscules et remplacer les caractères spéciaux
|
// Convertir en minuscules, supprimer les accents et remplacer les caractères spéciaux
|
||||||
let slug = title.toLowerCase()
|
let slug = title
|
||||||
.replace(/[^a-z0-9\s]/g, '')
|
.toLowerCase()
|
||||||
.replace(/\s+/g, '-')
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '') // Supprime les accents
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '') // Garde seulement lettres, chiffres, espaces et tirets
|
||||||
|
.replace(/\s+/g, '-') // Remplace les espaces par des tirets
|
||||||
|
.replace(/-+/g, '-') // Remplace les tirets multiples par un seul
|
||||||
|
.replace(/^-+|-+$/g, '') // Supprime les tirets en début et fin
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
// Si le slug est vide, utiliser 'campagne'
|
// Si le slug est vide, utiliser 'campagne'
|
||||||
@@ -276,6 +281,15 @@ export const propositionService = {
|
|||||||
.delete()
|
.delete()
|
||||||
.eq('id', id);
|
.eq('id', id);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteAllByCampaign(campaignId: string): Promise<void> {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('propositions')
|
||||||
|
.delete()
|
||||||
|
.eq('campaign_id', campaignId);
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -363,6 +377,15 @@ export const participantService = {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async deleteAllByCampaign(campaignId: string): Promise<void> {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('participants')
|
||||||
|
.delete()
|
||||||
|
.eq('campaign_id', campaignId);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
},
|
||||||
|
|
||||||
// Nouvelle méthode pour récupérer un participant par short_id
|
// Nouvelle méthode pour récupérer un participant par short_id
|
||||||
async getByShortId(shortId: string): Promise<Participant | null> {
|
async getByShortId(shortId: string): Promise<Participant | null> {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
|
|||||||
Reference in New Issue
Block a user