Add sharing options and a printable flyer page to the website

Implement share and PDF export buttons on the home page, and create a new flyer page with a customizable QR code for printing and distribution.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 923ae0e3-a363-4db8-b04a-e8baca2a1330
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: a2b8df7d-660f-4020-961b-e37cb231d6a4
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/8af7d2ec-2cc3-4ece-8af3-9f071488d072/923ae0e3-a363-4db8-b04a-e8baca2a1330/Z3YUti7
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
pironantoine
2026-04-04 10:24:23 +00:00
parent 213a67e612
commit 7e9eb3c360
7 changed files with 261 additions and 8 deletions
+3
View File
@@ -73,5 +73,8 @@
"vite": "catalog:",
"wouter": "^3.3.5",
"zod": "catalog:"
},
"dependencies": {
"qrcode.react": "^4.2.0"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 133 KiB

+3
View File
@@ -6,6 +6,7 @@ import NotFound from "@/pages/not-found";
import Home from "@/pages/home";
import About from "@/pages/about";
import Transparence from "@/pages/transparence";
import Flyer from "@/pages/flyer";
const queryClient = new QueryClient({
defaultOptions: {
@@ -27,6 +28,7 @@ function Navbar() {
<Link href="/" className="transition-colors hover:text-foreground/80 text-foreground/60" data-testid="nav-manifesto-link">Manifeste</Link>
<Link href="/about" className="transition-colors hover:text-foreground/80 text-foreground/60" data-testid="nav-about-link">À propos</Link>
<Link href="/transparence" className="transition-colors hover:text-foreground/80 text-foreground/60" data-testid="nav-transparence-link">Fonctionnement</Link>
<Link href="/flyer" className="transition-colors hover:text-foreground/80 text-foreground/60" data-testid="nav-flyer-link">Flyer QR</Link>
</nav>
<div className="ml-auto flex items-center">
<span
@@ -54,6 +56,7 @@ function Router() {
<Route path="/" component={Home} />
<Route path="/about" component={About} />
<Route path="/transparence" component={Transparence} />
<Route path="/flyer" component={Flyer} />
<Route component={NotFound} />
</Switch>
</main>
+23
View File
@@ -302,3 +302,26 @@
inset: -1px;
}
}
/* ─── Impression / PDF ─────────────────────────────────────── */
@media print {
/* Masquer tout sauf le flyer */
header,
nav,
.no-print {
display: none !important;
}
#flyer-print {
box-shadow: none !important;
border: none !important;
max-width: 100% !important;
width: 100% !important;
aspect-ratio: auto !important;
page-break-inside: avoid;
}
body {
background: white !important;
}
}
@@ -0,0 +1,124 @@
import React, { useState } from "react";
import { QRCodeSVG } from "qrcode.react";
import { Printer, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Link } from "wouter";
// ══════════════════════════════════════════════════════════
// URL encodée dans le QR code — modifiez cette ligne
const DEFAULT_QR_URL = "https://lavoixdupeuple.fr";
// ══════════════════════════════════════════════════════════
export default function Flyer() {
const params = new URLSearchParams(window.location.search);
const [qrUrl, setQrUrl] = useState(params.get("url") || DEFAULT_QR_URL);
const [inputValue, setInputValue] = useState(qrUrl);
const applyUrl = () => {
const trimmed = inputValue.trim();
if (trimmed) setQrUrl(trimmed);
};
return (
<div className="min-h-screen bg-muted/20 flex flex-col items-center py-8 px-4 gap-6">
{/* Barre de contrôle — masquée à l'impression */}
<div className="no-print w-full max-w-xl bg-white border border-border/50 p-4 rounded-sm space-y-3">
<p className="text-xs font-mono text-muted-foreground uppercase tracking-widest">
Destination du QR code
</p>
<div className="flex gap-2">
<Input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && applyUrl()}
placeholder="https://lavoixdupeuple.fr"
className="font-mono text-sm"
/>
<Button onClick={applyUrl} variant="outline" size="sm">
Appliquer
</Button>
</div>
<div className="flex gap-2">
<Button onClick={() => window.print()} size="sm" className="gap-2">
<Printer className="h-3.5 w-3.5" /> Imprimer / Exporter PDF
</Button>
<Link href="/">
<Button variant="ghost" size="sm"> Retour</Button>
</Link>
</div>
</div>
{/* Flyer imprimable */}
<div
id="flyer-print"
className="bg-white w-full max-w-xl border border-border/40 shadow-sm print:shadow-none print:border-0 print:max-w-none print:w-full"
style={{ aspectRatio: "1 / 1.414" /* A4 ratio */ }}
>
<div className="h-full flex flex-col items-center justify-between p-10 text-center">
{/* Tricolore décoratif */}
<div className="flex w-24 h-1.5 overflow-hidden rounded-full mb-2">
<span className="flex-1" style={{ background: "#002395" }} />
<span className="flex-1" style={{ background: "#EDEDED" }} />
<span className="flex-1" style={{ background: "#ED2939" }} />
</div>
{/* Titre */}
<div className="space-y-3">
<h1
className="font-serif text-4xl font-bold text-primary tracking-tight"
style={{ fontFamily: "'Bahnschrift', 'DIN Alternate', sans-serif" }}
>
La Voix du Peuple
</h1>
<p className="text-sm text-muted-foreground max-w-xs mx-auto leading-relaxed">
Soumettez vos propositions citoyennes.<br />
Elles sont synthétisées et transmises à vos élus.
</p>
</div>
{/* QR Code */}
<div className="flex flex-col items-center gap-4 my-4">
<div className="p-4 border border-border/40 bg-white">
<QRCodeSVG
value={qrUrl}
size={220}
bgColor="#ffffff"
fgColor="#1a1a2e"
level="H"
marginSize={1}
/>
</div>
<div className="flex items-center gap-1.5 text-xs font-mono text-muted-foreground">
<ExternalLink className="h-3 w-3" />
<span>{qrUrl}</span>
</div>
</div>
{/* Appel à l'action */}
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
Scannez le QR code ou rendez-vous sur
</p>
<p
className="text-lg font-bold text-primary tracking-wide"
style={{ fontFamily: "'Bahnschrift', 'DIN Alternate', sans-serif" }}
>
{qrUrl.replace(/^https?:\/\//, "")}
</p>
</div>
{/* Pied — fondements */}
<div className="mt-4 pt-4 border-t border-border/30 w-full">
<p className="text-[10px] text-muted-foreground/70 leading-relaxed">
Plateforme modérée selon la Déclaration universelle des droits de l'homme (ONU, 1948),
le PIDCP et la CEDH. Aucune donnée personnelle collectée.
</p>
</div>
</div>
</div>
</div>
);
}
+95 -8
View File
@@ -27,7 +27,9 @@ import {
} from "@/components/ui/accordion";
import {
Loader2, PenTool, CheckCircle2, Info, AlertCircle, TrendingUp, Users, Scale,
Share2, Printer, Copy,
} from "lucide-react";
import { useToast } from "@/hooks/use-toast";
const submitIdeaSchema = z.object({
content: z.string()
@@ -71,6 +73,7 @@ const VALEURS = [
export default function Home() {
const queryClient = useQueryClient();
const { toast } = useToast();
const [submitResult, setSubmitResult] = React.useState<{
success: boolean;
message: string;
@@ -84,6 +87,64 @@ export default function Home() {
query: { refetchInterval: 15000 },
});
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 safe = (s: string) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const html = `<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>La Voix du Peuple Synthèse</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap');
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', 'Segoe UI', sans-serif; max-width: 680px; margin: 48px auto; color: #1a1a2e; line-height: 1.75; padding: 0 24px; }
.tricolor { display: flex; height: 4px; width: 80px; margin-bottom: 32px; border-radius: 2px; overflow: hidden; }
.tricolor span:nth-child(1) { flex: 1; background: #002395; }
.tricolor span:nth-child(2) { flex: 1; background: #EDEDED; }
.tricolor span:nth-child(3) { flex: 1; background: #ED2939; }
h1 { font-size: 1.35rem; font-weight: 700; letter-spacing: -0.01em; margin-bottom: 4px; color: #1a1a2e; }
.meta { font-size: 0.72rem; color: #888; font-family: monospace; margin-bottom: 36px; }
.text { font-size: 1rem; white-space: pre-line; color: #1a1a2e; }
.footer { margin-top: 48px; padding-top: 16px; border-top: 1px solid #eee; font-size: 0.7rem; color: #bbb; font-family: monospace; display: flex; justify-content: space-between; }
@media print { body { margin: 32px auto; } }
</style>
</head>
<body>
<div class="tricolor"><span></span><span></span><span></span></div>
<h1>La Voix du Peuple Synthèse citoyenne</h1>
<div class="meta">Générée le ${safe(date)} &nbsp;·&nbsp; ${nb} contribution${nb !== 1 ? "s" : ""} intégrée${nb !== 1 ? "s" : ""}</div>
<div class="text">${safe(synthesis.text)}</div>
<div class="footer">
<span>lavoixdupeuple.fr</span>
<span>Document généré automatiquement modération selon DUDH / PIDCP / CEDH</span>
</div>
</body>
</html>`;
const w = window.open("", "_blank");
if (w) {
w.document.write(html);
w.document.close();
w.onload = () => w.print();
}
};
const form = useForm<SubmitIdeaValues>({
resolver: zodResolver(submitIdeaSchema),
defaultValues: { content: "", author: "" },
@@ -306,8 +367,8 @@ export default function Home() {
/>
{/* En-tête fixe */}
<div className="flex justify-between items-start px-6 md:px-10 py-5 border-b border-border/30 relative z-10 flex-shrink-0">
<div>
<div className="flex justify-between items-start px-6 md:px-10 py-5 border-b border-border/30 relative z-10 flex-shrink-0 gap-3">
<div className="min-w-0">
<h2 className="text-xs font-mono font-bold uppercase tracking-widest text-primary flex items-center gap-2">
<Users className="h-3.5 w-3.5" /> Synthèse des contributions
</h2>
@@ -315,12 +376,38 @@ export default function Home() {
Mise à jour à chaque nouvelle contribution
</p>
</div>
{stats && (
<div className="text-xs font-mono text-right" data-testid="text-stats">
<span className="text-muted-foreground">Contributions intégrées </span>
<span className="font-bold text-primary">{stats.accepted}</span>
</div>
)}
<div className="flex items-center gap-2 flex-shrink-0">
{stats && (
<div className="text-xs font-mono text-right mr-2" data-testid="text-stats">
<span className="text-muted-foreground">Intégrées </span>
<span className="font-bold text-primary">{stats.accepted}</span>
</div>
)}
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1.5 text-muted-foreground hover:text-foreground"
onClick={handleShare}
disabled={!synthesis?.text}
title="Partager ou copier la synthèse"
>
{navigator.share ? (
<><Share2 className="h-3.5 w-3.5" /> Partager</>
) : (
<><Copy className="h-3.5 w-3.5" /> Copier</>
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-xs gap-1.5 text-muted-foreground hover:text-foreground"
onClick={handlePrint}
disabled={!synthesis?.text}
title="Exporter en PDF / imprimer"
>
<Printer className="h-3.5 w-3.5" /> PDF
</Button>
</div>
</div>
{/* Texte défilable */}