SPB Git

spb/phi-conseil

Public

feat: gestion des témoins (Loi 25) — bannière accessible, choix par catégorie, Gérer les témoins, section confidentialité, tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Nov 8, 2026) parent 18a9e13 Browse files

21 changed files +582 −45

modified README.md +1 −0
@@ -43,6 +43,7 @@ references/ maquette client
43 43
44 - **Fidélité** : couleurs échantillonnées sur la maquette, structure DOM = ordre visuel sur tous les écrans, motifs (spirale de Fibonacci, vagues, feuillage) en SVG. 44 - **Fidélité** : couleurs échantillonnées sur la maquette, structure DOM = ordre visuel sur tous les écrans, motifs (spirale de Fibonacci, vagues, feuillage) en SVG.
45 - **Honnêteté du contenu** : aucune coordonnée, témoignage ou réalisation inventés. Les témoignages ne sont publiés que s'ils sont `approved: true` et la section `visible: true` ; les photos de la maquette sont remplacées par des illustrations vectorielles tant que le client n'a pas fourni d'images sous licence (`docs/ASSET_REGISTER.md`). 45 - **Honnêteté du contenu** : aucune coordonnée, témoignage ou réalisation inventés. Les témoignages ne sont publiés que s'ils sont `approved: true` et la section `visible: true` ; les photos de la maquette sont remplacées par des illustrations vectorielles tant que le client n'a pas fourni d'images sous licence (`docs/ASSET_REGISTER.md`).
46+- **Vie privée** : bannière de témoins conforme à la Loi 25 (refus par défaut, choix par catégorie, révocable via « Gérer les témoins », témoin `phi_consent` 180 jours). Aucun traceur n’est installé ; tout futur outil d’analyse doit être conditionné à `hasConsent("analytics")` (`src/lib/consent.ts`).
46 - **Sécurité** : CSP, en-têtes durcis, validation Zod client + serveur, honeypot, délai anti-robot, limitation de débit, dédoublonnage, journalisation sans donnée personnelle, secrets uniquement côté serveur. 47 - **Sécurité** : CSP, en-têtes durcis, validation Zod client + serveur, honeypot, délai anti-robot, limitation de débit, dédoublonnage, journalisation sans donnée personnelle, secrets uniquement côté serveur.
47 - **Accessibilité** : WCAG 2.2 AA visé (axe sans violation sur toutes les pages), navigation clavier, `prefers-reduced-motion`, lien d'évitement, états focus visibles. 48 - **Accessibilité** : WCAG 2.2 AA visé (axe sans violation sur toutes les pages), navigation clavier, `prefers-reduced-motion`, lien d'évitement, états focus visibles.
48 49
modified content/site.json +61 −8
@@ -6,13 +6,31 @@
6 "legalName": "Phi Conseil" 6 "legalName": "Phi Conseil"
7 }, 7 },
8 "navigation": [ 8 "navigation": [
9− { "label": "Accueil", "href": "/" }, 9+ {
10− { "label": "Nos services", "href": "/services" }, 10+ "label": "Accueil",
11− { "label": "À propos", "href": "/a-propos" }, 11+ "href": "/"
12− { "label": "Témoignages", "href": "/temoignages" }, 12+ },
13− { "label": "Nous joindre", "href": "/nous-joindre" } 13+ {
14+ "label": "Nos services",
15+ "href": "/services"
16+ },
17+ {
18+ "label": "À propos",
19+ "href": "/a-propos"
20+ },
21+ {
22+ "label": "Témoignages",
23+ "href": "/temoignages"
24+ },
25+ {
26+ "label": "Nous joindre",
27+ "href": "/nous-joindre"
28+ }
14 ], 29 ],
15− "headerCta": { "label": "Contactez-nous", "href": "/nous-joindre" }, 30+ "headerCta": {
31+ "label": "Contactez-nous",
32+ "href": "/nous-joindre"
33+ },
16 "contact": { 34 "contact": {
17 "email": "", 35 "email": "",
18 "phone": "", 36 "phone": "",
@@ -23,8 +41,14 @@
23 "linkedin": "" 41 "linkedin": ""
24 }, 42 },
25 "legalLinks": [ 43 "legalLinks": [
26− { "label": "Confidentialité", "href": "/confidentialite" }, 44+ {
27− { "label": "Mentions légales", "href": "/confidentialite#mentions-legales" } 45+ "label": "Confidentialité",
46+ "href": "/confidentialite"
47+ },
48+ {
49+ "label": "Mentions légales",
50+ "href": "/confidentialite#mentions-legales"
51+ }
28 ], 52 ],
29 "seo": { 53 "seo": {
30 "siteUrl": "https://www.example.invalid", 54 "siteUrl": "https://www.example.invalid",
@@ -32,5 +56,34 @@
32 "titleTemplate": "%s | PHI Conseil", 56 "titleTemplate": "%s | PHI Conseil",
33 "defaultDescription": "Votre partenaire en recherche de subventions, en rédaction de demandes et en reddition de comptes.", 57 "defaultDescription": "Votre partenaire en recherche de subventions, en rédaction de demandes et en reddition de comptes.",
34 "locale": "fr_CA" 58 "locale": "fr_CA"
59+ },
60+ "cookies": {
61+ "title": "Témoins (cookies) et vie privée",
62+ "description": "Ce site n’utilise que des témoins strictement nécessaires à son fonctionnement (par exemple pour mémoriser vos préférences de témoins). Aucun témoin d’analyse ou de marketing n’est activé sans votre accord. Vous pouvez modifier votre choix à tout moment via « Gérer les témoins » au bas de la page.",
63+ "acceptAll": "Tout accepter",
64+ "rejectAll": "Tout refuser",
65+ "customize": "Personnaliser",
66+ "save": "Enregistrer mes choix",
67+ "back": "Retour",
68+ "manageLabel": "Gérer les témoins",
69+ "policyLabel": "Politique de confidentialité",
70+ "policyHref": "/confidentialite#temoins",
71+ "categories": [
72+ {
73+ "id": "necessary",
74+ "label": "Strictement nécessaires",
75+ "description": "Indispensables au fonctionnement du site et à la mémorisation de vos préférences de témoins. Ils ne contiennent aucune donnée personnelle."
76+ },
77+ {
78+ "id": "analytics",
79+ "label": "Mesure d’audience",
80+ "description": "Statistiques anonymisées de fréquentation pour améliorer le site. Aucun outil n’est installé pour le moment ; ce choix s’appliquera si un outil est ajouté."
81+ },
82+ {
83+ "id": "marketing",
84+ "label": "Marketing",
85+ "description": "Témoins de suivi publicitaire. Non utilisés par ce site ; ce choix s’appliquera si un outil est ajouté."
86+ }
87+ ]
35 } 88 }
36 } 89 }
modified docs/CLIENT_GUIDE.md +10 −9
@@ -6,15 +6,16 @@ Tous les textes, services, avantages, témoignages, coordonnées et paramètres
6 6
7 ## `content/site.json` — identité et coordonnées 7 ## `content/site.json` — identité et coordonnées
8 8
9−| Champ | Usage | 9+| Champ | Usage |
10−| -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | 10+| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
11−| `brand.name`, `brand.tagline`, `brand.footerSignature`, `brand.legalName` | nom, signature de l'en-tête, phrase du pied de page, raison sociale du copyright | 11+| `brand.name`, `brand.tagline`, `brand.footerSignature`, `brand.legalName` | nom, signature de l'en-tête, phrase du pied de page, raison sociale du copyright |
12−| `navigation[]` | liens du menu (ordre = ordre affiché) | 12+| `navigation[]` | liens du menu (ordre = ordre affiché) |
13−| `headerCta` | bouton « Contactez-nous → » | 13+| `headerCta` | bouton « Contactez-nous → » |
14−| `contact.email`, `contact.phone`, `contact.address`, `contact.region` | coordonnées ; **laissez `""` pour masquer** l'icône courriel / la ligne correspondante | 14+| `contact.email`, `contact.phone`, `contact.address`, `contact.region` | coordonnées ; **laissez `""` pour masquer** l'icône courriel / la ligne correspondante |
15−| `social.linkedin` | URL complète du profil LinkedIn ; `""` = icône masquée | 15+| `social.linkedin` | URL complète du profil LinkedIn ; `""` = icône masquée |
16−| `legalLinks[]` | liens juridiques du pied de page | 16+| `legalLinks[]` | liens juridiques du pied de page |
17−| `seo.siteUrl`, `seo.defaultTitle`, `seo.titleTemplate`, `seo.defaultDescription` | métadonnées par défaut | 17+| `cookies.*` | textes de la bannière de témoins (titre, description, boutons, libellé « Gérer les témoins », catégories). Les identifiants `necessary` / `analytics` / `marketing` ne doivent pas changer |
18+| `seo.siteUrl`, `seo.defaultTitle`, `seo.titleTemplate`, `seo.defaultDescription` | métadonnées par défaut |
18 19
19 ## `content/home.json` — page d'accueil 20 ## `content/home.json` — page d'accueil
20 21
modified docs/IMPLEMENTATION_PLAN.md +1 −0
@@ -83,5 +83,6 @@ Compromis documentés : l'édition exige un éditeur de texte ou l'interface web
83 - [ ] Bandeau : fond bleu ardoise, titre blanc, bouton ivoire, spirale + feuillage dorés à droite. 83 - [ ] Bandeau : fond bleu ardoise, titre blanc, bouton ivoire, spirale + feuillage dorés à droite.
84 - [ ] Pied de page : logo + nom, liens, icônes LinkedIn/courriel (si fournis), copyright dynamique, signature, liens légaux. 84 - [ ] Pied de page : logo + nom, liens, icônes LinkedIn/courriel (si fournis), copyright dynamique, signature, liens légaux.
85 - [ ] Responsive 360/390/768/1024/1440/1920 sans débordement horizontal. 85 - [ ] Responsive 360/390/768/1024/1440/1920 sans débordement horizontal.
86+- [x] Témoins : bannière accessible, 3 catégories, choix mémorisé, réouverture, section dans la politique de confidentialité (ajouté 2026-11-08).
86 - [ ] Formulaire : validation client/serveur, honeypot, rate limit, SMTP configurable, états succès/erreur, tests. 87 - [ ] Formulaire : validation client/serveur, honeypot, rate limit, SMTP configurable, états succès/erreur, tests.
87 - [ ] Lint, typecheck, tests, build, axe : verts ou écarts documentés dans `docs/QA_REPORT.md`. 88 - [ ] Lint, typecheck, tests, build, axe : verts ou écarts documentés dans `docs/QA_REPORT.md`.
modified docs/QA_REPORT.md +24 −23
@@ -4,34 +4,35 @@ Date : 2026-11-08. Environnement : macOS, Node 26.13, pnpm 11.1, Next.js 16.4.5,
4 4
5 ## Résultats 5 ## Résultats
6 6
7−| Vérification | Commande | Résultat | 7+| Vérification | Commande | Résultat |
8−|---|---|---| 8+| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
9−| Lint | `pnpm lint` | ✅ 0 erreur, 0 avertissement | 9+| Lint | `pnpm lint` | ✅ 0 erreur, 0 avertissement |
10−| Types | `pnpm typecheck` | ✅ | 10+| Types | `pnpm typecheck` | ✅ |
11−| Tests unitaires | `pnpm test` | ✅ 28/28 (contenu/Zod, validation, limitation de débit, dédoublonnage, CSP, route `/api/contact` incl. honeypot et délai anti-robot, composants, géométrie de la spirale) | 11+| Tests unitaires | `pnpm test` | ✅ 32/32 (consentement témoins, contenu/Zod, validation, limitation de débit, dédoublonnage, CSP, route `/api/contact` incl. honeypot et délai anti-robot, composants, géométrie de la spirale) |
12−| Build de production | `pnpm build` | ✅ 10 routes statiques prérendues + `/api/contact` dynamique | 12+| Build de production | `pnpm build` | ✅ 10 routes statiques prérendues + `/api/contact` dynamique |
13−| Tests de bout en bout | `pnpm test:e2e` | ✅ 40 réussis, 8 ignorés volontairement (tests réservés au projet desktop ou mobile), 0 échec — projets Chromium 1440 × 900 et WebKit iPhone 13 | 13+| Tests de bout en bout | `pnpm test:e2e` | ✅ 48 réussis, 8 ignorés volontairement (tests réservés au projet desktop ou mobile), 0 échec — projets Chromium 1440 × 900 et WebKit iPhone 13 |
14−| Accessibilité automatisée | axe-core (WCAG 2.0/2.1/2.2 A+AA) sur les 6 pages, desktop + mobile | ✅ 0 violation | 14+| Accessibilité automatisée | axe-core (WCAG 2.0/2.1/2.2 A+AA) sur les 6 pages, desktop + mobile | ✅ 0 violation |
15−| Débordement horizontal | 360, 390, 768, 1024, 1440, 1920 px sur `/`, `/nous-joindre`, `/services` | ✅ aucun | 15+| Débordement horizontal | 360, 390, 768, 1024, 1440, 1920 px sur `/`, `/nous-joindre`, `/services` | ✅ aucun |
16−| Lighthouse mobile (accueil) | lighthouse 12, émulation mobile, Chromium headless local | ✅ Performance 100 · Accessibilité 100 · Bonnes pratiques 100 · SEO 100 — LCP 0,9 s, CLS 0, TBT 20 ms | 16+| Lighthouse mobile (accueil) | lighthouse 12, émulation mobile, Chromium headless local | ✅ Performance 100 · Accessibilité 100 · Bonnes pratiques 100 · SEO 100 — LCP 0,9 s, CLS 0, TBT 20 ms |
17−| En-têtes de sécurité | test e2e `en-têtes de sécurité présents` | ✅ CSP (`frame-ancestors 'none'`, `object-src 'none'`), `nosniff`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`, HSTS ; `X-Powered-By` retiré | 17+| En-têtes de sécurité | test e2e `en-têtes de sécurité présents` | ✅ CSP (`frame-ancestors 'none'`, `object-src 'none'`), `nosniff`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`, HSTS ; `X-Powered-By` retiré |
18−| Formulaire | e2e : erreurs associées aux champs (`aria-invalid`, `aria-describedby`), soumission valide → confirmation ; API : 400 sur payload invalide | ✅ | 18+| Formulaire | e2e : erreurs associées aux champs (`aria-invalid`, `aria-describedby`), soumission valide → confirmation ; API : 400 sur payload invalide | ✅ |
19−| Menu mobile | e2e WebKit : ouverture, focus sur « Fermer », Échap, retour du focus, navigation | ✅ | 19+| Menu mobile | e2e WebKit : ouverture, focus sur « Fermer », Échap, retour du focus, navigation | ✅ |
20−| Liens | e2e : aucun lien interne mort sur l'accueil, 6 pages en 200, 404 personnalisée, sitemap/robots/manifest/icônes servis | ✅ | 20+| Témoins (cookies) | e2e : bannière sans choix préalable, axe sur la bannière, « Tout refuser » / « Tout accepter » / « Personnaliser » (nécessaires verrouillés), réouverture depuis le pied de page, persistance après rechargement — Chromium + WebKit | ✅ 8/8 |
21+| Liens | e2e : aucun lien interne mort sur l'accueil, 6 pages en 200, 404 personnalisée, sitemap/robots/manifest/icônes servis | ✅ |
21 22
22 ## Comparaison visuelle avec la maquette 23 ## Comparaison visuelle avec la maquette
23 24
24 Captures dans `docs/screenshots/` (`home-1024.png` à la largeur de la maquette, `home-1440.png`, `home-mobile-390.png`, pages internes). Comparaison section par section à 1024 px : 25 Captures dans `docs/screenshots/` (`home-1024.png` à la largeur de la maquette, `home-1440.png`, `home-mobile-390.png`, pages internes). Comparaison section par section à 1024 px :
25 26
26−| Section | Fidélité | Écarts connus | 27+| Section | Fidélité | Écarts connus |
27−|---|---|---| 28+| ------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
28−| En-tête | ✅ logo + nom + signature, 5 liens, CTA bleu arrondi, lien actif souligné | signature masquée sous 640 px (place) | 29+| En-tête | ✅ logo + nom + signature, 5 liens, CTA bleu arrondi, lien actif souligné | signature masquée sous 640 px (place) |
29−| Hero | ✅ fond ivoire, titre serif 3 lignes, sous-titre, bouton, spirale dorée superposée, vagues ivoire/bleu pâle | **photo remplacée par une illustration vectorielle** (droits de la photo de maquette inconnus) ; titre un peu plus grand que la maquette à 1024 px | 30+| Hero | ✅ fond ivoire, titre serif 3 lignes, sous-titre, bouton, spirale dorée superposée, vagues ivoire/bleu pâle | **photo remplacée par une illustration vectorielle** (droits de la photo de maquette inconnus) ; titre un peu plus grand que la maquette à 1024 px |
30−| Nos services | ✅ titre entre filets dorés, 4 disques bleu-gris + icônes au trait, textes exacts | icônes redessinées (proches, non identiques) | 31+| Nos services | ✅ titre entre filets dorés, 4 disques bleu-gris + icônes au trait, textes exacts | icônes redessinées (proches, non identiques) |
31−| À propos | ✅ image gauche + courbe ivoire, surtitre doré, titre, paragraphe, bouton contour, 4 coches dorées avec filet vertical | **photo remplacée** (idem) ; surtitre doré assombri (#7c5e34 au lieu de ~#bea177) pour atteindre le contraste AA 4,5:1 | 32+| À propos | ✅ image gauche + courbe ivoire, surtitre doré, titre, paragraphe, bouton contour, 4 coches dorées avec filet vertical | **photo remplacée** (idem) ; surtitre doré assombri (#7c5e34 au lieu de ~#bea177) pour atteindre le contraste AA 4,5:1 |
32−| Témoignages | ✅ encadré bleu pâle arrondi, guillemets dorés, serif italique, pagination, pause, clavier, `prefers-reduced-motion` | **masqués en production** (`visible=false`, aucun avis autorisé) ; visibles sur le site de démonstration avec bandeau « exemple non vérifié » (`NEXT_PUBLIC_CONTENT_PREVIEW=1`) | 33+| Témoignages | ✅ encadré bleu pâle arrondi, guillemets dorés, serif italique, pagination, pause, clavier, `prefers-reduced-motion` | **masqués en production** (`visible=false`, aucun avis autorisé) ; visibles sur le site de démonstration avec bandeau « exemple non vérifié » (`NEXT_PUBLIC_CONTENT_PREVIEW=1`) |
33−| Bandeau final | ✅ fond bleu ardoise, titre blanc, bouton ivoire, spirale + feuillage dorés | sous-titre en blanc pur (maquette : blanc légèrement transparent) pour le contraste | 34+| Bandeau final | ✅ fond bleu ardoise, titre blanc, bouton ivoire, spirale + feuillage dorés | sous-titre en blanc pur (maquette : blanc légèrement transparent) pour le contraste |
34−| Pied de page | ✅ logo + nom, liens séparés par filets, icône courriel, copyright dynamique, signature, liens légaux | icône LinkedIn masquée tant que `social.linkedin` est vide | 35+| Pied de page | ✅ logo + nom, liens séparés par filets, icône courriel, copyright dynamique, signature, liens légaux | icône LinkedIn masquée tant que `social.linkedin` est vide |
35 36
36 Le rendu n'a pas été qualifié de « pixel-perfect » : polices (EB Garamond / Figtree) et illustrations diffèrent nécessairement des actifs de la maquette. 37 Le rendu n'a pas été qualifié de « pixel-perfect » : polices (EB Garamond / Figtree) et illustrations diffèrent nécessairement des actifs de la maquette.
37 38
added scripts/dbg-cookies.mjs +9 −0
@@ -0,0 +1,9 @@
1+import { chromium, webkit, devices } from "@playwright/test";
2+const base = process.argv[2] ?? "http://127.0.0.1:3100";
3+let b = await webkit.launch(); let ctx = await b.newContext({ ...devices["iPhone 13"] }); let p = await ctx.newPage();
4+await p.goto(base + "/", { waitUntil: "networkidle" }); await p.waitForTimeout(400);
5+await p.screenshot({ path: "docs/screenshots/cookies-banner-mobile.png" }); await b.close();
6+b = await chromium.launch(); p = await b.newPage({ viewport: { width: 1440, height: 900 } });
7+await p.goto(base + "/", { waitUntil: "networkidle" });
8+await p.getByRole("button", { name: "Personnaliser" }).click(); await p.waitForTimeout(400);
9+await p.screenshot({ path: "docs/screenshots/cookies-prefs-desktop.png" }); await b.close(); console.log("ok");
modified src/app/(site)/confidentialite/page.tsx +15 −0
@@ -78,6 +78,21 @@ export default function PrivacyPage() {
78 transmises de façon sécurisée au fournisseur de messagerie configuré par{" "} 78 transmises de façon sécurisée au fournisseur de messagerie configuré par{" "}
79 {site.brand.legalName}. 79 {site.brand.legalName}.
80 </p> 80 </p>
81+ <h2 id="temoins" className="text-h4 mt-10 scroll-mt-28">
82+ 6. Témoins (cookies)
83+ </h2>
84+ <p className="mt-3">
85+ Le site n’utilise que des témoins strictement nécessaires. Le seul témoin déposé est{" "}
86+ <code className="bg-pale-200/60 rounded px-1 text-sm">phi_consent</code>, qui mémorise
87+ vos préférences de témoins pendant 180 jours et ne contient aucune donnée personnelle.
88+ Aucun témoin de mesure d’audience ou de marketing n’est activé sans votre consentement
89+ explicite ; si de tels outils étaient ajoutés, ils ne se chargeraient qu’après votre
90+ accord dans la catégorie correspondante.
91+ </p>
92+ <p className="mt-3">
93+ Vous pouvez modifier ou retirer votre consentement à tout moment via le lien « Gérer les
94+ témoins » au bas de chaque page, ou en supprimant les témoins dans votre navigateur.
95+ </p>
81 <h2 id="mentions-legales" className="text-h4 mt-12 scroll-mt-28"> 96 <h2 id="mentions-legales" className="text-h4 mt-12 scroll-mt-28">
82 Mentions légales 97 Mentions légales
83 </h2> 98 </h2>
modified src/app/layout.tsx +2 −0
@@ -4,6 +4,7 @@ import { site } from "@/lib/content";
4 import { siteUrl } from "@/lib/env"; 4 import { siteUrl } from "@/lib/env";
5 import { Header } from "@/components/layout/Header"; 5 import { Header } from "@/components/layout/Header";
6 import { Footer } from "@/components/layout/Footer"; 6 import { Footer } from "@/components/layout/Footer";
7+import { CookieConsent } from "@/components/consent/CookieConsent";
7 import "./globals.css"; 8 import "./globals.css";
8 9
9 const garamond = EB_Garamond({ 10 const garamond = EB_Garamond({
@@ -55,6 +56,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
55 {children} 56 {children}
56 </main> 57 </main>
57 <Footer /> 58 <Footer />
59+ <CookieConsent texts={site.cookies} />
58 </body> 60 </body>
59 </html> 61 </html>
60 ); 62 );
added src/components/consent/CookieConsent.tsx +196 −0
@@ -0,0 +1,196 @@
1+"use client";
2+
3+import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
4+import { createPortal } from "react-dom";
5+import Link from "next/link";
6+import { Button } from "@/components/ui/Button";
7+import {
8+ allAccepted,
9+ allRejected,
10+ CONSENT_OPEN_EVENT,
11+ CONSENT_VERSION,
12+ type Consent,
13+ type OptionalCategory,
14+} from "@/lib/consent";
15+import { getServerSnapshot, getSnapshot, subscribe, writeConsent } from "./consent-store";
16+
17+export type CookieTexts = {
18+ title: string;
19+ description: string;
20+ acceptAll: string;
21+ rejectAll: string;
22+ customize: string;
23+ save: string;
24+ back: string;
25+ policyLabel: string;
26+ policyHref: string;
27+ categories: { id: "necessary" | OptionalCategory; label: string; description: string }[];
28+};
29+
30+export function CookieConsent({ texts }: { texts: CookieTexts }) {
31+ const consent = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
32+ const [prefsOpen, setPrefsOpen] = useState(false);
33+ const [forcedOpen, setForcedOpen] = useState(false);
34+ const [draft, setDraft] = useState<Record<OptionalCategory, boolean>>({
35+ analytics: false,
36+ marketing: false,
37+ });
38+ const headingId = useId();
39+ const descId = useId();
40+ const panelRef = useRef<HTMLDivElement>(null);
41+
42+ // Réouverture depuis le pied de page (« Gérer les témoins »).
43+ useEffect(() => {
44+ const onOpen = () => {
45+ const current = getSnapshot();
46+ if (current) setDraft({ analytics: current.analytics, marketing: current.marketing });
47+ setForcedOpen(true);
48+ setPrefsOpen(true);
49+ };
50+ window.addEventListener(CONSENT_OPEN_EVENT, onOpen);
51+ return () => window.removeEventListener(CONSENT_OPEN_EVENT, onOpen);
52+ }, []);
53+
54+ useEffect(() => {
55+ if (prefsOpen) panelRef.current?.focus();
56+ }, [prefsOpen]);
57+
58+ if (consent === null) return null; // rendu serveur / hydratation : rien
59+ const visible = consent === undefined || forcedOpen;
60+ if (!visible) return null;
61+
62+ const openPrefs = () => {
63+ if (consent) setDraft({ analytics: consent.analytics, marketing: consent.marketing });
64+ setPrefsOpen(true);
65+ };
66+ const decide = (c: Consent) => {
67+ writeConsent(c);
68+ setPrefsOpen(false);
69+ setForcedOpen(false);
70+ };
71+ const saveDraft = () =>
72+ decide({ version: CONSENT_VERSION, date: new Date().toISOString(), necessary: true, ...draft });
73+
74+ const panel = (
75+ <div
76+ role="dialog"
77+ aria-modal={prefsOpen ? "true" : undefined}
78+ aria-labelledby={headingId}
79+ aria-describedby={descId}
80+ className={`fixed inset-x-0 bottom-0 z-[60] p-3 sm:p-5 ${prefsOpen ? "bg-navy-900/40 top-0 flex items-end sm:items-center sm:justify-center" : ""}`}
81+ onKeyDown={(e) => {
82+ if (e.key === "Escape" && prefsOpen) {
83+ if (consent) {
84+ setPrefsOpen(false);
85+ setForcedOpen(false);
86+ } else setPrefsOpen(false);
87+ }
88+ }}
89+ >
90+ <div
91+ ref={panelRef}
92+ tabIndex={-1}
93+ className="border-pale-300/70 bg-ivory-50 mx-auto w-full max-w-[44rem] rounded-[var(--radius-lg)] border p-5 shadow-lg outline-none sm:p-7"
94+ >
95+ <h2 id={headingId} className="text-h4">
96+ {texts.title}
97+ </h2>
98+ <p id={descId} className="text-ink-700 mt-2 text-sm">
99+ {texts.description}{" "}
100+ <Link
101+ href={texts.policyHref}
102+ className="hover:text-navy-900 underline underline-offset-4"
103+ >
104+ {texts.policyLabel}
105+ </Link>
106+ </p>
107+
108+ {prefsOpen ? (
109+ <ul className="mt-5 flex flex-col gap-3" aria-label="Catégories de témoins">
110+ {texts.categories.map((cat) => {
111+ const required = cat.id === "necessary";
112+ const checked = required ? true : draft[cat.id as OptionalCategory];
113+ const inputId = `${headingId}-${cat.id}`;
114+ return (
115+ <li key={cat.id} className="flex items-start gap-3 rounded-md bg-white/60 p-3">
116+ <input
117+ id={inputId}
118+ type="checkbox"
119+ className="mt-1 h-4 w-4 accent-slate-500"
120+ checked={checked}
121+ disabled={required}
122+ aria-describedby={`${inputId}-desc`}
123+ onChange={(e) =>
124+ !required && setDraft((d) => ({ ...d, [cat.id]: e.target.checked }))
125+ }
126+ />
127+ <div>
128+ <label htmlFor={inputId} className="text-ink-900 block text-sm font-medium">
129+ {cat.label}
130+ {required ? (
131+ <span className="text-ink-500 ml-2 text-xs font-normal">
132+ (toujours actifs)
133+ </span>
134+ ) : null}
135+ </label>
136+ <p id={`${inputId}-desc`} className="text-ink-700 mt-0.5 text-xs">
137+ {cat.description}
138+ </p>
139+ </div>
140+ </li>
141+ );
142+ })}
143+ </ul>
144+ ) : null}
145+
146+ <div className="mt-5 flex flex-col-reverse gap-2 sm:flex-row sm:flex-wrap sm:justify-end">
147+ {prefsOpen ? (
148+ <>
149+ <Button variant="ghost" arrow={false} onClick={() => setPrefsOpen(false)}>
150+ {texts.back}
151+ </Button>
152+ <Button variant="outline" arrow={false} onClick={() => decide(allRejected())}>
153+ {texts.rejectAll}
154+ </Button>
155+ <Button arrow={false} onClick={saveDraft}>
156+ {texts.save}
157+ </Button>
158+ </>
159+ ) : (
160+ <>
161+ <Button variant="ghost" arrow={false} onClick={openPrefs}>
162+ {texts.customize}
163+ </Button>
164+ <Button variant="outline" arrow={false} onClick={() => decide(allRejected())}>
165+ {texts.rejectAll}
166+ </Button>
167+ <Button arrow={false} onClick={() => decide(allAccepted())}>
168+ {texts.acceptAll}
169+ </Button>
170+ </>
171+ )}
172+ </div>
173+ </div>
174+ </div>
175+ );
176+
177+ return createPortal(panel, document.body);
178+}
179+
180+export function ManageCookiesButton({
181+ label,
182+ className = "",
183+}: {
184+ label: string;
185+ className?: string;
186+}) {
187+ return (
188+ <button
189+ type="button"
190+ className={className}
191+ onClick={() => window.dispatchEvent(new Event(CONSENT_OPEN_EVENT))}
192+ >
193+ {label}
194+ </button>
195+ );
196+}
added src/components/consent/consent-store.ts +44 −0
@@ -0,0 +1,44 @@
1+"use client";
2+
3+import {
4+ CONSENT_COOKIE,
5+ CONSENT_EVENT,
6+ CONSENT_MAX_AGE_S,
7+ CONSENT_OPEN_EVENT,
8+ readConsentCookie,
9+ serializeConsent,
10+ type Consent,
11+} from "@/lib/consent";
12+
13+/** Magasin minimal basé sur document.cookie + événements, pour useSyncExternalStore. */
14+export function subscribe(cb: () => void) {
15+ window.addEventListener(CONSENT_EVENT, cb);
16+ return () => window.removeEventListener(CONSENT_EVENT, cb);
17+}
18+
19+let lastRaw: string | null = null;
20+let lastParsed: Consent | null | undefined;
21+
22+/** Retourne un snapshot stable : `undefined` = pas de choix enregistré. */
23+export function getSnapshot(): Consent | undefined {
24+ const raw = document.cookie;
25+ if (raw !== lastRaw || lastParsed === undefined) {
26+ lastRaw = raw;
27+ lastParsed = readConsentCookie(raw);
28+ }
29+ return lastParsed ?? undefined;
30+}
31+
32+export function getServerSnapshot(): Consent | undefined | null {
33+ return null; // null = inconnu côté serveur (rien n'est rendu)
34+}
35+
36+export function writeConsent(c: Consent) {
37+ const secure = window.location.protocol === "https:" ? "; Secure" : "";
38+ document.cookie = `${CONSENT_COOKIE}=${serializeConsent(c)}; Max-Age=${CONSENT_MAX_AGE_S}; Path=/; SameSite=Lax${secure}`;
39+ window.dispatchEvent(new Event(CONSENT_EVENT));
40+}
41+
42+export function openPreferences() {
43+ window.dispatchEvent(new Event(CONSENT_OPEN_EVENT));
44+}
modified src/components/layout/Footer.tsx +5 −0
@@ -3,6 +3,7 @@ import { site } from "@/lib/content";
3 import { LogoMark } from "@/components/graphics/Logo"; 3 import { LogoMark } from "@/components/graphics/Logo";
4 import { LinkedInIcon, MailIcon } from "@/components/ui/Icons"; 4 import { LinkedInIcon, MailIcon } from "@/components/ui/Icons";
5 import { currentYear } from "@/lib/time"; 5 import { currentYear } from "@/lib/time";
6+import { ManageCookiesButton } from "@/components/consent/CookieConsent";
6 7
7 export async function Footer() { 8 export async function Footer() {
8 const year = await currentYear(); 9 const year = await currentYear();
@@ -91,6 +92,10 @@ export async function Footer() {
91 {l.label} 92 {l.label}
92 </Link> 93 </Link>
93 ))} 94 ))}
95+ <ManageCookiesButton
96+ label={site.cookies.manageLabel}
97+ className="hover:text-navy-900 rounded-sm underline-offset-4 hover:underline"
98+ />
94 </p> 99 </p>
95 <p className="text-ink-700 font-semibold tracking-[0.14em] uppercase"> 100 <p className="text-ink-700 font-semibold tracking-[0.14em] uppercase">
96 {site.brand.footerSignature} 101 {site.brand.footerSignature}
added src/lib/consent.ts +67 −0
@@ -0,0 +1,67 @@
1+/**
2+ * Consentement aux témoins (cookies) — Loi 25 (Québec) / RGPD :
3+ * refus par défaut pour tout ce qui n'est pas strictement nécessaire,
4+ * choix granulaire, révocable à tout moment, conservé 180 jours.
5+ *
6+ * Le site ne pose aujourd'hui AUCUN témoin non nécessaire : ce module est la
7+ * porte d'entrée obligatoire pour tout futur outil d'analyse ou de marketing
8+ * (`hasConsent("analytics")` avant de charger un script).
9+ */
10+
11+export const CONSENT_COOKIE = "phi_consent";
12+export const CONSENT_VERSION = 1;
13+export const CONSENT_MAX_AGE_S = 180 * 24 * 60 * 60;
14+export const CONSENT_EVENT = "phi-consent-change";
15+export const CONSENT_OPEN_EVENT = "phi-consent-open";
16+
17+export const OPTIONAL_CATEGORIES = ["analytics", "marketing"] as const;
18+export type OptionalCategory = (typeof OPTIONAL_CATEGORIES)[number];
19+export type Category = "necessary" | OptionalCategory;
20+
21+export type Consent = {
22+ version: number;
23+ date: string; // ISO
24+ necessary: true;
25+ analytics: boolean;
26+ marketing: boolean;
27+};
28+
29+export function allRejected(): Consent {
30+ return { version: CONSENT_VERSION, date: new Date().toISOString(), necessary: true, analytics: false, marketing: false };
31+}
32+
33+export function allAccepted(): Consent {
34+ return { version: CONSENT_VERSION, date: new Date().toISOString(), necessary: true, analytics: true, marketing: true };
35+}
36+
37+export function parseConsent(raw: string | null | undefined): Consent | null {
38+ if (!raw) return null;
39+ try {
40+ const v = JSON.parse(decodeURIComponent(raw)) as Partial<Consent>;
41+ if (v.version !== CONSENT_VERSION) return null; // nouvelle version = redemander
42+ if (typeof v.analytics !== "boolean" || typeof v.marketing !== "boolean") return null;
43+ return {
44+ version: CONSENT_VERSION,
45+ date: typeof v.date === "string" ? v.date : new Date(0).toISOString(),
46+ necessary: true,
47+ analytics: v.analytics,
48+ marketing: v.marketing,
49+ };
50+ } catch {
51+ return null;
52+ }
53+}
54+
55+export function serializeConsent(c: Consent): string {
56+ return encodeURIComponent(JSON.stringify(c));
57+}
58+
59+export function readConsentCookie(cookieHeader: string): Consent | null {
60+ const m = cookieHeader.match(new RegExp(`(?:^|;\\s*)${CONSENT_COOKIE}=([^;]*)`));
61+ return parseConsent(m?.[1]);
62+}
63+
64+export function hasConsent(consent: Consent | null, category: Category): boolean {
65+ if (category === "necessary") return true;
66+ return consent?.[category] === true;
67+}
modified src/lib/content.ts +21 −0
@@ -45,6 +45,27 @@ export const siteSchema = z.object({
45 linkedin: z.union([z.literal(""), z.url()]), 45 linkedin: z.union([z.literal(""), z.url()]),
46 }), 46 }),
47 legalLinks: z.array(link), 47 legalLinks: z.array(link),
48+ cookies: z.object({
49+ title: z.string().min(1),
50+ description: z.string().min(1),
51+ acceptAll: z.string().min(1),
52+ rejectAll: z.string().min(1),
53+ customize: z.string().min(1),
54+ save: z.string().min(1),
55+ back: z.string().min(1),
56+ manageLabel: z.string().min(1),
57+ policyLabel: z.string().min(1),
58+ policyHref: z.string().min(1),
59+ categories: z
60+ .array(
61+ z.object({
62+ id: z.enum(["necessary", "analytics", "marketing"]),
63+ label: z.string().min(1),
64+ description: z.string().min(1),
65+ }),
66+ )
67+ .min(1),
68+ }),
48 seo: z.object({ 69 seo: z.object({
49 siteUrl: z.url(), 70 siteUrl: z.url(),
50 defaultTitle: z.string().min(1), 71 defaultTitle: z.string().min(1),
modified tests/e2e/a11y.spec.ts +1 −1
@@ -1,4 +1,4 @@
1−import { test, expect } from "@playwright/test"; 1+import { test, expect } from "./fixtures";
2 import AxeBuilder from "@axe-core/playwright"; 2 import AxeBuilder from "@axe-core/playwright";
3 3
4 for (const r of [ 4 for (const r of [
modified tests/e2e/contact.spec.ts +1 −1
@@ -1,4 +1,4 @@
1−import { test, expect } from "@playwright/test"; 1+import { test, expect } from "./fixtures";
2 2
3 test.describe("formulaire de contact", () => { 3 test.describe("formulaire de contact", () => {
4 test("affiche les erreurs associées aux champs", async ({ page }) => { 4 test("affiche les erreurs associées aux champs", async ({ page }) => {
added tests/e2e/cookies.spec.ts +55 −0
@@ -0,0 +1,55 @@
1+import { test, expect } from "@playwright/test";
2+import AxeBuilder from "@axe-core/playwright";
3+
4+const readConsent = async (page: import("@playwright/test").Page) => {
5+ const c = (await page.context().cookies()).find((k) => k.name === "phi_consent");
6+ return c
7+ ? (JSON.parse(decodeURIComponent(c.value)) as { analytics: boolean; marketing: boolean })
8+ : null;
9+};
10+
11+test.describe("gestion des témoins", () => {
12+ test("bannière visible sans choix, accessible, puis « Tout refuser »", async ({ page }) => {
13+ await page.goto("/");
14+ const dialog = page.getByRole("dialog", { name: /Témoins/ });
15+ await expect(dialog).toBeVisible();
16+ const axe = await new AxeBuilder({ page }).include("[role=dialog]").analyze();
17+ expect(axe.violations.map((v) => v.id)).toEqual([]);
18+ await dialog.getByRole("button", { name: "Tout refuser" }).click();
19+ await expect(dialog).toBeHidden();
20+ expect(await readConsent(page)).toMatchObject({ analytics: false, marketing: false });
21+ await page.reload();
22+ await expect(page.getByRole("dialog", { name: /Témoins/ })).toHaveCount(0);
23+ });
24+
25+ test("« Tout accepter » enregistre les deux catégories", async ({ page }) => {
26+ await page.goto("/");
27+ await page.getByRole("button", { name: "Tout accepter" }).click();
28+ expect(await readConsent(page)).toMatchObject({ analytics: true, marketing: true });
29+ });
30+
31+ test("« Personnaliser » : choix granulaire, nécessaires verrouillés", async ({ page }) => {
32+ await page.goto("/");
33+ await page.getByRole("button", { name: "Personnaliser" }).click();
34+ const necessary = page.getByLabel(/Strictement nécessaires/);
35+ await expect(necessary).toBeChecked();
36+ await expect(necessary).toBeDisabled();
37+ await page.getByLabel(/Mesure d’audience/).check();
38+ await page.getByRole("button", { name: "Enregistrer mes choix" }).click();
39+ expect(await readConsent(page)).toMatchObject({ analytics: true, marketing: false });
40+ });
41+
42+ test("réouverture depuis le pied de page et modification du choix", async ({ page }) => {
43+ await page.goto("/");
44+ await page.getByRole("button", { name: "Tout accepter" }).click();
45+ await page.getByRole("button", { name: "Gérer les témoins" }).click();
46+ const dialog = page.getByRole("dialog", { name: /Témoins/ });
47+ await expect(dialog).toBeVisible();
48+ const analytics = page.getByLabel(/Mesure d’audience/);
49+ await expect(analytics).toBeChecked();
50+ await analytics.uncheck();
51+ await page.getByRole("button", { name: "Enregistrer mes choix" }).click();
52+ await expect(dialog).toBeHidden();
53+ expect(await readConsent(page)).toMatchObject({ analytics: false, marketing: true });
54+ });
55+});
added tests/e2e/fixtures.ts +20 −0
@@ -0,0 +1,20 @@
1+import { test as base } from "@playwright/test";
2+
3+/** Tests ordinaires : un refus déjà enregistré pour que la bannière n'interfère pas. */
4+export const test = base.extend({
5+ context: async ({ context, baseURL }, run) => {
6+ const url = new URL(baseURL!);
7+ const value = encodeURIComponent(
8+ JSON.stringify({
9+ version: 1,
10+ date: "2026-01-01T00:00:00.000Z",
11+ necessary: true,
12+ analytics: false,
13+ marketing: false,
14+ }),
15+ );
16+ await context.addCookies([{ name: "phi_consent", value, domain: url.hostname, path: "/" }]);
17+ await run(context);
18+ },
19+});
20+export { expect } from "@playwright/test";
modified tests/e2e/mobile-menu.spec.ts +1 −1
@@ -1,4 +1,4 @@
1−import { test, expect } from "@playwright/test"; 1+import { test, expect } from "./fixtures";
2 2
3 test.describe("menu mobile", () => { 3 test.describe("menu mobile", () => {
4 test("ouverture, navigation clavier, fermeture Échap", async ({ page, isMobile }) => { 4 test("ouverture, navigation clavier, fermeture Échap", async ({ page, isMobile }) => {
modified tests/e2e/navigation.spec.ts +1 −1
@@ -1,4 +1,4 @@
1−import { test, expect } from "@playwright/test"; 1+import { test, expect } from "./fixtures";
2 2
3 const routes = ["/", "/services", "/a-propos", "/temoignages", "/nous-joindre", "/confidentialite"]; 3 const routes = ["/", "/services", "/a-propos", "/temoignages", "/nous-joindre", "/confidentialite"];
4 4
modified tests/e2e/screenshots.spec.ts +1 −1
@@ -1,4 +1,4 @@
1−import { test } from "@playwright/test"; 1+import { test } from "./fixtures";
2 2
3 /** Captures de référence pour docs/QA_REPORT.md (desktop + mobile). */ 3 /** Captures de référence pour docs/QA_REPORT.md (desktop + mobile). */
4 test("captures de l'accueil et des pages", async ({ page, isMobile }) => { 4 test("captures de l'accueil et des pages", async ({ page, isMobile }) => {
added tests/unit/consent.test.ts +46 −0
@@ -0,0 +1,46 @@
1+import { describe, expect, it } from "vitest";
2+import {
3+ allAccepted,
4+ allRejected,
5+ hasConsent,
6+ parseConsent,
7+ readConsentCookie,
8+ serializeConsent,
9+ CONSENT_VERSION,
10+} from "@/lib/consent";
11+
12+describe("consentement aux témoins", () => {
13+ it("sérialise et relit un consentement", () => {
14+ const c = allAccepted();
15+ expect(parseConsent(serializeConsent(c))).toMatchObject({
16+ analytics: true,
17+ marketing: true,
18+ necessary: true,
19+ });
20+ });
21+ it("ignore une valeur corrompue ou d'une autre version", () => {
22+ expect(parseConsent("%7Bnope")).toBeNull();
23+ expect(
24+ parseConsent(
25+ encodeURIComponent(
26+ JSON.stringify({ version: CONSENT_VERSION + 1, analytics: true, marketing: true }),
27+ ),
28+ ),
29+ ).toBeNull();
30+ expect(
31+ parseConsent(
32+ encodeURIComponent(JSON.stringify({ version: CONSENT_VERSION, analytics: "oui" })),
33+ ),
34+ ).toBeNull();
35+ });
36+ it("lit le témoin dans un en-tête Cookie", () => {
37+ const header = `foo=1; phi_consent=${serializeConsent(allRejected())}; bar=2`;
38+ expect(readConsentCookie(header)).toMatchObject({ analytics: false, marketing: false });
39+ expect(readConsentCookie("foo=1")).toBeNull();
40+ });
41+ it("les témoins nécessaires sont toujours autorisés, les autres jamais sans choix", () => {
42+ expect(hasConsent(null, "necessary")).toBe(true);
43+ expect(hasConsent(null, "analytics")).toBe(false);
44+ expect(hasConsent(allAccepted(), "marketing")).toBe(true);
45+ });
46+});
47
Menu

Theme