SPB Git

spb/valoplex Public

ValoPlex — moteur d'évaluation spécialisé pour les plex au Québec, petit frère de Vrai-Prix.

TypeScript 90.3% Python 7.1% CSS 2.5%
8.1 KB · 227 lines tsx
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Gestionnaire de consentement aux témoins (Loi 25) :4 * bandeau au premier passage + panneau de préférences réouvrable partout5 * (événement window « vp-open-cookie-prefs » ou bouton du pied de page).6 * Choix conservé 12 mois dans localStorage (vp-cookie-consent).7 */8"use client";9import { useEffect, useState } from "react";10import { useLang } from "./LangContext";1112export interface ConsentState {13  essential: true;14  preferences: boolean;15  analytics: boolean;16  savedAt: string;17}1819const KEY = "vp-cookie-consent";20const MAX_AGE_DAYS = 365;2122export function readConsent(): ConsentState | null {23  if (typeof window === "undefined") return null;24  try {25    const raw = window.localStorage.getItem(KEY);26    if (!raw) return null;27    const c = JSON.parse(raw) as ConsentState;28    const age = (Date.now() - new Date(c.savedAt).getTime()) / 86400000;29    return age > MAX_AGE_DAYS ? null : c;30  } catch {31    return null;32  }33}3435export function openCookiePrefs() {36  window.dispatchEvent(new Event("vp-open-cookie-prefs"));37}3839const TXT = {40  fr: {41    banner:42      "ValoPlex utilise un minimum de témoins : des essentiels (fonctionnement du site) et, si vous l'acceptez, un témoin de préférences pour retenir votre langue. Aucun témoin publicitaire, aucun pistage tiers.",43    acceptAll: "Tout accepter",44    essentialOnly: "Essentiels seulement",45    customize: "Personnaliser",46    prefsTitle: "Préférences de témoins",47    essential: "Essentiels",48    essentialDesc: "Nécessaires au fonctionnement (sécurité, session). Toujours actifs.",49    prefs: "Préférences",50    prefsDesc: "Retient votre langue d'affichage (FR/EN) sur cet appareil, 12 mois.",51    analytics: "Mesure d'audience",52    analyticsDesc: "Aucun outil de mesure n'est actuellement utilisé. Ce réglage s'appliquera si un jour un outil respectueux est ajouté.",53    always: "Toujours actifs",54    save: "Enregistrer mes choix",55    more: "Politique de confidentialité",56  },57  en: {58    banner:59      "ValoPlex uses a minimum of cookies: essential ones (site operation) and, with your consent, a preference cookie to remember your language. No advertising cookies, no third-party tracking.",60    acceptAll: "Accept all",61    essentialOnly: "Essential only",62    customize: "Customize",63    prefsTitle: "Cookie preferences",64    essential: "Essential",65    essentialDesc: "Required for operation (security, session). Always on.",66    prefs: "Preferences",67    prefsDesc: "Remembers your display language (FR/EN) on this device for 12 months.",68    analytics: "Analytics",69    analyticsDesc: "No analytics tool is currently used. This setting will apply if a privacy-respecting tool is ever added.",70    always: "Always on",71    save: "Save my choices",72    more: "Privacy policy",73  },74};7576function Toggle({77  on,78  disabled,79  onChange,80}: {81  on: boolean;82  disabled?: boolean;83  onChange?: (v: boolean) => void;84}) {85  return (86    <button87      type="button"88      role="switch"89      aria-checked={on}90      disabled={disabled}91      onClick={() => onChange?.(!on)}92      className={`relative h-7 w-12 shrink-0 rounded-full border-[1.5px] border-ink transition-colors ${93        on ? "bg-lime" : "bg-surface-2"94      } ${disabled ? "cursor-not-allowed opacity-70" : "cursor-pointer"}`}95    >96      <span97        className={`absolute top-[2.5px] h-[18px] w-[18px] rounded-full bg-ink transition-all ${98          on ? "left-[26px]" : "left-[3px]"99        }`}100      />101    </button>102  );103}104105export default function CookieConsent() {106  const { lang } = useLang();107  const t = TXT[lang];108  const [ready, setReady] = useState(false);109  const [consent, setConsent] = useState<ConsentState | null>(null);110  const [panel, setPanel] = useState(false);111  const [prefs, setPrefs] = useState(true);112  const [analytics, setAnalytics] = useState(false);113114  useEffect(() => {115    const id = setTimeout(() => {116      const c = readConsent();117      setConsent(c);118      if (c) {119        setPrefs(c.preferences);120        setAnalytics(c.analytics);121      }122      setReady(true);123    }, 0);124    const open = () => setPanel(true);125    window.addEventListener("vp-open-cookie-prefs", open);126    return () => {127      clearTimeout(id);128      window.removeEventListener("vp-open-cookie-prefs", open);129    };130  }, []);131132  const persist = (preferences: boolean, analyticsOn: boolean) => {133    const c: ConsentState = {134      essential: true,135      preferences,136      analytics: analyticsOn,137      savedAt: new Date().toISOString(),138    };139    window.localStorage.setItem(KEY, JSON.stringify(c));140    if (!preferences) window.localStorage.removeItem("vp-lang");141    setConsent(c);142    setPanel(false);143  };144145  if (!ready) return null;146  const showBanner = consent === null && !panel;147148  return (149    <>150      {showBanner && (151        <div className="fixed inset-x-0 bottom-0 z-[1300] p-3 sm:p-5">152          <div className="mx-auto max-w-3xl rounded-[10px] border-[1.5px] border-ink bg-ink p-5 text-paper shadow-[0_-8px_32px_rgba(16,18,16,0.35),6px_6px_0_rgba(20,24,20,0.3)]">153            <p className="klabel !text-lime">🍪 {t.prefsTitle}</p>154            <p className="mt-2 text-[13.5px] leading-relaxed text-[rgba(245,243,238,0.85)]">155              {t.banner}{" "}156              <a href="/confidentialite" className="border-b border-lime text-lime">157                {t.more}158              </a>159            </p>160            <div className="mt-4 flex flex-wrap gap-2.5">161              <button className="btn border-lime bg-lime text-ink" onClick={() => persist(true, true)}>162                {t.acceptAll}163              </button>164              <button165                className="btn border-[rgba(245,243,238,0.5)] bg-transparent text-paper hover:bg-[rgba(245,243,238,0.12)]"166                onClick={() => persist(false, false)}167              >168                {t.essentialOnly}169              </button>170              <button171                className="btn border-[rgba(245,243,238,0.5)] bg-transparent text-paper hover:bg-[rgba(245,243,238,0.12)]"172                onClick={() => setPanel(true)}173              >174                {t.customize}175              </button>176            </div>177          </div>178        </div>179      )}180181      {panel && (182        <div183          className="fixed inset-0 z-[1400] flex items-end justify-center bg-[rgba(16,18,16,0.55)] p-3 backdrop-blur-[2px] sm:items-center"184          onClick={() => setPanel(false)}185        >186          <div187            className="w-full max-w-lg rounded-[10px] border-[1.5px] border-ink bg-surface p-6 shadow-[8px_8px_0_rgba(20,24,20,0.3)]"188            onClick={(e) => e.stopPropagation()}189            role="dialog"190            aria-modal="true"191            aria-label={t.prefsTitle}192          >193            <h2 className="vp-display text-[19px] font-bold uppercase tracking-[-0.01em]">194              {t.prefsTitle}195            </h2>196            <div className="mt-4 space-y-3">197              {[198                { k: t.essential, d: t.essentialDesc, on: true, lock: true, set: undefined },199                { k: t.prefs, d: t.prefsDesc, on: prefs, lock: false, set: setPrefs },200                { k: t.analytics, d: t.analyticsDesc, on: analytics, lock: false, set: setAnalytics },201              ].map((row) => (202                <div key={row.k} className="flex items-start justify-between gap-4 rounded-lg border-[1.5px] border-[rgba(20,24,20,0.14)] bg-surface-2 p-4">203                  <div>204                    <p className="vp-display text-[14px] font-bold">205                      {row.k}206                      {row.lock && (207                        <span className="vp-mono ml-2 text-[9px] font-bold uppercase tracking-[0.08em] text-green-deep">208                          {t.always}209                        </span>210                      )}211                    </p>212                    <p className="mt-1 text-[12.5px] text-ink-2">{row.d}</p>213                  </div>214                  <Toggle on={row.on} disabled={row.lock} onChange={row.set} />215                </div>216              ))}217            </div>218            <button className="btn btn-primary mt-5 w-full" onClick={() => persist(prefs, analytics)}>219              {t.save}220            </button>221          </div>222        </div>223      )}224    </>225  );226}227