P5 — Mode consultation ciblée (Option B, implémentation complète)
Backend : - Nouvelle table `consultations` (slug unique, fenêtre temporelle, webhook, logo) - `ideas.consultation_id` FK nullable (NULL = contexte global home) - `synthesis.consultation_id` FK nullable (synthèse par contexte) - Boucle auto-fermeture (thread daemon, 60 s) — ferme + webhook à l'échéance - Webhook de clôture : POST JSON (synthèse + métadonnées) via urllib.request - Routes publiques : GET/POST /api/consultations/<slug>, synthèse, contributions, export/print - Routes admin : list, create, close (+ webhook), delete (cascade explicite) - CSP ajustée sur /export/print pour autoriser window.print() Frontend : - Nouvelle page /consultation/:slug — formulaire, synthèse live, contributions paginées, PDF - Admin panel : onglet Consultations — liste, formulaire création, fermeture, suppression Docs : DAT.md v1.5, DEX.md v1.7 (section P5, tables, routes, webhook) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import Admin from "@/pages/admin";
|
||||
import LegalNotice from "@/pages/legal-notice";
|
||||
import PrivacyPolicy from "@/pages/privacy-policy";
|
||||
import ContributionsBrutes from "@/pages/contributions-brutes";
|
||||
import ConsultationPage from "@/pages/consultation";
|
||||
import { AccessibilityProvider } from "@/hooks/use-accessibility";
|
||||
import { AccessibilityPanel } from "@/components/accessibility-panel";
|
||||
import { setVisitorId } from "@workspace/api-client-react";
|
||||
@@ -104,6 +105,7 @@ function Router() {
|
||||
<Route path="/mentions-legales" component={LegalNotice} />
|
||||
<Route path="/politique-confidentialite" component={PrivacyPolicy} />
|
||||
<Route path="/contributions-brutes" component={ContributionsBrutes} />
|
||||
<Route path="/consultation/:slug" component={ConsultationPage} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
</main>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,525 @@
|
||||
// Copyright (C) 2026 billisdead — Licence EUPL-1.2
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useRoute, Link } from "wouter";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { getVisitorId } from "@workspace/api-client-react";
|
||||
import { ConsentDialog } from "@/components/consent-dialog";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Clock, Users, ChevronLeft, ChevronRight, Building2,
|
||||
Loader2, CheckCircle2, XCircle, Lock, Printer, Share2,
|
||||
Info, ArrowLeft,
|
||||
} from "lucide-react";
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
import { fr } from "date-fns/locale";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? "";
|
||||
|
||||
type Consultation = {
|
||||
id: number;
|
||||
slug: string;
|
||||
title: string;
|
||||
subject: string;
|
||||
introMessage: string | null;
|
||||
organizerName: string | null;
|
||||
organizerLogoUrl: string | null;
|
||||
startsAt: string | null;
|
||||
endsAt: string | null;
|
||||
closedAt: string | null;
|
||||
isOpen: boolean;
|
||||
stats: { total: number; accepted: number; lastUpdated: string | null };
|
||||
};
|
||||
|
||||
type Synthesis = {
|
||||
text: string;
|
||||
ideaCount: number;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
type Contribution = {
|
||||
id: number;
|
||||
content: string;
|
||||
author: string | null;
|
||||
createdAt: string | null;
|
||||
};
|
||||
|
||||
type SubmitValues = {
|
||||
content: string;
|
||||
author?: string;
|
||||
_hp?: string;
|
||||
};
|
||||
|
||||
export default function ConsultationPage() {
|
||||
const [, params] = useRoute<{ slug: string }>("/consultation/:slug");
|
||||
const slug = params?.slug ?? "";
|
||||
|
||||
const [consultation, setConsultation] = useState<Consultation | null>(null);
|
||||
const [synthesis, setSynthesis] = useState<Synthesis | null>(null);
|
||||
const [contributions, setContributions] = useState<Contribution[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pages, setPages] = useState(1);
|
||||
const [loadingPage, setLoadingPage] = useState(true);
|
||||
const [pageError, setPageError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitResult, setSubmitResult] = useState<{ accepted: boolean; reason?: string | null } | null>(null);
|
||||
const [consentGiven, setConsentGiven] = useState(() => !!localStorage.getItem("consent_v1"));
|
||||
const [showConsentDialog, setShowConsentDialog] = useState(false);
|
||||
const pendingSubmitData = useRef<SubmitValues | null>(null);
|
||||
|
||||
const { toast } = useToast();
|
||||
const { register, handleSubmit, reset, watch, formState: { errors } } = useForm<SubmitValues>();
|
||||
const contentValue = watch("content", "");
|
||||
|
||||
const fetchConsultation = useCallback(async () => {
|
||||
if (!slug) return;
|
||||
setLoadingPage(true);
|
||||
setPageError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/consultations/${encodeURIComponent(slug)}`);
|
||||
if (!res.ok) {
|
||||
setPageError("Consultation introuvable ou inaccessible.");
|
||||
return;
|
||||
}
|
||||
setConsultation(await res.json());
|
||||
} catch {
|
||||
setPageError("Impossible de charger la consultation.");
|
||||
} finally {
|
||||
setLoadingPage(false);
|
||||
}
|
||||
}, [slug]);
|
||||
|
||||
const fetchSynthesis = useCallback(async () => {
|
||||
if (!slug) return;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/consultations/${encodeURIComponent(slug)}/synthesis`);
|
||||
if (res.ok) setSynthesis(await res.json());
|
||||
} catch { /* non-bloquant */ }
|
||||
}, [slug]);
|
||||
|
||||
const fetchContributions = useCallback(async (p: number) => {
|
||||
if (!slug) return;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/consultations/${encodeURIComponent(slug)}/contributions?page=${p}&per_page=20`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setContributions(data.contributions ?? []);
|
||||
setTotal(data.total ?? 0);
|
||||
setPages(data.pages ?? 1);
|
||||
}
|
||||
} catch { /* non-bloquant */ }
|
||||
}, [slug]);
|
||||
|
||||
useEffect(() => { fetchConsultation(); }, [fetchConsultation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!consultation) return;
|
||||
fetchSynthesis();
|
||||
fetchContributions(1);
|
||||
}, [consultation, fetchSynthesis, fetchContributions]);
|
||||
|
||||
const doActualSubmit = async (data: SubmitValues) => {
|
||||
setSubmitting(true);
|
||||
setSubmitResult(null);
|
||||
try {
|
||||
const visitorId = getVisitorId();
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (visitorId) headers["X-Visitor-Id"] = visitorId;
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE}/api/consultations/${encodeURIComponent(slug)}/ideas`,
|
||||
{ method: "POST", headers, body: JSON.stringify({ content: data.content, author: data.author || undefined }) }
|
||||
);
|
||||
const result = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
toast({ title: "Erreur", description: result.message || "Une erreur s'est produite.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitResult({ accepted: result.accepted, reason: result.reason });
|
||||
if (result.accepted) {
|
||||
reset();
|
||||
setTimeout(() => { fetchConsultation(); fetchSynthesis(); fetchContributions(1); }, 1500);
|
||||
}
|
||||
} catch {
|
||||
toast({ title: "Erreur réseau", description: "Impossible de joindre le serveur.", variant: "destructive" });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConsentConfirm = async () => {
|
||||
localStorage.setItem("consent_v1", "true");
|
||||
setConsentGiven(true);
|
||||
setShowConsentDialog(false);
|
||||
try {
|
||||
const visitorId = getVisitorId();
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (visitorId) headers["X-Visitor-Id"] = visitorId;
|
||||
await fetch(`${API_BASE}/api/consent`, {
|
||||
method: "POST", headers,
|
||||
body: JSON.stringify({ consent_version: "1.0" }),
|
||||
});
|
||||
} catch { /* non-bloquant */ }
|
||||
if (pendingSubmitData.current) {
|
||||
await doActualSubmit(pendingSubmitData.current);
|
||||
pendingSubmitData.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: SubmitValues) => {
|
||||
if (!consentGiven) {
|
||||
pendingSubmitData.current = data;
|
||||
setShowConsentDialog(true);
|
||||
return;
|
||||
}
|
||||
await doActualSubmit(data);
|
||||
};
|
||||
|
||||
const changePage = (newPage: number) => {
|
||||
setPage(newPage);
|
||||
fetchContributions(newPage);
|
||||
};
|
||||
|
||||
const shareSynthesis = async () => {
|
||||
if (!synthesis || !consultation) return;
|
||||
const text = `${consultation.title}\n\n${synthesis.text}\n\n— La Voix du Peuple, ${new Date().toLocaleDateString("fr-FR")}`;
|
||||
if (navigator.share) {
|
||||
await navigator.share({ title: consultation.title, text });
|
||||
} else {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast({ title: "Synthèse copiée dans le presse-papier" });
|
||||
}
|
||||
};
|
||||
|
||||
const printPdf = () => {
|
||||
window.open(`${API_BASE}/api/consultations/${encodeURIComponent(slug)}/export/print`, "_blank");
|
||||
};
|
||||
|
||||
if (loadingPage) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh] gap-3 text-muted-foreground">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
Chargement de la consultation…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pageError || !consultation) {
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto px-4 py-16 text-center space-y-4">
|
||||
<p className="text-muted-foreground">{pageError ?? "Consultation introuvable."}</p>
|
||||
<Link href="/">
|
||||
<Button variant="outline" size="sm">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" /> Retour à l'accueil
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isOpen = consultation.isOpen;
|
||||
const isClosed = !!consultation.closedAt;
|
||||
const endsAt = consultation.endsAt ? new Date(consultation.endsAt) : null;
|
||||
const closedAt = consultation.closedAt ? new Date(consultation.closedAt) : null;
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-4 py-8 space-y-8">
|
||||
|
||||
{/* En-tête de consultation */}
|
||||
<div className="rounded-xl border border-border bg-card p-6 space-y-4">
|
||||
|
||||
{(consultation.organizerName || consultation.organizerLogoUrl) && (
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
{consultation.organizerLogoUrl ? (
|
||||
<img
|
||||
src={consultation.organizerLogoUrl}
|
||||
alt={consultation.organizerName ?? "Organisateur"}
|
||||
className="h-8 w-8 rounded object-contain border border-border"
|
||||
/>
|
||||
) : (
|
||||
<Building2 className="h-5 w-5 flex-shrink-0" />
|
||||
)}
|
||||
<span className="font-medium text-foreground">{consultation.organizerName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-start gap-3">
|
||||
<div className="flex-1 space-y-1">
|
||||
<h1 className="font-serif text-2xl font-bold text-primary leading-tight">
|
||||
{consultation.title}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{consultation.subject}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
{isClosed ? (
|
||||
<Badge variant="secondary" className="gap-1.5">
|
||||
<Lock className="h-3 w-3" /> Clôturée
|
||||
</Badge>
|
||||
) : isOpen ? (
|
||||
<Badge className="bg-green-600 hover:bg-green-700 gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-white/80 animate-pulse inline-block" />
|
||||
En cours
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="gap-1.5">
|
||||
<Clock className="h-3 w-3" /> À venir
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{consultation.introMessage && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm whitespace-pre-line">
|
||||
{consultation.introMessage}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-xs text-muted-foreground border-t border-border pt-4">
|
||||
{endsAt && !isClosed && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3 w-3" />
|
||||
{isOpen
|
||||
? `Fermeture ${formatDistanceToNow(endsAt, { locale: fr, addSuffix: true })}`
|
||||
: `Ouverture ${formatDistanceToNow(endsAt, { locale: fr, addSuffix: true })}`}
|
||||
{" · "}
|
||||
{format(endsAt, "d MMMM yyyy à HH:mm", { locale: fr })} UTC
|
||||
</span>
|
||||
)}
|
||||
{closedAt && (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Lock className="h-3 w-3" />
|
||||
Clôturée le {format(closedAt, "d MMMM yyyy", { locale: fr })}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Users className="h-3 w-3" />
|
||||
{consultation.stats.accepted} contribution{consultation.stats.accepted !== 1 ? "s" : ""} intégrée{consultation.stats.accepted !== 1 ? "s" : ""}
|
||||
{consultation.stats.total > consultation.stats.accepted && (
|
||||
<span className="opacity-60 ml-1">
|
||||
({consultation.stats.total} soumise{consultation.stats.total !== 1 ? "s" : ""})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
{/* Colonne gauche : formulaire + contributions */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Formulaire (consultation ouverte) */}
|
||||
{isOpen && (
|
||||
<div className="rounded-xl border border-border bg-card p-5 space-y-4">
|
||||
<h2 className="font-serif text-lg font-semibold text-primary">
|
||||
Exprimer votre avis
|
||||
</h2>
|
||||
|
||||
{submitResult ? (
|
||||
<div className={`rounded-lg border p-4 space-y-2 ${
|
||||
submitResult.accepted
|
||||
? "border-green-200 bg-green-50"
|
||||
: "border-red-200 bg-red-50"
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 font-medium text-sm">
|
||||
{submitResult.accepted
|
||||
? <><CheckCircle2 className="h-4 w-4 text-green-600" /> Contribution intégrée à la synthèse</>
|
||||
: <><XCircle className="h-4 w-4 text-red-600" /> Contribution non retenue</>
|
||||
}
|
||||
</div>
|
||||
{!submitResult.accepted && submitResult.reason && (
|
||||
<p className="text-xs text-red-700 mt-1">{submitResult.reason}</p>
|
||||
)}
|
||||
<Button size="sm" variant="outline" className="mt-2" onClick={() => setSubmitResult(null)}>
|
||||
Soumettre une autre contribution
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-3">
|
||||
<div>
|
||||
<Textarea
|
||||
{...register("content", {
|
||||
required: "Le texte est requis.",
|
||||
minLength: { value: 10, message: "Au moins 10 caractères." },
|
||||
maxLength: { value: 1000, message: "Maximum 1000 caractères." },
|
||||
})}
|
||||
placeholder="Partagez votre avis, proposition ou témoignage…"
|
||||
rows={4}
|
||||
className="resize-none text-sm"
|
||||
disabled={submitting}
|
||||
aria-label="Votre contribution"
|
||||
/>
|
||||
<div className="flex justify-between mt-1">
|
||||
{errors.content
|
||||
? <p className="text-xs text-red-600">{errors.content.message}</p>
|
||||
: <span />
|
||||
}
|
||||
<span className={`text-xs ${(contentValue?.length ?? 0) > 900 ? "text-orange-500" : "text-muted-foreground"}`}>
|
||||
{contentValue?.length ?? 0}/1000
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
{...register("author")}
|
||||
placeholder="Pseudonyme (facultatif)"
|
||||
className="text-sm"
|
||||
disabled={submitting}
|
||||
/>
|
||||
{/* Champ leurre anti-bot — ne pas remplir */}
|
||||
<input
|
||||
{...register("_hp")}
|
||||
type="text"
|
||||
style={{ display: "none" }}
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Button type="submit" className="w-full" disabled={submitting}>
|
||||
{submitting
|
||||
? <><Loader2 className="h-4 w-4 animate-spin mr-2" /> Envoi en cours…</>
|
||||
: "Soumettre ma contribution"
|
||||
}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
Chaque contribution est filtrée par IA selon le droit international des droits humains.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Consultation pas encore ouverte */}
|
||||
{!isOpen && !isClosed && (
|
||||
<Alert>
|
||||
<Clock className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
Cette consultation n'est pas encore ouverte aux contributions.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Consultation fermée */}
|
||||
{isClosed && (
|
||||
<Alert>
|
||||
<Lock className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">
|
||||
Cette consultation est clôturée. Les contributions ne sont plus acceptées.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Contributions */}
|
||||
{contributions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="font-serif text-lg font-semibold text-primary">
|
||||
Contributions ({total})
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{contributions.map((c) => (
|
||||
<div key={c.id} className="rounded-lg border border-border bg-card p-3 space-y-1">
|
||||
<p className="text-sm leading-relaxed">{c.content}</p>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{c.author || "Anonyme"}</span>
|
||||
{c.createdAt && (
|
||||
<span>{format(new Date(c.createdAt), "d MMM yyyy", { locale: fr })}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{pages > 1 && (
|
||||
<div className="flex items-center justify-center gap-3 pt-2">
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => changePage(page - 1)}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground font-mono">{page} / {pages}</span>
|
||||
<Button size="sm" variant="outline" disabled={page >= pages} onClick={() => changePage(page + 1)}>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Colonne droite : synthèse */}
|
||||
<div className="space-y-4">
|
||||
{synthesis && synthesis.ideaCount > 0 ? (
|
||||
<div className="rounded-xl border border-primary/20 bg-primary/5 p-5 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="font-serif text-lg font-semibold text-primary">Synthèse collective</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{synthesis.ideaCount} contribution{synthesis.ideaCount !== 1 ? "s" : ""} intégrée{synthesis.ideaCount !== 1 ? "s" : ""}
|
||||
{synthesis.updatedAt && (
|
||||
<> · mise à jour {formatDistanceToNow(new Date(synthesis.updatedAt), { locale: fr, addSuffix: true })}</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1.5 flex-shrink-0">
|
||||
<Button
|
||||
size="sm" variant="outline" className="h-7 px-2"
|
||||
onClick={shareSynthesis} title="Copier / partager"
|
||||
aria-label="Copier ou partager la synthèse"
|
||||
>
|
||||
<Share2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm" variant="outline" className="h-7 px-2"
|
||||
onClick={printPdf} title="Exporter en PDF"
|
||||
aria-label="Exporter la synthèse en PDF"
|
||||
>
|
||||
<Printer className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm leading-relaxed whitespace-pre-line text-foreground/90">
|
||||
{synthesis.text}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground italic border-t border-border/50 pt-3">
|
||||
Synthèse générée par IA à partir des contributions citoyennes.
|
||||
Elle reflète l'expression des participants, pas une vérité établie.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border/50 bg-muted/30 p-6 text-center space-y-2">
|
||||
<Users className="h-8 w-8 mx-auto text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
La synthèse apparaîtra dès les premières contributions acceptées.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2">
|
||||
<Link href="/">
|
||||
<Button variant="ghost" size="sm" className="text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="h-3.5 w-3.5 mr-1.5" />
|
||||
Retour à la plateforme principale
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConsentDialog
|
||||
open={showConsentDialog}
|
||||
onConsent={handleConsentConfirm}
|
||||
onCancel={() => { setShowConsentDialog(false); pendingSubmitData.current = null; }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user