diff --git a/database/supabase-schema.sql b/database/supabase-schema.sql index 4c31384..a728010 100644 --- a/database/supabase-schema.sql +++ b/database/supabase-schema.sql @@ -17,6 +17,7 @@ CREATE TABLE campaigns ( status TEXT NOT NULL CHECK (status IN ('deposit', 'voting', 'closed')) DEFAULT 'deposit', budget_per_user INTEGER NOT NULL CHECK (budget_per_user > 0), spending_tiers TEXT NOT NULL, -- Montants séparés par des virgules (ex: "10,25,50,100") + slug TEXT UNIQUE, -- Slug unique pour les liens courts created_by UUID REFERENCES admin_users(id), created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() @@ -41,6 +42,7 @@ CREATE TABLE participants ( first_name TEXT NOT NULL, last_name TEXT NOT NULL, email TEXT NOT NULL, + short_id TEXT UNIQUE, -- Identifiant court unique pour les liens de vote created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() ); @@ -72,6 +74,8 @@ CREATE INDEX idx_propositions_campaign_id ON propositions(campaign_id); CREATE INDEX idx_participants_campaign_id ON participants(campaign_id); CREATE INDEX idx_campaigns_status ON campaigns(status); CREATE INDEX idx_campaigns_created_at ON campaigns(created_at DESC); +CREATE INDEX idx_campaigns_slug ON campaigns(slug); +CREATE INDEX idx_participants_short_id ON participants(short_id); CREATE INDEX idx_votes_campaign_participant ON votes(campaign_id, participant_id); CREATE INDEX idx_votes_proposition ON votes(proposition_id); CREATE INDEX idx_admin_users_email ON admin_users(email); @@ -99,6 +103,69 @@ CREATE TRIGGER update_settings_updated_at BEFORE UPDATE ON settings CREATE TRIGGER update_admin_users_updated_at BEFORE UPDATE ON admin_users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +-- Fonction pour générer un slug à partir d'un titre +CREATE OR REPLACE FUNCTION generate_slug(title TEXT) +RETURNS TEXT AS $$ +DECLARE + slug TEXT; + counter INTEGER := 0; + base_slug TEXT; +BEGIN + -- Convertir en minuscules et remplacer les caractères spéciaux + base_slug := lower(regexp_replace(title, '[^a-zA-Z0-9\s]', '', 'g')); + base_slug := regexp_replace(base_slug, '\s+', '-', 'g'); + base_slug := trim(both '-' from base_slug); + + -- Si le slug est vide, utiliser 'campagne' + IF base_slug = '' THEN + base_slug := 'campagne'; + END IF; + + slug := base_slug; + + -- Vérifier si le slug existe déjà et ajouter un numéro si nécessaire + WHILE EXISTS (SELECT 1 FROM campaigns WHERE campaigns.slug = slug) LOOP + counter := counter + 1; + slug := base_slug || '-' || counter; + END LOOP; + + RETURN slug; +END; +$$ LANGUAGE plpgsql; + +-- Fonction pour générer un short_id unique +CREATE OR REPLACE FUNCTION generate_short_id() +RETURNS TEXT AS $$ +DECLARE + chars TEXT := 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + result TEXT := ''; + i INTEGER; + short_id TEXT; + counter INTEGER := 0; +BEGIN + LOOP + -- Générer un identifiant de 6 caractères + result := ''; + FOR i IN 1..6 LOOP + result := result || substr(chars, floor(random() * length(chars))::integer + 1, 1); + END LOOP; + + short_id := result; + + -- Vérifier si le short_id existe déjà + IF NOT EXISTS (SELECT 1 FROM participants WHERE participants.short_id = short_id) THEN + RETURN short_id; + END IF; + + -- Éviter les boucles infinies + counter := counter + 1; + IF counter > 100 THEN + RAISE EXCEPTION 'Impossible de générer un short_id unique après 100 tentatives'; + END IF; + END LOOP; +END; +$$ LANGUAGE plpgsql; + -- Activer RLS sur toutes les tables ALTER TABLE admin_users ENABLE ROW LEVEL SECURITY; ALTER TABLE campaigns ENABLE ROW LEVEL SECURITY; diff --git a/docs/PROJECT-STRUCTURE.md b/docs/PROJECT-STRUCTURE.md index 0592dac..4ed2ffd 100644 --- a/docs/PROJECT-STRUCTURE.md +++ b/docs/PROJECT-STRUCTURE.md @@ -1,106 +1,201 @@ -# 📁 Structure du Projet - Mes Budgets Participatifs +# Structure du Projet -## 🗂️ **Organisation des dossiers** +## 📁 Organisation des fichiers ``` mes-budgets-participatifs/ -├── 📚 docs/ # Documentation complète -│ ├── README.md # Index de la documentation -│ ├── SETUP.md # Guide de configuration -│ ├── MIGRATION-GUIDE.md # Migration vers la sécurité -│ ├── SECURITY-SUMMARY.md # Résumé de la sécurisation -│ └── SETTINGS.md # Configuration avancée -│ -├── 🗄️ database/ # Scripts de base de données -│ └── supabase-schema.sql # Schéma complet avec sécurité -│ -├── 🛠️ scripts/ # Outils et scripts -│ └── test-security.js # Tests de sécurité -│ -├── 📱 src/ # Code source de l'application -│ ├── app/ # Pages Next.js (App Router) -│ ├── components/ # Composants React -│ ├── lib/ # Services et utilitaires -│ └── types/ # Types TypeScript -│ -├── 🎨 public/ # Assets statiques -├── 📦 node_modules/ # Dépendances (généré) -├── ⚙️ Configuration files # Fichiers de configuration -└── 📖 README.md # Documentation principale +├── src/ +│ ├── app/ # Pages Next.js (App Router) +│ │ ├── page.tsx # Page d'accueil +│ │ ├── admin/ # Pages d'administration (protégées) +│ │ │ ├── page.tsx # Dashboard principal +│ │ │ ├── settings/ # Paramètres SMTP +│ │ │ └── campaigns/[id]/ # Pages de gestion par campagne +│ │ ├── api/ # API Routes +│ │ │ ├── send-participant-email/ +│ │ │ ├── test-email/ +│ │ │ └── test-smtp/ +│ │ ├── campaigns/[id]/ # Pages publiques (anciennes routes) +│ │ │ ├── propose/ # Dépôt de propositions +│ │ │ └── vote/[participantId] # Vote public +│ │ ├── p/[slug]/ # Pages publiques (nouvelles routes courtes) +│ │ │ ├── page.tsx # Dépôt de propositions par slug +│ │ │ └── success/ # Page de succès pour dépôt +│ │ │ └── page.tsx +│ │ └── v/[shortId]/ # Pages de vote (nouvelles routes courtes) +│ │ ├── page.tsx # Vote par short_id +│ │ └── success/ # Page de succès pour vote +│ │ └── page.tsx +│ ├── components/ # Composants React +│ │ ├── ui/ # Composants Shadcn/ui +│ │ ├── AuthGuard.tsx # Protection des routes +│ │ ├── Navigation.tsx # Navigation principale +│ │ ├── SmtpSettingsForm.tsx # Configuration SMTP +│ │ └── [Modals] # Modales de gestion +│ ├── lib/ # Services et configuration +│ │ ├── supabase.ts # Configuration Supabase +│ │ ├── services.ts # Services de données +│ │ ├── email.ts # Service d'envoi d'emails +│ │ ├── encryption.ts # Chiffrement des données sensibles +│ │ └── utils.ts # Utilitaires +│ └── types/ # Types TypeScript +├── database/ +│ └── supabase-schema.sql # Schéma de base de données +├── scripts/ +│ ├── test-security.js # Tests de sécurité +│ └── migrate-short-links.js # Migration des liens courts +└── docs/ # Documentation ``` -## 📋 **Fichiers principaux** +## 🔗 Routes de l'application -### **Configuration** -- `package.json` - Dépendances et scripts -- `tsconfig.json` - Configuration TypeScript -- `next.config.ts` - Configuration Next.js -- `env.example` - Exemple de variables d'environnement +### Routes publiques -### **Documentation** -- `README.md` - Documentation principale -- `docs/README.md` - Index de la documentation -- `PROJECT-STRUCTURE.md` - Ce fichier +#### Nouvelles routes courtes (recommandées) +- **`/p/[slug]`** - Dépôt de propositions + - Exemple : `/p/budget-2024` + - Utilise le slug de la campagne pour un lien court et lisible -### **Base de données** -- `database/supabase-schema.sql` - Schéma complet avec sécurité +- **`/v/[shortId]`** - Vote public + - Exemple : `/v/ABC123` + - Utilise un identifiant court unique pour chaque participant -### **Outils** -- `scripts/test-security.js` - Tests de sécurité +#### Anciennes routes (compatibilité) +- **`/campaigns/[id]/propose`** - Dépôt de propositions + - Exemple : `/campaigns/123e4567-e89b-12d3-a456-426614174000/propose` -## 🔧 **Scripts disponibles** +- **`/campaigns/[id]/vote/[participantId]`** - Vote public + - Exemple : `/campaigns/123e4567-e89b-12d3-a456-426614174000/vote/987fcdeb-51a2-43d1-b789-123456789abc` +### Routes d'administration +- **`/admin`** - Dashboard principal +- **`/admin/settings`** - Paramètres SMTP +- **`/admin/campaigns/[id]/propositions`** - Gestion des propositions +- **`/admin/campaigns/[id]/participants`** - Gestion des participants +- **`/admin/campaigns/[id]/stats`** - Statistiques de la campagne + +## 🗄️ Structure de la base de données + +### Tables principales + +#### `campaigns` +- `id` (UUID) - Identifiant unique +- `title` (TEXT) - Titre de la campagne +- `description` (TEXT) - Description +- `status` (TEXT) - Statut : 'deposit', 'voting', 'closed' +- `budget_per_user` (INTEGER) - Budget par utilisateur +- `spending_tiers` (TEXT) - Montants disponibles (ex: "10,25,50,100") +- **`slug` (TEXT, UNIQUE)** - Slug pour les liens courts +- `created_at`, `updated_at` (TIMESTAMP) + +#### `participants` +- `id` (UUID) - Identifiant unique +- `campaign_id` (UUID) - Référence vers la campagne +- `first_name`, `last_name` (TEXT) - Nom et prénom +- `email` (TEXT) - Adresse email +- **`short_id` (TEXT, UNIQUE)** - Identifiant court pour les liens de vote +- `created_at` (TIMESTAMP) + +#### `propositions` +- `id` (UUID) - Identifiant unique +- `campaign_id` (UUID) - Référence vers la campagne +- `title`, `description` (TEXT) - Titre et description +- `author_first_name`, `author_last_name`, `author_email` (TEXT) - Informations de l'auteur +- `created_at` (TIMESTAMP) + +#### `votes` +- `id` (UUID) - Identifiant unique +- `campaign_id` (UUID) - Référence vers la campagne +- `participant_id` (UUID) - Référence vers le participant +- `proposition_id` (UUID) - Référence vers la proposition +- `amount` (INTEGER) - Montant voté +- `created_at`, `updated_at` (TIMESTAMP) + +### Fonctions PostgreSQL + +#### `generate_slug(title TEXT)` +Génère automatiquement un slug unique à partir du titre d'une campagne. + +#### `generate_short_id()` +Génère automatiquement un identifiant court unique pour les participants. + +## 🔧 Services et utilitaires + +### Services principaux (`src/lib/services.ts`) + +#### `campaignService` +- `getAll()` - Récupère toutes les campagnes +- `create(campaign)` - Crée une nouvelle campagne (génère automatiquement le slug) +- `update(id, updates)` - Met à jour une campagne +- `delete(id)` - Supprime une campagne +- `getBySlug(slug)` - Récupère une campagne par son slug +- `getStats(campaignId)` - Récupère les statistiques d'une campagne + +#### `participantService` +- `getByCampaign(campaignId)` - Récupère les participants d'une campagne +- `create(participant)` - Crée un nouveau participant (génère automatiquement le short_id) +- `update(id, updates)` - Met à jour un participant +- `delete(id)` - Supprime un participant +- `getByShortId(shortId)` - Récupère un participant par son short_id + +#### `propositionService` +- `getByCampaign(campaignId)` - Récupère les propositions d'une campagne +- `create(proposition)` - Crée une nouvelle proposition +- `update(id, updates)` - Met à jour une proposition +- `delete(id)` - Supprime une proposition + +#### `voteService` +- `getByParticipant(campaignId, participantId)` - Récupère les votes d'un participant +- `create(vote)` - Crée un nouveau vote +- `deleteByParticipant(campaignId, participantId)` - Supprime tous les votes d'un participant + +## 🚀 Scripts de migration + +### `scripts/migrate-short-links.js` +Script pour migrer les données existantes et générer les slugs et short_ids manquants. + +**Usage :** ```bash -# Développement -npm run dev - -# Build de production -npm run build - -# Tests de sécurité -npm run test:security - -# Linting -npm run lint -npm run lint:fix +node scripts/migrate-short-links.js ``` -## 📚 **Documentation par type** +**Fonctionnalités :** +- Génère automatiquement les slugs pour les campagnes existantes +- Génère automatiquement les short_ids pour les participants existants +- Gère les conflits et génère des identifiants uniques +- Affiche un rapport détaillé de la migration -### **🚀 Démarrage rapide** -- `docs/SETUP.md` - Installation et configuration +## 🔒 Sécurité -### **🔒 Sécurité** -- `docs/SECURITY-SUMMARY.md` - Vue d'ensemble de la sécurité -- `docs/SETTINGS.md` - Configuration SMTP et paramètres +### Authentification +- Utilisation de Supabase Auth pour l'authentification des administrateurs +- Protection des routes d'administration avec `AuthGuard` -### **🗄️ Base de données** -- `database/supabase-schema.sql` - Schéma complet avec RLS +### Autorisation +- Row Level Security (RLS) activé sur toutes les tables +- Contrôle d'accès basé sur les rôles utilisateur -## 🎯 **Points d'entrée** +### Validation des données +- Validation côté client et serveur +- Sanitisation des entrées utilisateur +- Protection contre les injections SQL -### **Pour les développeurs :** -1. `README.md` - Vue d'ensemble -2. `docs/SETUP.md` - Configuration -3. `src/` - Code source +## 📱 Interface utilisateur -### **Pour les administrateurs :** -1. `docs/SECURITY-SUMMARY.md` - Sécurité -2. `docs/SETTINGS.md` - Configuration +### Composants UI +- Utilisation de Shadcn/ui pour une interface cohérente +- Design responsive et accessible +- Support du mode sombre +- Composants réutilisables -### **Pour les déploiements :** -1. `database/supabase-schema.sql` - Base de données -2. `scripts/test-security.js` - Vérification -3. `env.example` - Variables d'environnement +### Pages publiques +- Interface épurée et intuitive +- Formulaires de dépôt et de vote optimisés +- Feedback visuel en temps réel +- Gestion des erreurs et des états de chargement -## 🔄 **Workflow de développement** - -1. **Configuration** → `docs/SETUP.md` -2. **Développement** → `src/` -3. **Tests** → `scripts/test-security.js` -4. **Documentation** → `docs/` -5. **Déploiement** → `database/` + configuration - ---- - -**Dernière mise à jour :** Réorganisation complète de la structure ✅ +### Interface d'administration +- Dashboard avec statistiques en temps réel +- Gestion complète des campagnes, propositions et participants +- Import/export de données +- Envoi d'emails personnalisés diff --git a/scripts/migrate-short-links.js b/scripts/migrate-short-links.js new file mode 100644 index 0000000..468b10b --- /dev/null +++ b/scripts/migrate-short-links.js @@ -0,0 +1,196 @@ +#!/usr/bin/env node + +/** + * Script de migration pour générer les slugs et short_ids pour les données existantes + * + * Usage: node scripts/migrate-short-links.js + */ + +const { createClient } = require('@supabase/supabase-js'); +require('dotenv').config({ path: '.env.local' }); + +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; +const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + +if (!supabaseUrl || !supabaseServiceKey) { + console.error('❌ Variables d\'environnement manquantes'); + console.error('NEXT_PUBLIC_SUPABASE_URL et SUPABASE_SERVICE_ROLE_KEY sont requis'); + process.exit(1); +} + +const supabase = createClient(supabaseUrl, supabaseServiceKey); + +async function generateSlug(title) { + // Convertir en minuscules et remplacer les caractères spéciaux + let slug = title.toLowerCase() + .replace(/[^a-z0-9\s]/g, '') + .replace(/\s+/g, '-') + .trim(); + + // Si le slug est vide, utiliser 'campagne' + if (!slug) { + slug = 'campagne'; + } + + // Vérifier si le slug existe déjà et ajouter un numéro si nécessaire + let counter = 0; + let finalSlug = slug; + + while (true) { + const { data, error } = await supabase + .from('campaigns') + .select('id') + .eq('slug', finalSlug) + .single(); + + if (error && error.code === 'PGRST116') { + // Aucune campagne trouvée avec ce slug, on peut l'utiliser + break; + } + + if (error) { + throw error; + } + + // Le slug existe déjà, ajouter un numéro + counter++; + finalSlug = `${slug}-${counter}`; + } + + return finalSlug; +} + +async function generateShortId() { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let counter = 0; + + while (counter < 100) { + // Générer un identifiant de 6 caractères + let result = ''; + for (let i = 0; i < 6; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + + // Vérifier si le short_id existe déjà + const { data, error } = await supabase + .from('participants') + .select('id') + .eq('short_id', result) + .single(); + + if (error && error.code === 'PGRST116') { + // Aucun participant trouvé avec ce short_id, on peut l'utiliser + return result; + } + + if (error) { + throw error; + } + + counter++; + } + + throw new Error('Impossible de générer un short_id unique après 100 tentatives'); +} + +async function migrateCampaigns() { + console.log('🔄 Migration des campagnes...'); + + // Récupérer toutes les campagnes sans slug + const { data: campaigns, error } = await supabase + .from('campaigns') + .select('id, title') + .is('slug', null); + + if (error) { + console.error('❌ Erreur lors de la récupération des campagnes:', error); + return; + } + + console.log(`📋 ${campaigns.length} campagnes à migrer`); + + for (const campaign of campaigns) { + try { + const slug = await generateSlug(campaign.title); + + const { error: updateError } = await supabase + .from('campaigns') + .update({ slug }) + .eq('id', campaign.id); + + if (updateError) { + console.error(`❌ Erreur lors de la mise à jour de la campagne ${campaign.id}:`, updateError); + } else { + console.log(`✅ Campagne "${campaign.title}" -> slug: ${slug}`); + } + } catch (error) { + console.error(`❌ Erreur lors de la génération du slug pour "${campaign.title}":`, error); + } + } + + console.log('✅ Migration des campagnes terminée'); +} + +async function migrateParticipants() { + console.log('🔄 Migration des participants...'); + + // Récupérer tous les participants sans short_id + const { data: participants, error } = await supabase + .from('participants') + .select('id, first_name, last_name') + .is('short_id', null); + + if (error) { + console.error('❌ Erreur lors de la récupération des participants:', error); + return; + } + + console.log(`📋 ${participants.length} participants à migrer`); + + for (const participant of participants) { + try { + const shortId = await generateShortId(); + + const { error: updateError } = await supabase + .from('participants') + .update({ short_id: shortId }) + .eq('id', participant.id); + + if (updateError) { + console.error(`❌ Erreur lors de la mise à jour du participant ${participant.id}:`, updateError); + } else { + console.log(`✅ Participant "${participant.first_name} ${participant.last_name}" -> short_id: ${shortId}`); + } + } catch (error) { + console.error(`❌ Erreur lors de la génération du short_id pour "${participant.first_name} ${participant.last_name}":`, error); + } + } + + console.log('✅ Migration des participants terminée'); +} + +async function main() { + console.log('🚀 Début de la migration des liens courts...\n'); + + try { + await migrateCampaigns(); + console.log(''); + await migrateParticipants(); + + console.log('\n🎉 Migration terminée avec succès !'); + console.log('\n📝 Résumé des nouvelles routes :'); + console.log('- Dépôt de propositions : /p/[slug]'); + console.log('- Vote : /v/[shortId]'); + console.log('- Les anciennes routes restent fonctionnelles pour la compatibilité'); + + } catch (error) { + console.error('❌ Erreur lors de la migration:', error); + process.exit(1); + } +} + +if (require.main === module) { + main(); +} + +module.exports = { migrateCampaigns, migrateParticipants }; diff --git a/src/app/admin/campaigns/[id]/participants/page.tsx b/src/app/admin/campaigns/[id]/participants/page.tsx index 76ed30b..74fcdc2 100644 --- a/src/app/admin/campaigns/[id]/participants/page.tsx +++ b/src/app/admin/campaigns/[id]/participants/page.tsx @@ -14,6 +14,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Badge } from '@/components/ui/badge'; import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; import Navigation from '@/components/Navigation'; import AuthGuard from '@/components/AuthGuard'; import { Users, User, Calendar, Mail, Vote, Copy, Check, Upload } from 'lucide-react'; @@ -95,8 +96,11 @@ function CampaignParticipantsPageContent() { return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(); }; - const copyVoteLink = (participantId: string) => { - const voteUrl = `${window.location.origin}/campaigns/${campaignId}/vote/${participantId}`; + const copyVoteLink = (participantId: string, shortId?: string) => { + // Utiliser le lien court si disponible, sinon le lien long + const voteUrl = shortId + ? `${window.location.origin}/v/${shortId}` + : `${window.location.origin}/campaigns/${campaignId}/vote/${participantId}`; navigator.clipboard.writeText(voteUrl); setCopiedParticipantId(participantId); setTimeout(() => setCopiedParticipantId(null), 2000); @@ -338,14 +342,18 @@ function CampaignParticipantsPageContent() { copyVoteLink(participant.id)} + onClick={() => copyVoteLink(participant.id, participant.short_id)} className="text-xs" + disabled={!participant.short_id} > {copiedParticipantId === participant.id ? ( <> @@ -359,24 +367,26 @@ function CampaignParticipantsPageContent() { > )} - { - setSelectedParticipant(participant); - setShowSendEmailModal(true); - }} - className="text-xs" - > - - Envoyer un mail - )} + + {/* Email Button */} + { + setSelectedParticipant(participant); + setShowSendEmailModal(true); + }} + className="text-xs" + > + + Envoyer un mail + ))} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 898b383..0efe7fa 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -10,6 +10,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; import { Progress } from '@/components/ui/progress'; import Navigation from '@/components/Navigation'; import AuthGuard from '@/components/AuthGuard'; @@ -324,14 +325,14 @@ function AdminPageContent() { { - copyToClipboard(`${window.location.origin}/campaigns/${campaign.id}/propose`, campaign.id); + copyToClipboard(`${window.location.origin}/p/${campaign.slug || 'campagne'}`, campaign.id); }} className="text-xs" > diff --git a/src/app/p/[slug]/page.tsx b/src/app/p/[slug]/page.tsx new file mode 100644 index 0000000..9dbd17f --- /dev/null +++ b/src/app/p/[slug]/page.tsx @@ -0,0 +1,285 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { Campaign } from '@/types'; +import { campaignService } from '@/lib/services'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Loader2, CheckCircle, AlertCircle } from 'lucide-react'; + +// Force dynamic rendering to avoid SSR issues with Supabase +export const dynamic = 'force-dynamic'; + +export default function ShortProposePage() { + const params = useParams(); + const router = useRouter(); + const slug = params.slug as string; + + const [campaign, setCampaign] = useState(null); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(false); + + const [formData, setFormData] = useState({ + title: '', + description: '', + author_first_name: '', + author_last_name: '', + author_email: '' + }); + + useEffect(() => { + if (slug) { + loadCampaign(); + } + }, [slug]); + + const loadCampaign = async () => { + try { + setLoading(true); + const campaignData = await campaignService.getBySlug(slug); + + if (!campaignData) { + setError('Campagne non trouvée'); + return; + } + + if (campaignData.status !== 'deposit') { + setError('Cette campagne n\'accepte plus de propositions'); + return; + } + + setCampaign(campaignData); + } catch (error) { + console.error('Erreur lors du chargement de la campagne:', error); + setError('Erreur lors du chargement de la campagne'); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!campaign) return; + + // Validation basique + if (!formData.title.trim() || !formData.description.trim() || + !formData.author_first_name.trim() || !formData.author_last_name.trim() || + !formData.author_email.trim()) { + setError('Veuillez remplir tous les champs obligatoires'); + return; + } + + // Validation email basique + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(formData.author_email)) { + setError('Veuillez saisir une adresse email valide'); + return; + } + + try { + setSubmitting(true); + setError(''); + + const { propositionService } = await import('@/lib/services'); + + await propositionService.create({ + campaign_id: campaign.id, + title: formData.title.trim(), + description: formData.description.trim(), + author_first_name: formData.author_first_name.trim(), + author_last_name: formData.author_last_name.trim(), + author_email: formData.author_email.trim() + }); + + setSuccess(true); + setFormData({ + title: '', + description: '', + author_first_name: '', + author_last_name: '', + author_email: '' + }); + + // Rediriger vers la page de succès après 3 secondes + setTimeout(() => { + router.push(`/p/${slug}/success`); + }, 3000); + + } catch (error: any) { + console.error('Erreur lors de la soumission:', error); + setError(error.message || 'Erreur lors de la soumission de la proposition'); + } finally { + setSubmitting(false); + } + }; + + const handleInputChange = (field: string, value: string) => { + setFormData(prev => ({ ...prev, [field]: value })); + if (error) setError(''); // Effacer l'erreur quand l'utilisateur commence à taper + }; + + if (loading) { + return ( + + + + Chargement de la campagne... + + + ); + } + + if (error && !campaign) { + return ( + + + + + + + Erreur + + {error} + + + + + ); + } + + if (success) { + return ( + + + + + + + Proposition soumise avec succès ! + + + Votre proposition a été enregistrée. Vous allez être redirigé... + + + + + + ); + } + + return ( + + + + + + Dépôt de proposition + + + {campaign?.title} + + {campaign?.description && ( + + {campaign.description} + + )} + + + + {error && ( + + + + {error} + + + )} + + + + + Prénom * + handleInputChange('author_first_name', e.target.value)} + placeholder="Votre prénom" + required + /> + + + Nom * + handleInputChange('author_last_name', e.target.value)} + placeholder="Votre nom" + required + /> + + + + + Email * + handleInputChange('author_email', e.target.value)} + placeholder="votre.email@exemple.com" + required + /> + + + + Titre de la proposition * + handleInputChange('title', e.target.value)} + placeholder="Titre de votre proposition" + required + /> + + + + Description * + handleInputChange('description', e.target.value)} + placeholder="Décrivez votre proposition en détail..." + rows={6} + required + /> + + + + {submitting ? ( + <> + + Envoi en cours... + > + ) : ( + 'Soumettre ma proposition' + )} + + + + + + + ); +} diff --git a/src/app/p/[slug]/success/page.tsx b/src/app/p/[slug]/success/page.tsx new file mode 100644 index 0000000..039e982 --- /dev/null +++ b/src/app/p/[slug]/success/page.tsx @@ -0,0 +1,46 @@ +'use client'; + +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { CheckCircle, ArrowLeft } from 'lucide-react'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; + +export default function ProposeSuccessPage() { + const params = useParams(); + const slug = params.slug as string; + + return ( + + + + + + + Proposition soumise avec succès ! + + + + Votre proposition a été enregistrée et sera examinée par l'équipe organisatrice. + Vous recevrez une confirmation par email. + + + + + + + Déposer une autre proposition + + + + + + Retour à l'accueil + + + + + + + ); +} diff --git a/src/app/v/[shortId]/page.tsx b/src/app/v/[shortId]/page.tsx new file mode 100644 index 0000000..ad0001b --- /dev/null +++ b/src/app/v/[shortId]/page.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { participantService } from '@/lib/services'; +import { Loader2 } from 'lucide-react'; + +// Force dynamic rendering to avoid SSR issues with Supabase +export const dynamic = 'force-dynamic'; + +export default function ShortVoteRedirect() { + const params = useParams(); + const router = useRouter(); + const shortId = params.shortId as string; + + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + if (shortId) { + redirectToVotePage(); + } + }, [shortId]); + + const redirectToVotePage = async () => { + try { + setLoading(true); + + // Récupérer le participant par short_id + const participant = await participantService.getByShortId(shortId); + + if (!participant) { + setError('Lien de vote invalide ou expiré'); + return; + } + + // Rediriger vers l'ancienne route avec les IDs complets + const voteUrl = `/campaigns/${participant.campaign_id}/vote/${participant.id}`; + router.replace(voteUrl); + + } catch (error) { + console.error('Erreur lors de la redirection:', error); + setError('Erreur lors du chargement du lien de vote'); + } finally { + setLoading(false); + } + }; + + if (loading) { + return ( + + + + Redirection vers la page de vote... + + + ); + } + + if (error) { + return ( + + + + + + + Erreur + {error} + router.push('/')} + className="mt-4 inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700" + > + Retour à l'accueil + + + + + ); + } + + return null; +} diff --git a/src/components/AuthGuard.tsx b/src/components/AuthGuard.tsx index d181a8f..02f8513 100644 --- a/src/components/AuthGuard.tsx +++ b/src/components/AuthGuard.tsx @@ -152,22 +152,35 @@ export default function AuthGuard({ children, requireSuperAdmin = false }: AuthG Mot de passe - setShowPassword(!showPassword)} - className="absolute left-3 top-3 text-muted-foreground hover:text-foreground" - > - {showPassword ? : } - setPassword(e.target.value)} + onKeyDown={(e) => { + // Si l'utilisateur appuie sur Tab depuis le champ mot de passe, + // déplacer le focus vers le bouton œil + if (e.key === 'Tab' && !e.shiftKey) { + e.preventDefault(); + const eyeButton = document.getElementById('password-toggle'); + if (eyeButton) { + eyeButton.focus(); + } + } + }} className="pl-10" required /> + setShowPassword(!showPassword)} + className="absolute left-3 top-3 text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 rounded" + tabIndex={0} + > + {showPassword ? : } + diff --git a/src/components/SendParticipantEmailModal.tsx b/src/components/SendParticipantEmailModal.tsx index 0dbb037..5de62e6 100644 --- a/src/components/SendParticipantEmailModal.tsx +++ b/src/components/SendParticipantEmailModal.tsx @@ -29,8 +29,10 @@ export default function SendParticipantEmailModal({ const [sending, setSending] = useState(false); const [result, setResult] = useState<{ success: boolean; message: string } | null>(null); - // Générer le lien de vote - const voteUrl = `${typeof window !== 'undefined' ? window.location.origin : ''}/campaigns/${campaign.id}/vote/${participant.id}`; + // Générer le lien de vote (utiliser uniquement le lien court) + const voteUrl = participant.short_id + ? `${typeof window !== 'undefined' ? window.location.origin : ''}/v/${participant.short_id}` + : `${typeof window !== 'undefined' ? window.location.origin : ''}/v/EN_ATTENTE`; // Initialiser le message par défaut quand le modal s'ouvre useEffect(() => { diff --git a/src/lib/services.ts b/src/lib/services.ts index b1b8a07..511f376 100644 --- a/src/lib/services.ts +++ b/src/lib/services.ts @@ -3,6 +3,39 @@ import { Campaign, Proposition, Participant, Vote, ParticipantWithVoteStatus, Se import { encryptionService } from './encryption'; import { emailService } from './email'; +// Fonction utilitaire pour générer un slug côté client +function generateSlugClient(title: string): string { + // Convertir en minuscules et remplacer les caractères spéciaux + let slug = title.toLowerCase() + .replace(/[^a-z0-9\s]/g, '') + .replace(/\s+/g, '-') + .trim(); + + // Si le slug est vide, utiliser 'campagne' + if (!slug) { + slug = 'campagne'; + } + + // Ajouter un timestamp pour éviter les conflits + const timestamp = Date.now().toString().slice(-6); + return `${slug}-${timestamp}`; +} + +// Fonction utilitaire pour générer un short_id côté client +function generateShortIdClient(): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let result = ''; + + // Générer un identifiant de 6 caractères + for (let i = 0; i < 6; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + + // Ajouter un timestamp pour éviter les conflits + const timestamp = Date.now().toString().slice(-3); + return `${result}${timestamp}`; +} + // Services pour les campagnes export const campaignService = { async getAll(): Promise { @@ -17,6 +50,27 @@ export const campaignService = { // eslint-disable-next-line @typescript-eslint/no-explicit-any async create(campaign: any): Promise { + // Générer automatiquement le slug si non fourni + if (!campaign.slug) { + try { + // Essayer d'utiliser la fonction PostgreSQL + const { data: slugData, error: slugError } = await supabase + .rpc('generate_slug', { title: campaign.title }); + + if (slugError) { + // Si la fonction n'existe pas, générer le slug côté client + console.warn('Fonction generate_slug non disponible, génération côté client:', slugError); + campaign.slug = generateSlugClient(campaign.title); + } else { + campaign.slug = slugData; + } + } catch (error) { + // Fallback vers la génération côté client + console.warn('Erreur avec generate_slug, génération côté client:', error); + campaign.slug = generateSlugClient(campaign.title); + } + } + const { data, error } = await supabase .from('campaigns') .insert(campaign) @@ -29,6 +83,27 @@ export const campaignService = { // eslint-disable-next-line @typescript-eslint/no-explicit-any async update(id: string, updates: any): Promise { + // Générer automatiquement le slug si le titre a changé et qu'aucun slug n'est fourni + if (updates.title && !updates.slug) { + try { + // Essayer d'utiliser la fonction PostgreSQL + const { data: slugData, error: slugError } = await supabase + .rpc('generate_slug', { title: updates.title }); + + if (slugError) { + // Si la fonction n'existe pas, générer le slug côté client + console.warn('Fonction generate_slug non disponible, génération côté client:', slugError); + updates.slug = generateSlugClient(updates.title); + } else { + updates.slug = slugData; + } + } catch (error) { + // Fallback vers la génération côté client + console.warn('Erreur avec generate_slug, génération côté client:', error); + updates.slug = generateSlugClient(updates.title); + } + } + const { data, error } = await supabase .from('campaigns') .update(updates) @@ -77,6 +152,23 @@ export const campaignService = { propositions: propositionsResult.count || 0, participants: participantsResult.count || 0 }; + }, + + // Nouvelle méthode pour récupérer une campagne par slug + async getBySlug(slug: string): Promise { + const { data, error } = await supabase + .from('campaigns') + .select('*') + .eq('slug', slug) + .single(); + + if (error) { + if (error.code === 'PGRST116') { + return null; // Aucune campagne trouvée + } + throw error; + } + return data; } }; @@ -160,6 +252,27 @@ export const participantService = { // eslint-disable-next-line @typescript-eslint/no-explicit-any async create(participant: any): Promise { + // Générer automatiquement le short_id si non fourni + if (!participant.short_id) { + try { + // Essayer d'utiliser la fonction PostgreSQL + const { data: shortIdData, error: shortIdError } = await supabase + .rpc('generate_short_id'); + + if (shortIdError) { + // Si la fonction n'existe pas, générer le short_id côté client + console.warn('Fonction generate_short_id non disponible, génération côté client:', shortIdError); + participant.short_id = generateShortIdClient(); + } else { + participant.short_id = shortIdData; + } + } catch (error) { + // Fallback vers la génération côté client + console.warn('Erreur avec generate_short_id, génération côté client:', error); + participant.short_id = generateShortIdClient(); + } + } + const { data, error } = await supabase .from('participants') .insert(participant) @@ -207,6 +320,23 @@ export const participantService = { .eq('id', id); if (error) throw error; + }, + + // Nouvelle méthode pour récupérer un participant par short_id + async getByShortId(shortId: string): Promise { + const { data, error } = await supabase + .from('participants') + .select('*') + .eq('short_id', shortId) + .single(); + + if (error) { + if (error.code === 'PGRST116') { + return null; // Aucun participant trouvé + } + throw error; + } + return data; } }; diff --git a/src/types/index.ts b/src/types/index.ts index 88654ad..f7d4f5e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -7,6 +7,7 @@ export interface Campaign { status: CampaignStatus; budget_per_user: number; spending_tiers: string; // Montants séparés par des virgules + slug?: string; // Slug unique pour les liens courts created_at: string; updated_at: string; } @@ -35,6 +36,7 @@ export interface Participant { first_name: string; last_name: string; email: string; + short_id?: string; // Identifiant court unique pour les liens de vote created_at: string; }
Chargement de la campagne...
{error}
+ Votre proposition a été enregistrée. Vous allez être redirigé... +
+ {campaign.description} +
+ Votre proposition a été enregistrée et sera examinée par l'équipe organisatrice. + Vous recevrez une confirmation par email. +
Redirection vers la page de vote...