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 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 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 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 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 6 "legalName": "Phi Conseil"
7 7 },
8 8 "navigation": [
9 − { "label": "Accueil", "href": "/" },
10 − { "label": "Nos services", "href": "/services" },
11 − { "label": "À propos", "href": "/a-propos" },
12 − { "label": "Témoignages", "href": "/temoignages" },
13 − { "label": "Nous joindre", "href": "/nous-joindre" }
9 + {
10 + "label": "Accueil",
11 + "href": "/"
12 + },
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 34 "contact": {
17 35 "email": "",
18 36 "phone": "",
@@ -23,8 +41,14 @@
23 41 "linkedin": ""
24 42 },
25 43 "legalLinks": [
26 − { "label": "Confidentialité", "href": "/confidentialite" },
27 − { "label": "Mentions légales", "href": "/confidentialite#mentions-legales" }
44 + {
45 + "label": "Confidentialité",
46 + "href": "/confidentialite"
47 + },
48 + {
49 + "label": "Mentions légales",
50 + "href": "/confidentialite#mentions-legales"
51 + }
28 52 ],
29 53 "seo": {
30 54 "siteUrl": "https://www.example.invalid",
@@ -32,5 +56,34 @@
32 56 "titleTemplate": "%s | PHI Conseil",
33 57 "defaultDescription": "Votre partenaire en recherche de subventions, en rédaction de demandes et en reddition de comptes.",
34 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 7 ## `content/site.json` — identité et coordonnées
8 8
9 −| Champ | Usage |
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 |
12 −| `navigation[]` | liens du menu (ordre = ordre affiché) |
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 |
15 −| `social.linkedin` | URL complète du profil LinkedIn ; `""` = icône masquée |
16 −| `legalLinks[]` | liens juridiques du pied de page |
17 −| `seo.siteUrl`, `seo.defaultTitle`, `seo.titleTemplate`, `seo.defaultDescription` | métadonnées par défaut |
9 +| Champ | Usage |
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 |
12 +| `navigation[]` | liens du menu (ordre = ordre affiché) |
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 |
15 +| `social.linkedin` | URL complète du profil LinkedIn ; `""` = icône masquée |
16 +| `legalLinks[]` | liens juridiques du pied de page |
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 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 83 - [ ] Bandeau : fond bleu ardoise, titre blanc, bouton ivoire, spirale + feuillage dorés à droite.
84 84 - [ ] Pied de page : logo + nom, liens, icônes LinkedIn/courriel (si fournis), copyright dynamique, signature, liens légaux.
85 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 87 - [ ] Formulaire : validation client/serveur, honeypot, rate limit, SMTP configurable, états succès/erreur, tests.
87 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 5 ## Résultats
6 6
7 −| Vérification | Commande | Résultat |
8 −|---|---|---|
9 −| Lint | `pnpm lint` | ✅ 0 erreur, 0 avertissement |
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) |
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 |
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 |
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é |
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 | ✅ |
20 −| Liens | e2e : aucun lien interne mort sur l'accueil, 6 pages en 200, 404 personnalisée, sitemap/robots/manifest/icônes servis | ✅ |
7 +| Vérification | Commande | Résultat |
8 +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
9 +| Lint | `pnpm lint` | ✅ 0 erreur, 0 avertissement |
10 +| Types | `pnpm typecheck` | ✅ |
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 |
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 |
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 |
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 | ✅ |
19 +| Menu mobile | e2e WebKit : ouverture, focus sur « Fermer », Échap, retour du focus, navigation | ✅ |
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 23 ## Comparaison visuelle avec la maquette
23 24
24 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 −|---|---|---|
28 −| 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 −| 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 −| 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 −| 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 |
27 +| Section | Fidélité | Écarts connus |
28 +| ------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
29 +| En-tête | ✅ logo + nom + signature, 5 liens, CTA bleu arrondi, lien actif souligné | signature masquée sous 640 px (place) |
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 |
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) |
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 |
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`) |
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 |
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 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 78 transmises de façon sécurisée au fournisseur de messagerie configuré par{" "}
79 79 {site.brand.legalName}.
80 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 96 <h2 id="mentions-legales" className="text-h4 mt-12 scroll-mt-28">
82 97 Mentions légales
83 98 </h2>
modified src/app/layout.tsx +2 −0
@@ -4,6 +4,7 @@ import { site } from "@/lib/content";
4 4 import { siteUrl } from "@/lib/env";
5 5 import { Header } from "@/components/layout/Header";
6 6 import { Footer } from "@/components/layout/Footer";
7 +import { CookieConsent } from "@/components/consent/CookieConsent";
7 8 import "./globals.css";
8 9
9 10 const garamond = EB_Garamond({
@@ -55,6 +56,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
55 56 {children}
56 57 </main>
57 58 <Footer />
59 + <CookieConsent texts={site.cookies} />
58 60 </body>
59 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 3 import { LogoMark } from "@/components/graphics/Logo";
4 4 import { LinkedInIcon, MailIcon } from "@/components/ui/Icons";
5 5 import { currentYear } from "@/lib/time";
6 +import { ManageCookiesButton } from "@/components/consent/CookieConsent";
6 7
7 8 export async function Footer() {
8 9 const year = await currentYear();
@@ -91,6 +92,10 @@ export async function Footer() {
91 92 {l.label}
92 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 99 </p>
95 100 <p className="text-ink-700 font-semibold tracking-[0.14em] uppercase">
96 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 45 linkedin: z.union([z.literal(""), z.url()]),
46 46 }),
47 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 69 seo: z.object({
49 70 siteUrl: z.url(),
50 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 2 import AxeBuilder from "@axe-core/playwright";
3 3
4 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 3 test.describe("formulaire de contact", () => {
4 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 3 test.describe("menu mobile", () => {
4 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 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 3 /** Captures de référence pour docs/QA_REPORT.md (desktop + mobile). */
4 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