lien public pour dépot propositions

This commit is contained in:
Yannick Le Duc
2025-08-25 14:43:29 +02:00
parent e0f86a8845
commit 30a228e14f
7 changed files with 519 additions and 8 deletions

View File

@@ -170,9 +170,17 @@ export default function CampaignPropositionsPage() {
<p className="text-sm text-gray-600 mb-3"> <p className="text-sm text-gray-600 mb-3">
{proposition.description} {proposition.description}
</p> </p>
<p className="text-xs text-gray-500"> <div className="flex items-center space-x-4 text-xs text-gray-500">
Créée le {new Date(proposition.created_at).toLocaleDateString('fr-FR')} <span>
</p> <strong>Auteur :</strong> {proposition.author_first_name} {proposition.author_last_name}
</span>
<span>
<strong>Email :</strong> {proposition.author_email}
</span>
<span>
<strong>Créée le :</strong> {new Date(proposition.created_at).toLocaleDateString('fr-FR')}
</span>
</div>
</div> </div>
<div className="flex items-center space-x-2 ml-6"> <div className="flex items-center space-x-2 ml-6">
<button <button

View File

@@ -18,6 +18,7 @@ export default function AdminPage() {
const [showEditModal, setShowEditModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false);
const [selectedCampaign, setSelectedCampaign] = useState<Campaign | null>(null); const [selectedCampaign, setSelectedCampaign] = useState<Campaign | null>(null);
const [copiedCampaignId, setCopiedCampaignId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
loadCampaigns(); loadCampaigns();
@@ -216,6 +217,48 @@ export default function AdminPage() {
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500">
<span className="font-medium">Paliers de dépenses:</span> {campaign.spending_tiers} <span className="font-medium">Paliers de dépenses:</span> {campaign.spending_tiers}
</div> </div>
{campaign.status === 'deposit' && (
<div className="mt-4 p-3 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex items-center justify-between">
<div className="flex-1">
<h4 className="text-sm font-medium text-blue-900 mb-1">Lien public pour déposer des propositions</h4>
<div className="flex items-center space-x-2">
<input
type="text"
readOnly
value={`${window.location.origin}/campaigns/${campaign.id}/propose`}
className="flex-1 text-xs bg-white border border-blue-300 rounded px-2 py-1 text-blue-700 font-mono"
/>
<button
onClick={() => {
navigator.clipboard.writeText(`${window.location.origin}/campaigns/${campaign.id}/propose`);
setCopiedCampaignId(campaign.id);
setTimeout(() => setCopiedCampaignId(null), 2000);
}}
className="inline-flex items-center px-2 py-1 border border-blue-300 rounded text-xs font-medium text-blue-700 bg-white hover:bg-blue-50"
title="Copier le lien"
>
{copiedCampaignId === campaign.id ? (
<>
<svg className="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Copié !
</>
) : (
<>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
</>
)}
</button>
</div>
</div>
</div>
</div>
)}
</div> </div>
<div className="flex items-center space-x-2 ml-6"> <div className="flex items-center space-x-2 ml-6">

View File

@@ -0,0 +1,331 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import Link from 'next/link';
import { Campaign } from '@/types';
import { campaignService, propositionService } from '@/lib/services';
// Force dynamic rendering to avoid SSR issues with Supabase
export const dynamic = 'force-dynamic';
export default function PublicProposePage() {
const params = useParams();
const campaignId = params.id as string;
const [campaign, setCampaign] = useState<Campaign | null>(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 (campaignId) {
loadCampaign();
}
}, [campaignId]);
const loadCampaign = async () => {
try {
setLoading(true);
const campaigns = await campaignService.getAll();
const campaignData = campaigns.find(c => c.id === campaignId);
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();
setSubmitting(true);
setError('');
try {
// Créer la proposition avec les informations de l'auteur
await propositionService.create({
campaign_id: campaignId,
title: formData.title,
description: formData.description,
author_first_name: formData.author_first_name,
author_last_name: formData.author_last_name,
author_email: formData.author_email
});
setSuccess(true);
setFormData({
title: '',
description: '',
author_first_name: '',
author_last_name: '',
author_email: ''
});
} catch (err: any) {
const errorMessage = err?.message || err?.details || 'Erreur lors de la soumission de la proposition';
setError(`Erreur lors de la soumission de la proposition: ${errorMessage}`);
} finally {
setSubmitting(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Chargement de la campagne...</p>
</div>
</div>
);
}
if (error && !campaign) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md mx-auto">
<svg className="mx-auto h-12 w-12 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<h2 className="mt-4 text-lg font-medium text-gray-900">Erreur</h2>
<p className="mt-2 text-sm text-gray-600">{error}</p>
<Link
href="/"
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
</Link>
</div>
</div>
</div>
);
}
if (success) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="bg-white rounded-lg shadow-lg p-8 max-w-md mx-auto">
<svg className="mx-auto h-12 w-12 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h2 className="mt-4 text-lg font-medium text-gray-900">Proposition soumise !</h2>
<p className="mt-2 text-sm text-gray-600">
Votre proposition a été soumise avec succès. Merci pour votre participation !
</p>
<div className="mt-6 space-y-3">
<button
onClick={() => setSuccess(false)}
className="w-full inline-flex justify-center 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"
>
Soumettre une autre proposition
</button>
<Link
href="/"
className="w-full inline-flex justify-center items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
Retour à l'accueil
</Link>
</div>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<Link
href="/"
className="inline-flex items-center text-sm text-indigo-600 hover:text-indigo-500 mb-4"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Retour à l'accueil
</Link>
<h1 className="text-3xl font-bold text-gray-900">Déposer une proposition</h1>
<p className="mt-2 text-gray-600">
Campagne : <span className="font-medium">{campaign?.title}</span>
</p>
</div>
</div>
</div>
{/* Campaign Info */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-8">
<h2 className="text-lg font-medium text-gray-900 mb-4">Informations sur la campagne</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<h3 className="text-sm font-medium text-gray-500">Description</h3>
<p className="mt-1 text-sm text-gray-900">{campaign?.description}</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Budget par participant</h3>
<p className="mt-1 text-sm text-gray-900">{campaign?.budget_per_user}€</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Paliers de dépenses</h3>
<p className="mt-1 text-sm text-gray-900">{campaign?.spending_tiers}</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500">Statut</h3>
<span className="mt-1 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
Dépôt de propositions
</span>
</div>
</div>
</div>
{/* Form */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">Votre proposition</h2>
<p className="mt-1 text-sm text-gray-600">
Remplissez le formulaire ci-dessous pour soumettre votre proposition.
</p>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-md">
{error}
</div>
)}
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
Titre de la proposition *
</label>
<input
type="text"
id="title"
name="title"
value={formData.title}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Titre de votre proposition"
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
Description *
</label>
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
required
rows={6}
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Décrivez votre proposition en détail..."
/>
</div>
<div className="border-t border-gray-200 pt-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">Vos informations</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="author_first_name" className="block text-sm font-medium text-gray-700 mb-1">
Prénom *
</label>
<input
type="text"
id="author_first_name"
name="author_first_name"
value={formData.author_first_name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Votre prénom"
/>
</div>
<div>
<label htmlFor="author_last_name" className="block text-sm font-medium text-gray-700 mb-1">
Nom *
</label>
<input
type="text"
id="author_last_name"
name="author_last_name"
value={formData.author_last_name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Votre nom"
/>
</div>
</div>
<div className="mt-4">
<label htmlFor="author_email" className="block text-sm font-medium text-gray-700 mb-1">
Email *
</label>
<input
type="email"
id="author_email"
name="author_email"
value={formData.author_email}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="votre.email@exemple.com"
/>
</div>
</div>
<div className="flex items-center justify-end pt-6">
<button
type="submit"
disabled={submitting}
className="px-6 py-3 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? 'Soumission...' : 'Soumettre la proposition'}
</button>
</div>
</form>
</div>
</div>
</div>
);
}

View File

@@ -22,7 +22,10 @@ export default function AddPropositionModal({
}: AddPropositionModalProps) { }: AddPropositionModalProps) {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
title: '', title: '',
description: '' description: '',
author_first_name: '',
author_last_name: '',
author_email: ''
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -36,13 +39,19 @@ export default function AddPropositionModal({
await propositionService.create({ await propositionService.create({
campaign_id: campaignId, campaign_id: campaignId,
title: formData.title, title: formData.title,
description: formData.description description: formData.description,
author_first_name: formData.author_first_name,
author_last_name: formData.author_last_name,
author_email: formData.author_email
}); });
onSuccess(); onSuccess();
// Reset form // Reset form
setFormData({ setFormData({
title: '', title: '',
description: '' description: '',
author_first_name: '',
author_last_name: '',
author_email: ''
}); });
} catch (err) { } catch (err) {
setError('Erreur lors de l\'ajout de la proposition'); setError('Erreur lors de l\'ajout de la proposition');
@@ -123,6 +132,60 @@ export default function AddPropositionModal({
/> />
</div> </div>
<div className="border-t border-gray-200 pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Informations de l'auteur</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label htmlFor="author_first_name" className="block text-sm font-medium text-gray-700 mb-1">
Prénom *
</label>
<input
type="text"
id="author_first_name"
name="author_first_name"
value={formData.author_first_name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Prénom"
/>
</div>
<div>
<label htmlFor="author_last_name" className="block text-sm font-medium text-gray-700 mb-1">
Nom *
</label>
<input
type="text"
id="author_last_name"
name="author_last_name"
value={formData.author_last_name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Nom"
/>
</div>
</div>
<div className="mt-3">
<label htmlFor="author_email" className="block text-sm font-medium text-gray-700 mb-1">
Email *
</label>
<input
type="email"
id="author_email"
name="author_email"
value={formData.author_email}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="email@exemple.com"
/>
</div>
</div>
<div className="flex items-center justify-end space-x-3 pt-4"> <div className="flex items-center justify-end space-x-3 pt-4">
<button <button
type="button" type="button"

View File

@@ -21,7 +21,10 @@ export default function EditPropositionModal({
}: EditPropositionModalProps) { }: EditPropositionModalProps) {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
title: '', title: '',
description: '' description: '',
author_first_name: '',
author_last_name: '',
author_email: ''
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -31,7 +34,10 @@ export default function EditPropositionModal({
if (proposition) { if (proposition) {
setFormData({ setFormData({
title: proposition.title, title: proposition.title,
description: proposition.description description: proposition.description,
author_first_name: proposition.author_first_name,
author_last_name: proposition.author_last_name,
author_email: proposition.author_email
}); });
} }
}, [proposition]); }, [proposition]);
@@ -117,6 +123,60 @@ export default function EditPropositionModal({
/> />
</div> </div>
<div className="border-t border-gray-200 pt-4">
<h3 className="text-sm font-medium text-gray-900 mb-3">Informations de l'auteur</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label htmlFor="author_first_name" className="block text-sm font-medium text-gray-700 mb-1">
Prénom *
</label>
<input
type="text"
id="author_first_name"
name="author_first_name"
value={formData.author_first_name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Prénom"
/>
</div>
<div>
<label htmlFor="author_last_name" className="block text-sm font-medium text-gray-700 mb-1">
Nom *
</label>
<input
type="text"
id="author_last_name"
name="author_last_name"
value={formData.author_last_name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="Nom"
/>
</div>
</div>
<div className="mt-3">
<label htmlFor="author_email" className="block text-sm font-medium text-gray-700 mb-1">
Email *
</label>
<input
type="email"
id="author_email"
name="author_email"
value={formData.author_email}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
placeholder="email@exemple.com"
/>
</div>
</div>
<div className="flex items-center justify-end space-x-3 pt-4"> <div className="flex items-center justify-end space-x-3 pt-4">
<button <button
type="button" type="button"

View File

@@ -23,6 +23,9 @@ export interface Proposition {
campaign_id: string; campaign_id: string;
title: string; title: string;
description: string; description: string;
author_first_name: string;
author_last_name: string;
author_email: string;
created_at: string; created_at: string;
} }

View File

@@ -18,6 +18,9 @@ CREATE TABLE propositions (
campaign_id UUID NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE, campaign_id UUID NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
title TEXT NOT NULL, title TEXT NOT NULL,
description TEXT NOT NULL, description TEXT NOT NULL,
author_first_name TEXT NOT NULL,
author_last_name TEXT NOT NULL,
author_email TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
); );