Etiqueta de verificación de AdSense, con interruptor y sin anuncios para quien paga

Se añade `<meta name="google-adsense-account">` al <head> de todas las páginas, que
es lo que pide Google para verificar la propiedad del sitio. OJO: esto NO muestra
anuncios ni carga adsbygoogle.js todavía; solo permite que Google confirme que el
dominio es nuestro y arranque la revisión.

TRES interruptores (ver components/AdSense.tsx):
1. `ADSENSE_CLIENT_ID` en el .env (no versionado). Vacío o sin poner = no se emite
   nada. Documentada en el README. NO lleva prefijo NEXT_PUBLIC_ a propósito: la
   etiqueta se pinta en el servidor, así que no hace falta exponerla al bundle ni
   rebuildear para activarla/desactivarla, al revés que NEXT_PUBLIC_GA_ID.
2. Admin (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) Y pagos: solo ve anuncios el rango 1 (Newbie, el
   de por defecto) que ADEMÁS nunca ha pagado.

Los dos cortes del punto 3 son distintos a propósito y no basta con mirar el rango:
el mínimo de SumUp son 0,20 €, que a 100 PD/unidad son 20 PD, y eso sigue siendo
rango 1. Mirando solo el rango, quien pagara el mínimo seguiría viendo anuncios,
que es justo lo contrario de lo que queremos.

Para no duplicar el SQL, las consultas de saldo y de total donado salen de
getAccountDashboard a dos helpers (`getPoints`, `getDonatedPD`) que reutiliza el
nuevo `getAccountRankInfo`, con las dos consultas mínimas en vez del panel entero
(personajes, baneos, correo…). El criterio de rango es el mismo que ya usaba
/my-account: el máximo entre lo donado y el saldo actual.

Verificado contra la web real: anónimo y rango 1 sin pagos SÍ reciben la etiqueta;
rango 1 con 99 PD también; 100 PD de saldo (rango 2) no; rango 1 tras pagar 0,20 €
tampoco; admin tampoco; y con la variable vacía no la recibe nadie. Los dos últimos
casos no existían en la BD, así que se probaron con datos temporales en la cuenta
14 (fila en stripelog y en api_points), ya borrados. /my-account sigue pintando el
rango correcto tras mover las consultas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 06:57:28 +00:00
parent 94f9731b2c
commit 3361f2e9e9
4 changed files with 81 additions and 16 deletions
+1
View File
@@ -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 `<meta name="google-adsense-account">` en el `<head>` 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
+4
View File
@@ -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 (
<html lang={locale}>
<head>
{/* Verificación de la cuenta de Google AdSense: solo si hay ADSENSE_CLIENT_ID
y el visitante no es admin. Ver components/AdSense.tsx. */}
<AdSense />
{/* Iconos del sitio (idénticos a la web Django) */}
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
+42
View File
@@ -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 <Header/>
* 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 <meta name="google-adsense-account" content={client} />
}
+34 -16
View File
@@ -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<AccountDashboard> {
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<RowDataPacket[]>(
'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<number> {
try {
const [d] = await db(DB.default).query<RowDataPacket[]>(
"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<AccountDashboard> {
const accountId = session.accountId ?? 0
const { dp, vp } = await getPoints(accountId)
const donatedPD = await getDonatedPD(accountId)
let battlepayCredits = 0
try {