// Copyright (C) 2026 billisdead — Licence EUPL-1.2 import React from "react"; import { Link } from "wouter"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { format } from "date-fns"; import { fr } from "date-fns/locale"; import { useQueryClient } from "@tanstack/react-query"; import HCaptcha from "@hcaptcha/react-hcaptcha"; import { useSubmitIdea, useListIdeas, useGetIdeaStats, useGetSynthesis, getListIdeasQueryKey, getGetIdeaStatsQueryKey, addExtraHeader, removeExtraHeader, getVisitorId, ApiError, } from "@workspace/api-client-react"; import { ConsentDialog } from "@/components/consent-dialog"; import { Button } from "@/components/ui/button"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Textarea } from "@/components/ui/textarea"; import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; import { Loader2, PenTool, CheckCircle2, Info, AlertCircle, TrendingUp, Users, Scale, Share2, Printer, Copy, Flag, BookOpen, } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; const submitIdeaSchema = z.object({ content: z.string() .min(10, "Votre contribution doit faire au moins 10 caractères.") .max(1000, "Merci de rester sous 1 000 caractères."), author: z.string().max(100).optional(), }); type SubmitIdeaValues = z.infer; const VALEURS = [ { source: "DUDH — Art. 1 (ONU, 1948)", texte: "Tous les êtres humains naissent libres et égaux en dignité et en droits.", }, { source: "DUDH — Art. 19", texte: "Tout individu a droit à la liberté d'opinion et d'expression.", }, { source: "DUDH — Art. 20", texte: "Tout appel à la haine nationale, raciale ou religieuse constituant une incitation à la discrimination, à l'hostilité ou à la violence est interdit par la loi.", }, { source: "PIDCP — Art. 20 (ONU, 1966)", texte: "Tout appel à la haine nationale, raciale ou religieuse qui constitue une incitation à la discrimination, à l'hostilité ou à la violence est interdit par la loi.", }, { source: "CEDH — Art. 10 (Conseil de l'Europe, 1950)", texte: "Toute personne a droit à la liberté d'expression, sous réserve des restrictions nécessaires à la protection des droits d'autrui.", }, { source: "CEDH — Art. 17", texte: "Aucune disposition de la Convention ne peut être interprétée comme impliquant le droit de se livrer à une activité visant à la destruction des droits reconnus.", }, { source: "Charte des droits fondamentaux de l'UE — Art. 1 (2000)", texte: "La dignité humaine est inviolable. Elle doit être respectée et protégée.", }, ]; // Clé hCaptcha — activée si la variable d'environnement est définie const HCAPTCHA_SITE_KEY = import.meta.env.VITE_HCAPTCHA_SITE_KEY as string | undefined; // Détecte "Sur X :" / "Concernant Y :" en début de bloc et met le préfixe en valeur function SynthesisText({ text }: { text: string }) { const THEME_RE = /^((?:Sur|Concernant|À propos d[eu]?|En ce qui concerne|Quant à)[^:]{0,100}:\s?)([\s\S]*)$/i; const blocks = text.split(/\n{2,}/); return (
{blocks.map((block, i) => { const trimmed = block.trim(); if (!trimmed) return null; const m = trimmed.match(THEME_RE); if (m) { return (

{m[1]} {m[2]}

); } return (

{trimmed}

); })}
); } const API_BASE = import.meta.env.VITE_API_URL ?? ""; export default function Home() { const queryClient = useQueryClient(); const { toast } = useToast(); const [submitResult, setSubmitResult] = React.useState<{ success: boolean; message: string; reason?: string; } | null>(null); const [flaggedIds, setFlaggedIds] = React.useState>(new Set()); const [flaggingId, setFlaggingId] = React.useState(null); // Ref pour le champ leurre honeypot — invisible, non relié à react-hook-form const honeypotRef = React.useRef(null); // hCaptcha — widget et token const captchaRef = React.useRef(null); const [captchaToken, setCaptchaToken] = React.useState(null); // Consentement RGPD — persisté dans localStorage (art. 9.2.a) const [consentGiven, setConsentGiven] = React.useState( () => !!localStorage.getItem("consent_v1") ); const [showConsentDialog, setShowConsentDialog] = React.useState(false); const pendingSubmitData = React.useRef(null); const handleFlag = async (ideaId: number) => { if (flaggedIds.has(ideaId) || flaggingId === ideaId) return; setFlaggingId(ideaId); try { const res = await fetch(`${API_BASE}/api/ideas/${ideaId}/flag`, { method: "POST" }); if (res.ok) { setFlaggedIds((prev) => new Set(prev).add(ideaId)); toast({ title: "Signalement envoyé", description: "Cette contribution a été signalée à l'administrateur." }); } else { toast({ title: "Erreur", description: "Impossible d'envoyer le signalement.", variant: "destructive" }); } } catch { toast({ title: "Erreur réseau", description: "Vérifiez votre connexion.", variant: "destructive" }); } finally { setFlaggingId(null); } }; const submitIdea = useSubmitIdea(); const { data: ideas, isLoading: isLoadingIdeas } = useListIdeas(); const { data: stats } = useGetIdeaStats(); const { data: synthesis, isLoading: isLoadingSynthesis } = useGetSynthesis({ query: { refetchInterval: 15000 }, }); const [countdown, setCountdown] = React.useState(15); React.useEffect(() => { setCountdown(15); }, [synthesis]); React.useEffect(() => { if (countdown <= 0) return; const t = setTimeout(() => setCountdown(c => c - 1), 1000); return () => clearTimeout(t); }, [countdown]); const [mobileTab, setMobileTab] = React.useState<"proposer" | "synthese">("proposer"); const [lawCheckResult, setLawCheckResult] = React.useState(null); const [lawCheckLoading, setLawCheckLoading] = React.useState(false); // Soumission effective après confirmation du consentement (ou si déjà consenti) const doActualSubmit = (data: SubmitIdeaValues) => { if (captchaToken) { addExtraHeader("x-hcaptcha-token", captchaToken); } setSubmitResult(null); submitIdea.mutate({ data }, { onSuccess: (result) => { if (result.accepted) { setSubmitResult({ success: true, message: "Votre contribution a été ajoutée à la synthèse." }); form.reset(); } else { setSubmitResult({ success: false, message: "Cette contribution n'a pas pu être intégrée : elle n'est pas compatible avec le cadre de modération de cette plateforme.", reason: result.reason ?? undefined, }); } queryClient.invalidateQueries({ queryKey: getListIdeasQueryKey() }); queryClient.invalidateQueries({ queryKey: getGetIdeaStatsQueryKey() }); }, onError: (error) => { const apiMessage = error instanceof ApiError ? (error.data as { message?: string } | null)?.message : undefined; setSubmitResult({ success: false, message: apiMessage ?? "Une erreur est survenue lors de l'envoi. Veuillez réessayer.", }); }, onSettled: () => { removeExtraHeader("x-hcaptcha-token"); captchaRef.current?.resetCaptcha(); setCaptchaToken(null); }, }); }; const postConsent = async () => { const visitorId = getVisitorId(); await fetch(`${API_BASE}/api/consent`, { method: "POST", headers: { "Content-Type": "application/json", ...(visitorId ? { "X-Visitor-Id": visitorId } : {}), }, body: JSON.stringify({ consent_version: "1.0" }), }).catch(() => {}); }; // Confirme le consentement, l'enregistre en DB, puis exécute la soumission en attente const handleConsentConfirm = async () => { localStorage.setItem("consent_v1", new Date().toISOString()); setConsentGiven(true); setShowConsentDialog(false); await postConsent(); if (pendingSubmitData.current) { doActualSubmit(pendingSubmitData.current); pendingSubmitData.current = null; } }; const checkLaw = async () => { const content = form.getValues("content")?.trim(); if (!content || content.length < 10) return; setLawCheckLoading(true); setLawCheckResult(null); try { const res = await fetch(`${API_BASE}/api/check-law`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content }), }); const data = await res.json(); setLawCheckResult(data.note || null); } catch { setLawCheckResult(null); } finally { setLawCheckLoading(false); } }; const handleShare = () => { if (!synthesis?.text) return; const date = format(new Date(), "d MMMM yyyy 'à' HH:mm", { locale: fr }); const nb = synthesis.ideaCount ?? 0; const shareText = `La Voix du Peuple — Synthèse citoyenne\nGénérée le ${date}\n\n${synthesis.text}\n\n(${nb} contribution${nb !== 1 ? "s" : ""} intégrée${nb !== 1 ? "s" : ""})\n\nhttps://lavoixdupeuple.fr`; if (navigator.share) { navigator.share({ title: "La Voix du Peuple — Synthèse", text: shareText }); } else { navigator.clipboard.writeText(shareText).then(() => { toast({ description: "Texte copié dans le presse-papier ✓" }); }); } }; const handlePrint = () => { if (!synthesis?.text) return; const date = format(new Date(), "d MMMM yyyy 'à' HH:mm", { locale: fr }); const nb = synthesis.ideaCount ?? 0; const esc = (s: string) => s.replace(/&/g, "&").replace(//g, ">"); const THEME_RE = /^((?:Sur|Concernant|À propos d[eu]?|En ce qui concerne|Quant à)[^:]{0,100}:\s?)([\s\S]*)$/i; const formattedText = synthesis.text .split(/\n{2,}/) .filter(Boolean) .map((block) => { const t = block.trim(); const m = t.match(THEME_RE); if (m) return `

${esc(m[1])}${esc(m[2])}

`; return `

${esc(t)}

`; }) .join("\n"); const html = ` La Voix du Peuple — Synthèse

La Voix du Peuple · Synthèse citoyenne

Synthèse des contributions

Générée le ${esc(date)}  ·  ${nb} contribution${nb !== 1 ? "s" : ""} intégrée${nb !== 1 ? "s" : ""}
${formattedText}
`; const w = window.open("", "_blank"); if (w) { w.document.write(html); w.document.close(); } }; const form = useForm({ resolver: zodResolver(submitIdeaSchema), defaultValues: { content: "", author: "" }, }); const onSubmit = async (data: SubmitIdeaValues) => { // Honeypot — si le champ leurre est rempli, c'est un bot if (honeypotRef.current?.value) { setSubmitResult({ success: true, message: "Votre contribution a été ajoutée à la synthèse." }); form.reset(); return; } // hCaptcha — obligatoire si la clé de site est configurée if (HCAPTCHA_SITE_KEY && !captchaToken) { toast({ title: "Vérification requise", description: "Veuillez valider le CAPTCHA avant de soumettre.", variant: "destructive" }); return; } // Consentement RGPD — obligatoire avant la première contribution if (!consentGiven) { pendingSubmitData.current = data; setShowConsentDialog(true); return; } await postConsent(); doActualSubmit(data); }; return ( <> { setShowConsentDialog(false); pendingSubmitData.current = null; }} /> {/* Bandeau d'introduction */}

La Voix du Peuple est un espace d'expression citoyenne, pas un sondage ni une vérité établie. Exprimez-vous librement — chaque contribution est modérée selon le droit international des droits humains, puis reflétée dans la synthèse collective affichée à droite. Ce que vous lisez représente ce que des personnes ont choisi d'exprimer, pas un consensus validé.

{stats && stats.total > 0 && (
{stats.accepted}
acceptées
{stats.total}
soumises
)}
{/* Onglets mobile — masqués sur md+ */}
{/* Colonne gauche : formulaire + fil des idées */}

Vos propositions

Quelle mesure souhaiteriez-vous voir portée par vos représentants ?

{/* Champ leurre anti-bot (honeypot) — invisible, ne jamais supprimer */} ( Votre idée