web-next: banner de consentimiento de cookies + estado de la API (Zaraz)

- Banner real `uw-cookie-content` (Rechazar/Personalizar/Aceptar todas) fijo
  abajo y site-wide (montado en el layout, componente CookieBanner). Aparece
  en la primera visita, guarda la decisión en la cookie uw_cookie_consent (1
  año) y se sincroniza con Cloudflare Zaraz si está presente
  (zaraz.consent.setAll / .modal). Estilos en globals.css.
- «Personalizar» abre el modal granular de Zaraz (o /cookies si no hay Zaraz).
- window.UWCookies {showBanner, revokeConsent, acceptAll, rejectAll}.
- Página /cookies: panel «Tu estado de consentimiento» (ConsentStatus) que
  muestra los datos EN VIVO de la API de Zaraz (zaraz.consent.getAll()) por
  propósito; si Zaraz no está, cae a la preferencia guardada por categoría.

Verificado con navegador: el banner aparece, «Aceptar todas» lo oculta y
escribe la cookie, y el panel de /cookies refleja el estado.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 11:58:54 +00:00
parent e289a87d1f
commit 4ebe3c34d3
4 changed files with 298 additions and 23 deletions
+192 -22
View File
@@ -1,50 +1,148 @@
'use client'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import { Link } from '@/i18n/navigation'
// ---------------------------------------------------------------------------
// API de consentimiento de Cloudflare Zaraz (se inyecta cuando el dominio pasa
// por el proxy de Cloudflare con Zaraz Consent activado).
// ---------------------------------------------------------------------------
interface ZarazConsent {
modal?: boolean
APIReady?: boolean
setAll?: (status: boolean) => void
getAll?: () => Record<string, boolean>
sendQueuedEvents?: () => void
}
declare global {
interface Window {
zaraz?: { consent?: ZarazConsent }
UWCookies?: { showBanner: () => void; revokeConsent: () => void }
UWCookies?: {
showBanner: () => void
revokeConsent: () => void
acceptAll: () => void
rejectAll: () => void
}
}
}
/** Abre el modal de consentimiento de Zaraz. */
function showBanner() {
const c = typeof window !== 'undefined' ? window.zaraz?.consent : undefined
// Categorías de consentimiento (necesarias siempre activas).
type Category = { key: string; label: string; always?: boolean }
export const CONSENT_CATEGORIES: readonly Category[] = [
{ key: 'necessary', label: 'Necesarias', always: true },
{ key: 'preferences', label: 'Preferencias' },
{ key: 'analytics', label: 'Analíticas' },
{ key: 'marketing', label: 'Marketing' },
]
type Consent = Record<string, boolean>
const CONSENT_COOKIE = 'uw_cookie_consent'
const CONSENT_EVENT = 'uw:consentchange'
const SHOW_EVENT = 'uw:showbanner'
function zaraz(): ZarazConsent | undefined {
return typeof window !== 'undefined' ? window.zaraz?.consent : undefined
}
/** Lee la preferencia guardada en la cookie uw_cookie_consent. */
function readConsent(): Consent | null {
if (typeof document === 'undefined') return null
const m = document.cookie.match(/(?:^|; )uw_cookie_consent=([^;]*)/)
if (!m) return null
try {
return JSON.parse(decodeURIComponent(m[1])) as Consent
} catch {
return null
}
}
function writeConsent(c: Consent) {
const maxAge = 60 * 60 * 24 * 365 // 1 año
document.cookie = `${CONSENT_COOKIE}=${encodeURIComponent(JSON.stringify(c))}; path=/; max-age=${maxAge}; SameSite=Lax`
// Sincroniza con Cloudflare Zaraz si está presente (control granular real lo
// gestiona el panel de Zaraz; aquí aplicamos aceptar/rechazar todo).
const nonNecessary = CONSENT_CATEGORIES.some((cat) => !cat.always && c[cat.key])
zaraz()?.setAll?.(nonNecessary)
zaraz()?.sendQueuedEvents?.()
window.dispatchEvent(new CustomEvent(CONSENT_EVENT))
}
function allFlags(value: boolean): Consent {
const c: Consent = {}
for (const cat of CONSENT_CATEGORIES) c[cat.key] = cat.always ? true : value
return c
}
function acceptAll() {
writeConsent(allFlags(true))
}
function rejectAll() {
writeConsent(allFlags(false))
}
/** Abre el modal granular de Zaraz; si no está, lleva a la página de cookies. */
function customize() {
const c = zaraz()
if (c) c.modal = true
else console.warn('[UWCookies] Zaraz no disponible (el sitio debe pasar por Cloudflare con Zaraz Consent activado).')
else if (typeof window !== 'undefined') window.location.href = '/cookies'
}
function showBanner() {
window.dispatchEvent(new CustomEvent(SHOW_EVENT))
}
/** Retira el consentimiento (pone todos los propósitos a false) y reabre el modal. */
function revokeConsent() {
const c = typeof window !== 'undefined' ? window.zaraz?.consent : undefined
if (c?.setAll) {
c.setAll(false)
c.modal = true
} else {
console.warn('[UWCookies] Zaraz no disponible; no se puede retirar el consentimiento.')
}
// Borra la cookie, retira en Zaraz y reabre el banner.
document.cookie = `${CONSENT_COOKIE}=; path=/; max-age=0; SameSite=Lax`
zaraz()?.setAll?.(false)
window.dispatchEvent(new CustomEvent(CONSENT_EVENT))
showBanner()
}
/**
* Botones de consentimiento de la página /cookies. Cablean el sistema de
* Cloudflare Zaraz y exponen window.UWCookies (showBanner/revokeConsent) para
* el resto del sitio.
*/
export function CookieConsentButtons() {
// ---------------------------------------------------------------------------
// Banner de consentimiento (fijo abajo, en todo el sitio).
// ---------------------------------------------------------------------------
export function CookieBanner() {
const [visible, setVisible] = useState(false)
useEffect(() => {
window.UWCookies = { showBanner, revokeConsent }
window.UWCookies = { showBanner, revokeConsent, acceptAll, rejectAll }
// Aparece si aún no hay decisión guardada.
if (!readConsent()) setVisible(true)
const onShow = () => setVisible(true)
const onChange = () => {
if (readConsent()) setVisible(false)
}
window.addEventListener(SHOW_EVENT, onShow)
window.addEventListener(CONSENT_EVENT, onChange)
return () => {
window.removeEventListener(SHOW_EVENT, onShow)
window.removeEventListener(CONSENT_EVENT, onChange)
}
}, [])
if (!visible) return null
return (
<div className="uw-cookie-content" role="dialog" aria-label="Consentimiento de cookies">
<div className="uw-cookie-text">
<h3>Utilizamos cookies</h3>
<p>Este sitio utiliza cookies propias y de terceros para garantizar el funcionamiento del sitio, analizar el tráfico y ofrecerte publicidad personalizada. Consulta nuestra política de cookies para más información.</p>
<Link href="/cookies" className="uw-cookie-link">Política de Cookies</Link>
</div>
<div className="uw-cookie-actions">
<button type="button" className="uw-cookie-btn uw-cookie-btn-reject" data-action="reject" onClick={() => { rejectAll(); setVisible(false) }}>Rechazar</button>
<button type="button" className="uw-cookie-btn uw-cookie-btn-customize" data-action="customize" onClick={customize}>Personalizar</button>
<button type="button" className="uw-cookie-btn uw-cookie-btn-accept" data-action="accept" onClick={() => { acceptAll(); setVisible(false) }}>Aceptar todas</button>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Botones «Cambiar / Retirar consentimiento» de la página /cookies.
// ---------------------------------------------------------------------------
export function CookieConsentButtons() {
useEffect(() => {
window.UWCookies = { showBanner, revokeConsent, acceptAll, rejectAll }
}, [])
return (
<div className="uw-consent-buttons">
<button type="button" onClick={showBanner} className="btn-uw btn-uw-form">
@@ -56,3 +154,75 @@ export function CookieConsentButtons() {
</div>
)
}
// ---------------------------------------------------------------------------
// Panel «Tu estado de consentimiento»: muestra los datos de la API de Zaraz
// (o, si Zaraz no está, la preferencia guardada en la cookie uw_cookie_consent).
// ---------------------------------------------------------------------------
export function ConsentStatus() {
const [state, setState] = useState<{ source: string; values: Consent | null }>({
source: 'cookie',
values: null,
})
useEffect(() => {
function refresh() {
const c = zaraz()
if (c?.APIReady && c.getAll) {
setState({ source: 'zaraz', values: c.getAll() })
} else {
setState({ source: 'cookie', values: readConsent() })
}
}
refresh()
window.addEventListener(CONSENT_EVENT, refresh)
window.addEventListener('zarazConsentAPIReady', refresh)
window.addEventListener('zarazConsentChoicesUpdated', refresh)
return () => {
window.removeEventListener(CONSENT_EVENT, refresh)
window.removeEventListener('zarazConsentAPIReady', refresh)
window.removeEventListener('zarazConsentChoicesUpdated', refresh)
}
}, [])
const zarazData = state.source === 'zaraz' && state.values
const values = state.values
return (
<div className="uw-consent-status">
<p className="uw-consent-source">
{zarazData
? 'Estado actual (Cloudflare Zaraz Consent API):'
: values
? 'Estado actual (preferencia guardada):'
: 'Cloudflare Zaraz no está disponible en este dominio; aún no hay preferencia guardada.'}
</p>
<ul>
{zarazData ? (
// Datos en vivo de la API de Zaraz: cada propósito y su estado.
Object.entries(values as Consent).map(([purpose, granted]) => (
<li key={purpose}>
<span>{purpose}</span>
<span className={granted ? 'uw-consent-on' : 'uw-consent-off'}>
{granted ? 'Aceptada' : 'Rechazada'}
</span>
</li>
))
) : (
// Categorías locales (cookie uw_cookie_consent).
CONSENT_CATEGORIES.map((cat) => {
const on = cat.always || Boolean(values?.[cat.key])
return (
<li key={cat.key}>
<span>{cat.label}</span>
<span className={cat.always ? 'uw-consent-always' : on ? 'uw-consent-on' : 'uw-consent-off'}>
{cat.always ? 'Siempre activas' : values ? (on ? 'Aceptada' : 'Rechazada') : '—'}
</span>
</li>
)
})
)}
</ul>
</div>
)
}