spb/phi-conseil
Public
1"use client";23import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";4import { createPortal } from "react-dom";5import Link from "next/link";6import { Button } from "@/components/ui/Button";7import {8 allAccepted,9 allRejected,10 CONSENT_OPEN_EVENT,11 CONSENT_VERSION,12 type Consent,13 type OptionalCategory,14} from "@/lib/consent";15import { getServerSnapshot, getSnapshot, subscribe, writeConsent } from "./consent-store";1617export 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};2930export 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);4142 // 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 }, []);5354 useEffect(() => {55 if (prefsOpen) panelRef.current?.focus();56 }, [prefsOpen]);5758 if (consent === null) return null; // rendu serveur / hydratation : rien59 const visible = consent === undefined || forcedOpen;60 if (!visible) return null;6162 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 });7374 const panel = (75 <div76 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 <div91 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 <Link101 href={texts.policyHref}102 className="hover:text-navy-900 underline underline-offset-4"103 >104 {texts.policyLabel}105 </Link>106 </p>107108 {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 <input117 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}145146 <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 );176177 return createPortal(panel, document.body);178}179180export function ManageCookiesButton({181 label,182 className = "",183}: {184 label: string;185 className?: string;186}) {187 return (188 <button189 type="button"190 className={className}191 onClick={() => window.dispatchEvent(new Event(CONSENT_OPEN_EVENT))}192 >193 {label}194 </button>195 );196}197