diff --git a/README.md b/README.md
index 7a17735..0cd349c 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,7 @@ npm run dev # http://127.0.0.1:3000
| `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 |
+| `ADSENSE_CLIENT_ID` | ID de editor de Google AdSense (`ca-pub-...`). Emite `` en el `
` de todas las páginas. **Vacío o sin poner = etiqueta desactivada**. No la ven ni los admins ni las cuentas de rango 2+ o que hayan pagado alguna vez (ver `components/AdSense.tsx`). Se lee en el servidor en cada petición: activar o desactivar es tocar el `.env` + reinicio, **sin rebuild** |
## 3. Producción
diff --git a/web-next/app/[locale]/layout.tsx b/web-next/app/[locale]/layout.tsx
index 012ce7e..15b02b5 100644
--- a/web-next/app/[locale]/layout.tsx
+++ b/web-next/app/[locale]/layout.tsx
@@ -10,6 +10,7 @@ import { Social } from '@/components/Social'
import { Footer } from '@/components/Footer'
import { CookieBanner } from '@/components/CookieConsent'
import { Analytics } from '@/components/Analytics'
+import { AdSense } from '@/components/AdSense'
import '../globals.css'
export function generateStaticParams() {
@@ -52,6 +53,9 @@ export default async function LocaleLayout({
return (
+ {/* Verificación de la cuenta de Google AdSense: solo si hay ADSENSE_CLIENT_ID
+ y el visitante no es admin. Ver components/AdSense.tsx. */}
+
{/* Iconos del sitio (idénticos a la web Django) */}
diff --git a/web-next/components/AdSense.tsx b/web-next/components/AdSense.tsx
new file mode 100644
index 0000000..ef61b23
--- /dev/null
+++ b/web-next/components/AdSense.tsx
@@ -0,0 +1,42 @@
+import { getSession } from '@/lib/session'
+import { isAdmin } from '@/lib/admin'
+import { getAccountRankInfo } from '@/lib/account'
+
+/**
+ * Etiqueta de verificación de Google AdSense, con TRES interruptores:
+ *
+ * 1. `ADSENSE_CLIENT_ID` en el .env. Vacío o sin poner = no se emite nada. Es el
+ * interruptor del administrador. No lleva prefijo NEXT_PUBLIC_ a propósito:
+ * esto se renderiza en el servidor (el layout ya es dinámico porque
+ * lee la sesión), así que no hace falta exponerlo al bundle del cliente.
+ * 2. Admin (allowlist ADMIN_EMAILS o gmlevel de AzerothCore): sin etiqueta, para
+ * que la navegación del staff no cuente como tráfico con anuncios.
+ * 3. Rango de la cuenta (ns-ranks, ver getAccountRank en lib/account.ts) y pagos:
+ * solo ve anuncios el rango 1 (Newbie, el de por defecto) que ADEMÁS nunca ha
+ * pagado. Son dos cortes distintos a propósito: cualquier pago confirmado por
+ * Stripe o SumUp los quita por mínimo que sea, y el mínimo de SumUp (0,20 € =
+ * 20 PD) no llega a los 100 PD del rango 2, así que mirar solo el rango dejaría
+ * con anuncios a alguien que ya ha pagado.
+ *
+ * Ambas comprobaciones cortan en seco sin tocar la BD si no hay sesión, así que el
+ * visitante anónimo (el caso normal) no paga ninguna consulta extra.
+ *
+ * Ojo: esto es solo la etiqueta de verificación de la cuenta, no carga adsbygoogle.js
+ * ni escribe cookies, por eso no pasa por el banner de consentimiento. El día que se
+ * añada el script de anuncios sí tendrá que ir detrás de la categoría «Marketing»
+ * (useConsent('marketing'), ver components/Analytics.tsx).
+ */
+export async function AdSense() {
+ const client = process.env.ADSENSE_CLIENT_ID
+ if (!client) return null
+
+ const session = await getSession()
+ if (await isAdmin(session)) return null
+
+ if (session.accountId) {
+ const { level, donatedPD } = await getAccountRankInfo(session.accountId)
+ if (level >= 2 || donatedPD > 0) return null
+ }
+
+ return
+}
diff --git a/web-next/lib/account.ts b/web-next/lib/account.ts
index fa18d60..8e9f003 100644
--- a/web-next/lib/account.ts
+++ b/web-next/lib/account.ts
@@ -54,39 +54,57 @@ export interface AccountDashboard {
characters: Character[]
}
-/** Datos del panel de cuenta. Resiliente si las BD de AzerothCore no están pobladas. */
-export async function getAccountDashboard(session: SessionData, locale = 'es'): Promise {
- const accountId = session.accountId ?? 0
-
- let dp = 0
- let vp = 0
+/** Saldo de puntos (PD y PV) de una cuenta. 0/0 si no tiene fila en api_points. */
+async function getPoints(accountId: number): Promise<{ dp: number; vp: number }> {
try {
const [p] = await db(DB.default).query(
'SELECT dp, vp FROM api_points WHERE accountID = ?',
[accountId],
)
- if (p[0]) {
- dp = p[0].dp
- vp = p[0].vp
- }
+ if (p[0]) return { dp: p[0].dp, vp: p[0].vp }
} catch {
/* ignore */
}
+ return { dp: 0, vp: 0 }
+}
- // Total donado (en PD) para el rango: suma de TODOS los pagos confirmados
- // (Stripe mode test/live + SumUp mode sumup), convertidos a PD. Cuenta todas
- // las pasarelas y donaciones mixtas, y NO baja al gastar PD (a diferencia del
- // saldo). El rango usará el máximo entre esto y el saldo actual.
- let donatedPD = 0
+/**
+ * Total donado (en PD) para el rango: suma de TODOS los pagos confirmados
+ * (Stripe mode test/live + SumUp mode sumup), convertidos a PD. Cuenta todas
+ * las pasarelas y donaciones mixtas, y NO baja al gastar PD (a diferencia del
+ * saldo). El rango usará el máximo entre esto y el saldo actual.
+ */
+async function getDonatedPD(accountId: number): Promise {
try {
const [d] = await db(DB.default).query(
"SELECT COALESCE(SUM(amount), 0) AS total FROM stripelog WHERE account_id = ? AND fulfilled = 1 AND mode IN ('sumup', 'test', 'live')",
[accountId],
)
- donatedPD = Math.round(Number(d[0]?.total ?? 0) * PD_PER_UNIT)
+ return Math.round(Number(d[0]?.total ?? 0) * PD_PER_UNIT)
} catch {
/* ignore */
}
+ return 0
+}
+
+/**
+ * Rango (1-14) y total donado de una cuenta, con las dos consultas mínimas y sin
+ * traerse el panel entero (personajes, baneos, correo…). Mismo criterio de rango
+ * que /my-account: el máximo entre lo donado y el saldo actual.
+ */
+export async function getAccountRankInfo(
+ accountId: number,
+): Promise<{ level: number; donatedPD: number }> {
+ const [{ dp }, donatedPD] = await Promise.all([getPoints(accountId), getDonatedPD(accountId)])
+ return { level: getAccountRank(Math.max(donatedPD, dp)).level, donatedPD }
+}
+
+/** Datos del panel de cuenta. Resiliente si las BD de AzerothCore no están pobladas. */
+export async function getAccountDashboard(session: SessionData, locale = 'es'): Promise {
+ const accountId = session.accountId ?? 0
+
+ const { dp, vp } = await getPoints(accountId)
+ const donatedPD = await getDonatedPD(accountId)
let battlepayCredits = 0
try {