diff --git a/README.md b/README.md
index d78248f..7a17735 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,7 @@ npm run dev # http://127.0.0.1:3000
| `NEXT_PUBLIC_TURNSTILE_SITE_KEY` / `TURNSTILE_SECRET_KEY` | Captcha de Cloudflare |
| `ADMIN_EMAILS` / `ADMIN_GMLEVEL` | Acceso al panel de administración |
| `CRON_SECRET` | Protege el endpoint de reconciliación de SumUp |
+| `NEXT_PUBLIC_GA_ID` | Google Analytics 4 (p. ej. `G-XXXXXXXXXX`). **Vacío o sin poner = analítica desactivada**, no se carga nada. Además, aunque esté puesta, solo se carga si el visitante acepta la categoría «Analíticas» del banner de cookies. Al ser `NEXT_PUBLIC_*` se incrusta en el build: activar o desactivar exige `npm run build` + reinicio |
## 3. Producción
diff --git a/web-next/app/[locale]/layout.tsx b/web-next/app/[locale]/layout.tsx
index 75a12e3..012ce7e 100644
--- a/web-next/app/[locale]/layout.tsx
+++ b/web-next/app/[locale]/layout.tsx
@@ -9,6 +9,7 @@ import { Video } from '@/components/Video'
import { Social } from '@/components/Social'
import { Footer } from '@/components/Footer'
import { CookieBanner } from '@/components/CookieConsent'
+import { Analytics } from '@/components/Analytics'
import '../globals.css'
export function generateStaticParams() {
@@ -92,6 +93,9 @@ export default async function LocaleLayout({
+ {/* Analítica: solo si hay NEXT_PUBLIC_GA_ID y el visitante ha aceptado
+ la categoría «Analíticas» del banner. Ver components/Analytics.tsx. */}
+
{/* Tooltips de wowhead (WotLK) en todos los enlaces .../wowhead.com/... y
elementos con data-wowhead. Funciona con contenido añadido por React
(carrito, búsquedas) porque delega el hover en el documento. */}
diff --git a/web-next/components/Analytics.tsx b/web-next/components/Analytics.tsx
new file mode 100644
index 0000000..78402fd
--- /dev/null
+++ b/web-next/components/Analytics.tsx
@@ -0,0 +1,26 @@
+'use client'
+
+import { GoogleAnalytics } from '@next/third-parties/google'
+import { useConsent } from './CookieConsent'
+
+/**
+ * Google Analytics 4 (gtag.js), con DOS interruptores:
+ *
+ * 1. `NEXT_PUBLIC_GA_ID` en el .env. Vacío o sin poner = analítica desactivada y
+ * no se carga nada. Es el interruptor del administrador.
+ * 2. El consentimiento del visitante. El sitio ya tiene un banner con la categoría
+ * «Analíticas» que se puede rechazar; cargar gtag sin mirarlo convertiría ese
+ * banner en mentira (y, con visitantes en la UE, en un problema legal). Solo se
+ * monta si la categoría está aceptada, y `useConsent` reacciona al momento: en
+ * cuanto se acepta entra, y si se revoca deja de renderizarse.
+ *
+ * El componente es el oficial de Next (`@next/third-parties`), que carga el mismo
+ * gtag.js pero después de la hidratación, para no penalizar la primera pintada.
+ */
+export function Analytics() {
+ const consent = useConsent('analytics')
+ const gaId = process.env.NEXT_PUBLIC_GA_ID
+
+ if (!gaId || !consent) return null
+ return
+}
diff --git a/web-next/components/CookieConsent.tsx b/web-next/components/CookieConsent.tsx
index f0eef40..297bec5 100644
--- a/web-next/components/CookieConsent.tsx
+++ b/web-next/components/CookieConsent.tsx
@@ -90,6 +90,27 @@ function revokeConsent() {
showBanner()
}
+/**
+ * ¿Ha aceptado el usuario esta categoría de cookies? Reacciona en caliente al
+ * banner (`writeConsent`/`revokeConsent` disparan `ns:consentchange`), así que
+ * quien la use se entera de aceptar o revocar sin recargar.
+ *
+ * Arranca en `false` a propósito: en el servidor no hay cookie que leer, y hasta
+ * que no se confirme el consentimiento NO debe cargarse nada de la categoría.
+ */
+export function useConsent(category: string): boolean {
+ const [granted, setGranted] = useState(false)
+
+ useEffect(() => {
+ const sync = () => setGranted(!!readConsent()?.[category])
+ sync()
+ window.addEventListener(CONSENT_EVENT, sync)
+ return () => window.removeEventListener(CONSENT_EVENT, sync)
+ }, [category])
+
+ return granted
+}
+
// ---------------------------------------------------------------------------
// Banner de consentimiento (fijo abajo, en todo el sitio).
// ---------------------------------------------------------------------------
diff --git a/web-next/package-lock.json b/web-next/package-lock.json
index 21cfea7..4661503 100644
--- a/web-next/package-lock.json
+++ b/web-next/package-lock.json
@@ -8,6 +8,7 @@
"name": "web-next",
"version": "0.1.0",
"dependencies": {
+ "@next/third-parties": "^16.2.10",
"@types/qrcode": "^1.5.6",
"iron-session": "^8.0.4",
"mysql2": "^3.22.6",
@@ -1575,6 +1576,18 @@
"node": ">= 10"
}
},
+ "node_modules/@next/third-parties": {
+ "version": "16.2.10",
+ "resolved": "https://registry.npmjs.org/@next/third-parties/-/third-parties-16.2.10.tgz",
+ "integrity": "sha512-H3yxCMLziM1JKXnjQv83WBH2cp1fB1vMxpC1/bBTpvvaesBEuRuprpzq5RI32atis0VhUZflgdpJdGNZIGpQaQ==",
+ "dependencies": {
+ "third-party-capital": "1.0.20"
+ },
+ "peerDependencies": {
+ "next": "^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0-beta.0",
+ "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -7484,6 +7497,11 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/third-party-capital": {
+ "version": "1.0.20",
+ "resolved": "https://registry.npmjs.org/third-party-capital/-/third-party-capital-1.0.20.tgz",
+ "integrity": "sha512-oB7yIimd8SuGptespDAZnNkzIz+NWaJCu2RMsbs4Wmp9zSDUM8Nhi3s2OOcqYuv3mN4hitXc8DVx+LyUmbUDiA=="
+ },
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
diff --git a/web-next/package.json b/web-next/package.json
index 9ad64c7..47d5e90 100644
--- a/web-next/package.json
+++ b/web-next/package.json
@@ -9,6 +9,7 @@
"lint": "eslint"
},
"dependencies": {
+ "@next/third-parties": "^16.2.10",
"@types/qrcode": "^1.5.6",
"iron-session": "^8.0.4",
"mysql2": "^3.22.6",