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