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:
@@ -73,5 +73,8 @@
|
|||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"wouter": "^3.3.5",
|
"wouter": "^3.3.5",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"qrcode.react": "^4.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 133 KiB |
@@ -6,6 +6,7 @@ import NotFound from "@/pages/not-found";
|
|||||||
import Home from "@/pages/home";
|
import Home from "@/pages/home";
|
||||||
import About from "@/pages/about";
|
import About from "@/pages/about";
|
||||||
import Transparence from "@/pages/transparence";
|
import Transparence from "@/pages/transparence";
|
||||||
|
import Flyer from "@/pages/flyer";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
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="/" 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="/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="/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>
|
</nav>
|
||||||
<div className="ml-auto flex items-center">
|
<div className="ml-auto flex items-center">
|
||||||
<span
|
<span
|
||||||
@@ -54,6 +56,7 @@ function Router() {
|
|||||||
<Route path="/" component={Home} />
|
<Route path="/" component={Home} />
|
||||||
<Route path="/about" component={About} />
|
<Route path="/about" component={About} />
|
||||||
<Route path="/transparence" component={Transparence} />
|
<Route path="/transparence" component={Transparence} />
|
||||||
|
<Route path="/flyer" component={Flyer} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -302,3 +302,26 @@
|
|||||||
inset: -1px;
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,7 +27,9 @@ import {
|
|||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
import {
|
import {
|
||||||
Loader2, PenTool, CheckCircle2, Info, AlertCircle, TrendingUp, Users, Scale,
|
Loader2, PenTool, CheckCircle2, Info, AlertCircle, TrendingUp, Users, Scale,
|
||||||
|
Share2, Printer, Copy,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
const submitIdeaSchema = z.object({
|
const submitIdeaSchema = z.object({
|
||||||
content: z.string()
|
content: z.string()
|
||||||
@@ -71,6 +73,7 @@ const VALEURS = [
|
|||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
const [submitResult, setSubmitResult] = React.useState<{
|
const [submitResult, setSubmitResult] = React.useState<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -84,6 +87,64 @@ export default function Home() {
|
|||||||
query: { refetchInterval: 15000 },
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
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)} · ${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>({
|
const form = useForm<SubmitIdeaValues>({
|
||||||
resolver: zodResolver(submitIdeaSchema),
|
resolver: zodResolver(submitIdeaSchema),
|
||||||
defaultValues: { content: "", author: "" },
|
defaultValues: { content: "", author: "" },
|
||||||
@@ -306,8 +367,8 @@ export default function Home() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* En-tête fixe */}
|
{/* 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 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>
|
<div className="min-w-0">
|
||||||
<h2 className="text-xs font-mono font-bold uppercase tracking-widest text-primary flex items-center gap-2">
|
<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
|
<Users className="h-3.5 w-3.5" /> Synthèse des contributions
|
||||||
</h2>
|
</h2>
|
||||||
@@ -315,12 +376,38 @@ export default function Home() {
|
|||||||
Mise à jour à chaque nouvelle contribution
|
Mise à jour à chaque nouvelle contribution
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="text-xs font-mono text-right" data-testid="text-stats">
|
<div className="text-xs font-mono text-right mr-2" data-testid="text-stats">
|
||||||
<span className="text-muted-foreground">Contributions intégrées </span>
|
<span className="text-muted-foreground">Intégrées </span>
|
||||||
<span className="font-bold text-primary">{stats.accepted}</span>
|
<span className="font-bold text-primary">{stats.accepted}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Texte défilable */}
|
{/* Texte défilable */}
|
||||||
|
|||||||
Generated
+13
@@ -403,6 +403,10 @@ importers:
|
|||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
|
|
||||||
artifacts/voix-du-peuple:
|
artifacts/voix-du-peuple:
|
||||||
|
dependencies:
|
||||||
|
qrcode.react:
|
||||||
|
specifier: ^4.2.0
|
||||||
|
version: 4.2.0(react@19.1.0)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@hookform/resolvers':
|
'@hookform/resolvers':
|
||||||
specifier: ^3.10.0
|
specifier: ^3.10.0
|
||||||
@@ -2701,6 +2705,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
|
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
qrcode.react@4.2.0:
|
||||||
|
resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
qs@6.15.0:
|
qs@6.15.0:
|
||||||
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
|
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
@@ -5292,6 +5301,10 @@ snapshots:
|
|||||||
|
|
||||||
punycode.js@2.3.1: {}
|
punycode.js@2.3.1: {}
|
||||||
|
|
||||||
|
qrcode.react@4.2.0(react@19.1.0):
|
||||||
|
dependencies:
|
||||||
|
react: 19.1.0
|
||||||
|
|
||||||
qs@6.15.0:
|
qs@6.15.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
side-channel: 1.1.0
|
side-channel: 1.1.0
|
||||||
|
|||||||
Reference in New Issue
Block a user