SPB Git

spb/phi-conseil

Public
6.4 KB · 197 lines tsx
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
Files · main
Menu

Theme