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>
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 | ||