2c78489e4d
Salieron al pasar el lint por todo el proyecto. De 47 a 0 (quedan 15 avisos: <img> vs <Image /> y el <link> del tema, los dos deliberados). - Footer y Video (42 de los 47, que en realidad eran 7 enlaces: el plugin repite cada uno 6 veces): usaban `<a href="/terms-and-conditions">` SIN el idioma delante. No estaba roto de milagro: el middleware lo salvaba mirando la cookie NEXT_LOCALE. Pero cada clic se comía un 307 y recargaba la página entera en vez de navegar en cliente, y el idioma lo decidía la cookie en vez de la URL en la que estás. Ahora van con el `Link` de i18n, que ya existía. - Turnstile: escribía una ref (`cb.current = onVerify`) DURANTE el render. Es el patrón de "callback fresca", pero no está permitido: React puede descartar ese render y dejarla mal. Pasa a un efecto. - ActivateClient y ConfirmClient: ponían el estado de error con setState dentro del efecto cuando NO hay hash. Eso se sabe ya al renderizar (es un prop), así que se deriva del estado inicial y se ahorra un render. - BattlepayList: `window.location.href = …` -> `.assign()`, igual que en la tienda. - CookieConsent: aquí el efecto es correcto y la regla no aplica, así que se silencia explicando por qué: el consentimiento vive en una cookie del navegador, en el servidor no existe, y leerlo al renderizar rompería la hidratación (le saldría el banner a quien ya había decidido). Verificado: desde /es/ el pie enlaza a /es/terms-and-conditions y desde /en/ a /en/terms-and-conditions; portada, cookies, tienda y mi-cuenta siguen dando 200. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
282 lines
11 KiB
TypeScript
282 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import { useTranslations } from 'next-intl'
|
|
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
|
|
acceptAll: () => void
|
|
rejectAll: () => void
|
|
}
|
|
}
|
|
}
|
|
|
|
// Categorías de consentimiento (necesarias siempre activas).
|
|
type Category = { key: string; label: string; always?: boolean; desc?: string }
|
|
export const CONSENT_CATEGORIES: readonly Category[] = [
|
|
{ key: 'necessary', label: 'Necesarias', always: true, desc: 'Esenciales para el funcionamiento del sitio. No se pueden desactivar.' },
|
|
{ key: 'analytics', label: 'Analíticas', desc: 'Nos ayudan a entender cómo usas el sitio para mejorarlo.' },
|
|
{ key: 'marketing', label: 'Marketing', desc: 'Se utilizan para mostrarte anuncios relevantes.' },
|
|
]
|
|
|
|
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))
|
|
}
|
|
function showBanner() {
|
|
window.dispatchEvent(new CustomEvent(SHOW_EVENT))
|
|
}
|
|
function revokeConsent() {
|
|
// 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()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Banner de consentimiento (fijo abajo, en todo el sitio).
|
|
// ---------------------------------------------------------------------------
|
|
export function CookieBanner() {
|
|
const t = useTranslations('UI')
|
|
const [visible, setVisible] = useState(false)
|
|
const [details, setDetails] = useState(false)
|
|
// Estado de los checkboxes granulares (categorías no necesarias).
|
|
const [prefs, setPrefs] = useState<Consent>({ analytics: false, marketing: false })
|
|
|
|
useEffect(() => {
|
|
window.UWCookies = { showBanner, revokeConsent, acceptAll, rejectAll }
|
|
// Precarga los checkboxes con lo ya guardado (si lo hay).
|
|
// El lint avisa de que esto encadena un render, y aquí no hay alternativa:
|
|
// el consentimiento está en una cookie del navegador y en el servidor no
|
|
// existe, así que no se puede saber al renderizar sin romper la hidratación
|
|
// (saldría el banner en el HTML a quien ya había decidido). El render de más
|
|
// es inherente y solo ocurre al montar.
|
|
/* eslint-disable react-hooks/set-state-in-effect */
|
|
const saved = readConsent()
|
|
if (saved) setPrefs({ analytics: !!saved.analytics, marketing: !!saved.marketing })
|
|
else setVisible(true) // Aparece si aún no hay decisión guardada.
|
|
/* eslint-enable react-hooks/set-state-in-effect */
|
|
const onShow = () => {
|
|
const cur = readConsent()
|
|
if (cur) setPrefs({ analytics: !!cur.analytics, marketing: !!cur.marketing })
|
|
setDetails(false)
|
|
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
|
|
|
|
function savePrefs() {
|
|
writeConsent({ necessary: true, analytics: !!prefs.analytics, marketing: !!prefs.marketing })
|
|
setVisible(false)
|
|
}
|
|
|
|
return (
|
|
<div className="uw-cookie-content" role="dialog" aria-label={t('cookie.dialogLabel')}>
|
|
<div className="uw-cookie-text">
|
|
<h3>{t('cookie.title')}</h3>
|
|
<p>{t('cookie.description')}</p>
|
|
<Link href="/cookies" className="uw-cookie-link">{t('cookie.policyLink')}</Link>
|
|
|
|
{details && (
|
|
<div className="uw-cookie-details" style={{ display: 'block' }}>
|
|
<div className="uw-cookie-category">
|
|
<label>
|
|
<input type="checkbox" checked disabled readOnly />
|
|
<span className="uw-cookie-cat-name">{t('cookie.catNecessary')}</span>
|
|
</label>
|
|
<p>{t('cookie.descNecessary')}</p>
|
|
</div>
|
|
<div className="uw-cookie-category">
|
|
<label>
|
|
<input type="checkbox" id="uw-cookie-analytics" checked={!!prefs.analytics} onChange={(e) => setPrefs((s) => ({ ...s, analytics: e.target.checked }))} />
|
|
<span className="uw-cookie-cat-name">{t('cookie.catAnalytics')}</span>
|
|
</label>
|
|
<p>{t('cookie.descAnalytics')}</p>
|
|
</div>
|
|
<div className="uw-cookie-category">
|
|
<label>
|
|
<input type="checkbox" id="uw-cookie-marketing" checked={!!prefs.marketing} onChange={(e) => setPrefs((s) => ({ ...s, marketing: e.target.checked }))} />
|
|
<span className="uw-cookie-cat-name">{t('cookie.catMarketing')}</span>
|
|
</label>
|
|
<p>{t('cookie.descMarketing')}</p>
|
|
</div>
|
|
<div className="uw-cookie-actions-detail">
|
|
<button type="button" className="uw-cookie-btn uw-cookie-btn-save" data-action="save" onClick={savePrefs}>{t('cookie.savePrefs')}</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="uw-cookie-actions">
|
|
<button type="button" className="uw-cookie-btn uw-cookie-btn-reject" data-action="reject" onClick={() => { rejectAll(); setVisible(false) }}>{t('cookie.reject')}</button>
|
|
<button type="button" className="uw-cookie-btn uw-cookie-btn-customize" data-action="customize" onClick={() => setDetails((v) => !v)}>{t('cookie.customize')}</button>
|
|
<button type="button" className="uw-cookie-btn uw-cookie-btn-accept" data-action="accept" onClick={() => { acceptAll(); setVisible(false) }}>{t('cookie.acceptAll')}</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Botones «Cambiar / Retirar consentimiento» de la página /cookies.
|
|
// ---------------------------------------------------------------------------
|
|
export function CookieConsentButtons() {
|
|
const t = useTranslations('UI')
|
|
useEffect(() => {
|
|
window.UWCookies = { showBanner, revokeConsent, acceptAll, rejectAll }
|
|
}, [])
|
|
return (
|
|
<div className="uw-consent-buttons">
|
|
<button type="button" onClick={showBanner} className="btn-uw btn-uw-form">
|
|
{t('cookie.changeConsent')}
|
|
</button>
|
|
<button type="button" onClick={revokeConsent} className="btn-uw btn-uw-form btn-uw-secondary">
|
|
{t('cookie.revokeConsent')}
|
|
</button>
|
|
</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 t = useTranslations('UI')
|
|
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
|
|
const catLabel: Record<string, string> = {
|
|
necessary: t('cookie.catNecessary'),
|
|
analytics: t('cookie.catAnalytics'),
|
|
marketing: t('cookie.catMarketing'),
|
|
}
|
|
|
|
return (
|
|
<div className="uw-consent-status">
|
|
<p className="uw-consent-source">
|
|
{zarazData
|
|
? t('cookie.statusZaraz')
|
|
: values
|
|
? t('cookie.statusSaved')
|
|
: t('cookie.statusUnavailable')}
|
|
</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 ? t('cookie.accepted') : t('cookie.rejected')}
|
|
</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>{catLabel[cat.key] ?? cat.label}</span>
|
|
<span className={cat.always ? 'uw-consent-always' : on ? 'uw-consent-on' : 'uw-consent-off'}>
|
|
{cat.always ? t('cookie.alwaysOn') : values ? (on ? t('cookie.accepted') : t('cookie.rejected')) : '—'}
|
|
</span>
|
|
</li>
|
|
)
|
|
})
|
|
)}
|
|
</ul>
|
|
</div>
|
|
)
|
|
}
|