Commit Graph

149 Commits

Author SHA1 Message Date
Inna 67656fa32d Anuncios de Monetag, apagados por defecto y detrás del consentimiento
Se añade la integración del script de Monetag (MultiTag: popunder + push + in-page
push + vignette) como alternativa a AdSense para monetizar sin depender de que
Google apruebe el sitio.

Tres interruptores (ver components/Monetag.tsx):
1. `MONETAG_SRC` + `MONETAG_ZONE` en el .env (no versionadas, documentadas en el
   README). Cualquiera de las dos vacía = no se carga nada. Se leen en el servidor
   y se pasan como props, así que encender/apagar es tocar el .env + reiniciar,
   SIN rebuild. Ahora mismo van vacías: la integración queda instalada pero inerte
   hasta que se pegue la zona del panel de Monetag.
2. El filtro común de publicidad, extraído a lib/ads.ts (`shouldServeAds`): fuera
   para admins y para quien sea rango 2+ o haya pagado alguna vez. Antes esta lógica
   vivía dentro de AdSense.tsx; ahora la comparten la etiqueta de AdSense y el
   script de Monetag desde un único sitio, para que no se puedan desincronizar.
3. El consentimiento «Marketing» del banner, en components/MonetagScript.tsx
   (cliente). A diferencia de la etiqueta de AdSense, MultiTag escribe cookies y
   pide permisos, así que NO puede cargarse sin aceptar la categoría. Mismo patrón
   que Analytics.tsx: useConsent reacciona en caliente, arranca en false.

El push de MultiTag necesitará además un sw.js servido desde la raíz del dominio
(public/sw.js, descargado del panel). Sin él, el resto de formatos funcionan igual.

Verificado: con las variables vacías la web queda idéntica (ni script de Monetag ni
cambios) y la etiqueta de AdSense sigue intacta. Con una zona de prueba, el filtro
de servidor deja pasar a anónimos y rango 1 sin pagos, y corta a los admins. Sin
consentimiento no se inyecta ningún <script> (la zona solo viaja como prop inerte
en el payload de React).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:15:26 +00:00
Inna 3361f2e9e9 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>
2026-07-16 06:57:28 +00:00
Inna 94f9731b2c Google Analytics 4, con interruptor y respetando el consentimiento
Se añade GA4 (gtag.js) con el componente oficial `@next/third-parties`, que es lo
que recomienda el doc de Next 16 y carga el mismo gtag pero tras la hidratación.

DOS interruptores:
1. `NEXT_PUBLIC_GA_ID` en el .env (no versionado). Vacío o sin poner = no se carga
   nada. Documentada en el README.
2. El consentimiento del visitante. El sitio YA tenía un banner con la categoría
   «Analíticas» que se puede rechazar: soltar gtag en el <head> lo habría
   convertido en mentira, y con visitantes en la UE, en un problema legal.

Para lo segundo se expone `useConsent(categoría)` desde CookieConsent, que además
reacciona en caliente: `writeConsent`/`revokeConsent` ya disparaban el evento
`ns:consentchange`, así que la analítica entra en cuanto se acepta y desaparece
si se revoca, sin recargar. Arranca en `false` a propósito: hasta confirmar el
consentimiento no se carga nada.

Verificado en el build: sin consentimiento no hay ni rastro de googletagmanager
en el HTML, y la condición compilada es `id && consent ? <GoogleAnalytics/> : null`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 19:49:00 +00:00
Inna 4ee7d78994 El enlace de restablecer deja de servir una vez usado
La página pintaba el formulario sin mirar el token: rellenabas la contraseña
nueva y solo al enviarla te enterabas de que el enlace ya no valía. El API sí
marcaba `used = 1` y rechazaba el reintento, pero la pantalla no lo reflejaba.

Ahora la página comprueba el token al abrirse con `isResetTokenValid()` (mismas
condiciones que `resetPassword` —existe, sin usar, dentro de la hora— pero SIN
consumirlo) y, si no vale, muestra el aviso del tema en vez del formulario.

Textos: `invalidLink` pasa a «El enlace de restablecer la contraseña es inválido»
y se añade `needHelp`, como en la página de activación.

Verificado en producción con enlaces reales: válido -> formulario; usado,
caducado, inexistente y sin token -> aviso, sin formulario. Y el ciclo entero:
restablecer con un enlace válido lo marca usado y al reabrirlo ya sale el aviso.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 19:13:06 +00:00
Inna fea1316f3c Recuperar cuenta: siempre por correo, y sin exigir Gmail
- Las 4 opciones exigían Gmail (`GMAIL_RE`), así que las cuentas ya creadas no
  podían recuperarse: de las 17 bnet que existen, 16 NO son de Gmail. Ahora solo
  se valida que sea un correo (`EMAIL_RE`), en un único sitio antes del tipo.
  El REGISTRO sigue exigiendo Gmail: eso no se toca.
- «Contraseña» se pedía por nombre de usuario («15#1»); ahora por correo, como
  las otras tres. El campo del formulario deja de alternar texto/correo.

En el flujo de contraseña, el correo que se guarda en `passwordreset` se toma de
la BD y NO del tecleado: el verifier SRP6 de bnet se deriva del correo, así que
`resetPassword` necesita exactamente la misma forma (MAYÚSCULAS) o generaría un
verifier con el que no se podría entrar.

Quedan sin uso y se borran: `usesUsername`, la clave de error `invalidUsername` y
el texto `username`. `Recover.invalidEmail` decía «Introduce un correo de Gmail
válido» y pasa a ser genérico.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 19:05:26 +00:00
Inna 79520a57ff El login exige un correo, no un nombre de cuenta
Con Battle.net se entra con el correo, pero el campo aceptaba cualquier cosa: el
<form> lleva `noValidate`, así que el navegador no comprobaba su propio
type="email", y el servidor solo miraba que no estuviera vacío. Entrar con «15#1»
llegaba hasta `authenticate` y devolvía «Correo o contraseña incorrectos», que
manda a buscar el fallo donde no está.

Se valida en el servidor (autoritativo) y en el cliente (aviso inmediato), con un
mensaje propio: «Introduce tu correo electrónico, no el nombre de la cuenta.».

`EMAIL_RE` es a propósito permisiva —solo exige una @ con algo a cada lado, sin
punto en el dominio ni restricción a Gmail—: el registro solo admite Gmail HOY,
pero de las 17 cuentas Battle.net que existen, 16 NO son de Gmail (INNA@INNA.CL,
y hasta Q@Q sin dominio). Validar de más al entrar las habría dejado fuera a
todas. Comprobado contra los 14 correos reales: 0 bloqueadas, y rechaza 15#1,
17#1 y demás nombres de cuenta.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 18:57:25 +00:00
Inna c255724ebf Aceptar el correo en MAYÚSCULAS y mostrar las cuentas como WOW1, no 17#1
Dos fallos que se juntaban justo en «recuperar nombre de cuenta»:

- La validación de Gmail era sensible a mayúsculas (`/@gmail\.com$/` sin la `i`),
  así que NOVAWOW86@GMAIL.COM se rechazaba con invalidEmail. Y desde que los
  correos muestran la cuenta en MAYÚSCULAS, quien la copiara de ahí y la pegara
  no podía recuperar la cuenta, ni registrarse, ni cambiar el correo: la misma
  regex estaba repetida en register/change-email/recover. Ahora es una sola, con
  la `i`, en lib/bnet.ts (junto a normalizeEmail). El dominio de un correo NO
  distingue mayúsculas.

- Los correos enseñaban el username interno («17#1»), que no le dice nada a
  nadie, en vez del nombre visible («WOW1»). Afectaba a DOS plantillas: la de
  nombres de cuenta y la de la lista de tokens. La conversión existía, pero como
  helper local de my-account; sube a lib/bnet.ts como `gameAccountDisplayName`
  (el inverso de `makeGameAccountUsername`) y la aplican las propias plantillas,
  para que ningún sitio que las use pueda olvidarse.

Verificado: la regex acepta las 3 variantes de caja y sigue rechazando no-Gmail;
los dos correos renderizados muestran «Cuenta: WOW1 / WOW2».

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 18:53:09 +00:00
Inna 653f7f0a07 El botón de solicitar token deja de quedarse bloqueado en «Token enviado»
Tras un envío correcto se ponía `sent` y el botón quedaba desactivado para
siempre: no volvía a «Solicitar token» y, sobre todo, ya no podías pulsarlo para
ver el aviso de «cada 7 días», que es la respuesta que hay que ver. Había que
recargar la página para recuperarlo.

Ese bloqueo no venía del original: la versión anterior (isla React) tenía
`disabled={busy}` y nada más. Se vuelve a eso. La clave `sent` («Token enviado»)
queda sin uso y se borra de ES/EN.

Verificado en producción: dos POST seguidos responden el cooldown, en vez de que
el segundo fuera inalcanzable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 18:29:24 +00:00
Inna 61ca63a55f Arreglar cierre de sesión solo: <Link> prefetcheaba /log-out
Regresión del commit anterior. /log-out es un GET que destruye la sesión, y yo
lo enlacé con <Link>: el router de Next lo prefetchea al entrar en pantalla, así
que la cookie se borraba sin que nadie pulsara. La página se renderizaba en el
servidor con la sesión («¡Ya estás conectado!») y al recargar ya no había sesión.

Reproducido: una petición de prefetch (RSC: 1, Next-Router-Prefetch: 1) a
/es/log-out responde `set-cookie: nightspire_session=; Max-Age=0`.

Se enlaza con <a> plano, que es justo como lo hace SiteHeader. Queda comentado
en ambos sitios para que no vuelva a colarse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 18:27:02 +00:00
Inna c46cea2807 Log-in y crear cuenta: avisar si ya hay sesión en vez de ofrecer el formulario
Ninguna de las dos miraba la sesión, así que quien ya estaba conectado veía el
formulario de login o el de registro. Ahora, con `bnetId` en sesión (lo mismo que
da por buena la sesión en el resto del sitio), sale «¡Ya estás conectado!» y un
enlace a /log-out, como las plantillas del tema.

En crear cuenta el cuadro informativo se mantiene: sigue explicando cómo son las
cuentas aunque ya tengas sesión. Solo se sustituye el formulario.

Las dos pasan a `force-dynamic`: leen cookies y no se pueden prerenderizar.

Verificado en producción sellando una cookie de sesión real con iron-session:
con sesión salen los dos avisos y desaparece el formulario; sin sesión el
formulario sigue ahí y el aviso no se renderiza.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 18:18:23 +00:00
Inna 4f8f101ba8 Impedir cuentas Battle.net duplicadas al activar; citar el límite real de 8
`registerAccount` ya rechazaba un correo con cuenta bnet, pero `activateAccount`
insertaba SIN volver a comprobarlo, y entre registrarse y activar el correo puede
dejar de estar libre. Como `battlenet_accounts.email` no tiene índice único, la
BD tampoco lo frenaba: hay 3 filas TEST@TEST.COM de 2024 que lo demuestran.
Ahora se recomprueba antes del INSERT y se borra la activación, que ya no sirve.
Nuevo error `emailExists` (ES/EN) para no soltar un «enlace inválido» que despista.

MAX_GAME_ACCOUNTS sube a lib/bnet.ts y lo usa el correo de activación: el texto
prometía 10 cuentas mientras el código cortaba en 8, justo por estar el número
escrito a mano en los dos sitios. El límite del panel ya funcionaba (API + aviso
«Has alcanzado el máximo de cuentas»); solo se centraliza la constante.

Verificado en producción con una activación pendiente de un correo que ya tenía
bnet: devuelve emailExists, no crea la cuenta y el contador no sube.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 18:02:54 +00:00
Inna a78417e954 Activación de cuenta: recuperar el diseño del tema y decir QUÉ cuenta se activó
La página mostraba un genérico «¡Cuenta activada!» + enlace a iniciar sesión.
Se recupera la estructura de las plantillas del tema (activation_success.html /
activation_invalid.html): bienvenida, la cuenta en `yellow-info`, y el aviso de
enlace inválido en `red-info2` con la línea de contacto.

El original dejaba un hueco: renderizaba `{{ username }}` pero la vista hacía
`render(...)` SIN contexto, así que salía «La cuenta  ha sido activada». Aquí
`activateAccount` devuelve el email y sí se muestra. Va el email tal cual se
registró, no el normalizado: ese va en MAYÚSCULAS porque lo exige el SRP6 de
Battle.net y en pantalla quedaría como INNA@INNA.CL.

El nombre del servidor sale de `getRealmName()` (acore_auth.realmlist), como en
el resto de páginas, en vez de escribirlo a mano.

Verificado en producción: el caso inválido lo pinta el servidor y sale idéntico
al del tema; y activando una fila de prueba, el API devuelve {success, email} y
reutilizar el enlace o mandar un hash que no existe dan `invalidLink` (filas de
prueba borradas después).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 17:43:46 +00:00
Inna 8c85cf2dbe El enlace de activación vuelve a ser alfanumérico; avisar a qué correo se envió
Dos cosas que el port de Django cambió sin querer:

- El hash de activación se generaba con `randomBytes(16).toString('hex')`: 32
  caracteres, sí, pero solo `0-9a-f`. El original era `get_random_string(32)`,
  alfanumérico con mayúsculas y minúsculas (`?act=P4JHQDJey2jJDnEBnqUePI5O2qEFB1`).
- El aviso de cuenta creada era un genérico «Revisa tu correo», mientras que el
  original decía qué cuenta se creó y a qué dirección fue el enlace. Se recupera
  el formato de dos líneas (con id `create-response`, como el original).

El muestreo por rechazo del token de seguridad se sube a `lib/random-token.ts` y
lo comparten los dos generadores, que solo se diferencian en el alfabeto: letras
para el token que se teclea a mano, alfanumérico para el hash de la URL.

Verificado con 100.000 hashes: todos casan /^[A-Za-z0-9]{32}$/, salen los 62
caracteres y la desviación por carácter se queda en 1,3% (ruido).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 17:34:53 +00:00
Inna 97ccb8a179 El CSS del tema entra por el build en vez de un <link> desde public/
Antes: <link href="/ns-themes/.../nightspire-style.css?v=2"> en el <head>. Una
peticion aparte, sin minificar y con cache invalidada a mano con ?v=2.

Ahora: app/theme.css, importado al FINAL de globals.css. Next lo compila,
minifica y le pone hash de contenido; se sirve con cache immutable y hay una
hoja de estilo menos (4 -> 3).

⚠ El @import va el ULTIMO y no es cosmético: el tema se diseñó contra los
defaults del navegador y el preflight de Tailwind los pisa (box-sizing,
alturas). Antes ganaba la cascada por cargarse el último en el <head>; ahora
gana por ser el último import. Si sube de sitio, el diseño se rompe.

Las url() pasan a ABSOLUTAS (/ns-themes/ns-ryu/...). Los assets siguen en
public/, y así el compilador no intenta resolverlos: el @font-face declara
.eot/.otf/.ttf que NO existen (solo hay .woff) y con rutas relativas el build
fallaría.

Verificado: los 6 páginas dan exactamente el mismo alto que antes (8159, 1356,
2645, 1356, 2221, 2349 px) con la misma fuente y colores, y el input del login
sigue en box-sizing:content-box — que es justo donde el preflight rompía. Ojo:
el md5 de las capturas NO sirve para comparar, porque la cabecera lleva un vídeo
y cada captura pilla un fotograma distinto.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 16:20:06 +00:00
Inna fa7897c195 Prefijos del tema nw- y uw- -> ns- (carpetas, ficheros, clases y rutas)
Renombrado completo de los prefijos heredados: 19 carpetas, 11 ficheros y 318
referencias en 35 ficheros de código, más las clases y las rutas url() de dentro
del CSS del tema. Se usa git mv para conservar el historial.

También fuera del código, que un sed no ve:
- BD: votesite.image_url (4 filas) apuntaba a /nw-themes/...
- Ficheros con la marca vieja en el NOMBRE: novawow-maintenance.webp ->
  nightspire-maintenance.webp (lo usa la página de mantenimiento) y
  store_novawow_response.js.

⚠ Alias en Caddy /nw-themes/* -> /ns-themes/*: los correos ENVIADOS antes del
rebranding llevan esas rutas escritas y están en las bandejas de los usuarios.
Sin el alias, sus imágenes se romperían. Verificado que las rutas viejas siguen
sirviendo 200.

Nota: ns-js/ y ns-js-handlers/ son CÓDIGO MUERTO (manejadores jQuery del portal
Django, que ya se borró). Se midió en el navegador: la web no pide ni un solo JS
del tema. Se renombran igualmente por consistencia, pero son candidatos a
borrarse.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 15:50:43 +00:00
Inna 5362787a34 Cookies: declarar las reales de Next.js, quitar las inventadas
La página venía de ultimowow y declaraba 26 cookies que este sitio NO pone.
Empezando por PHPSESSID, que es de PHP: aquí no hay PHP. Además todo el bloque
de Cloudflare (cf_clearance, __cf_bm, _cfuvid, CF_VERIFIED_DEVICE_*,
_cf_logged_in, sparrow_id), Google Analytics (_ga, _gid, _gat), Facebook
(_fbp, _fbc, fr), Adobe (AMCV_*), Zaraz (zaraz-consent, cfz_*) y las de
preferencias del foro/visor 3D de ultimowow (comments_sort, temp_default_3dmodel).

Se comprobó en un navegador contra producción, no de memoria: las únicas cookies
son NEXT_LOCALE (idioma), ns_cookie_consent (consentimiento, 1 año) y
nightspire_session (sesión, httpOnly). Turnstile NO pone ninguna en nuestro
dominio, y el sitio NO está detrás de un proxy de Cloudflare (la IP es del
servidor y no llega cabecera cf-ray), así que ninguna cookie de Cloudflare
aplicaba. Tampoco hay Google Analytics ni píxeles: no aparecen en el código.

Terceros medidos en el navegador: challenges.cloudflare.com (Turnstile, solo en
formularios), wow.zamimg.com (iconos de Wowhead), cdnjs.cloudflare.com
(tipografías/iconos) y YouTube (solo si un creador tiene vídeo configurado).

40 claves de traducción eliminadas en ES y EN; 43 usadas = 43 definidas, sin
huérfanas.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 15:40:28 +00:00
Inna 6d974f7773 Legal: quitar 'Error 404' del bloque de contacto
Estaba escrito a mano en el JSX de privacy-policy, refund-policy y
terms-and-conditions, no en las traducciones, por eso no cayó con el
resto. Era el nombre de la empresa heredado de ultimowow, figurando
como responsable del servicio.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 15:29:51 +00:00
Inna 6f52d325a6 Rebranding: sitios de voto, nota legal, prefijo uw->ns y repo renombrado
Sitios de voto: los 4 apuntaban a las fichas de UltimoWoW en los rankings con
SUS ids (Gtop100 94649, pingUsername 91402, etc.). No era cosmética: cada voto
de nuestros jugadores subía a UltimoWoW en el ranking. Se cambian nombre e ids
por un 236588 de relleno, así el enlace deja de acreditar a otro servidor. Las
fichas reales de NightSpire están por crear; entonces habrá que poner sus ids.

Nota legal: decía "Error 404 es la empresa que representa el servicio", heredado
de ultimowow. Al renombrar la marca pasaba a afirmar que una empresa ajena
representa a NightSpire, lo cual es falso. Se quita la empresa en ES y EN.

Prefijo uw- (de UltimoWoW) -> ns-: 100 identificadores en 11 ficheros (clases
CSS, ids de formulario, eventos y la cookie de consentimiento, que pasa a
ns_cookie_consent; a los usuarios les reaparecerá el aviso una vez). Se comprobó
antes que el CSS del tema no define ninguna clase uw-, así que no rompe estilos.
btn-ns-form y ns-cookie-btn-customize se usan sin estar definidas, pero ya era
así antes del cambio: eran clases muertas.

Repo de Gitea renombrado Inna/NovaWoW -> Inna/NightSpire (era la marca vieja más
visible de /changelogs, que forma la URL de cada commit). Gitea redirige la URL
antigua con 301. Se actualizan el remoto y lib/changelog.ts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 15:28:29 +00:00
Inna 579396c34a Rebranding a NightSpire en toda la web
Marca, dominios y correos: UltimoWoW/Nova WoW -> NightSpire, cualquier URL de
esos dominios -> https://www.nightspire.gg y los correos -> consultas@nightspire.gg.
98 sustituciones en 16 ficheros de textos de usuario (messages/, app/, components/).

Assets: logo largo de cabecera y vídeo sustituidos por los de NightSpire (el logo
nuevo es 214x28, igual que el viejo, así que no hace falta tocar el CSS).

Redes: apuntan a las cuentas de NightSpire, tanto en el pie de la web como en el
de los correos (que seguían enlazando a las de NovaWoW desde el porte del diseño).

Cookie de sesión novawow_session -> nightspire_session. Cierra la sesión de todos
los conectados una vez; se asume ahora, recién hecho el cutover.

Ficheros del tema renombrados: novawow-style.css -> nightspire-style.css y
novawow-main-logo-transparent.webp -> nightspire-main-logo-transparent.webp, para
que la marca vieja no quede ni en las rutas.

El foro externo (foro.ultimowow.com) pasa a ser el nuestro: <Link> a /forum, que
respeta el idioma y no abre pestaña nueva.

NO se tocan y es a propósito:
- Los comentarios que nombran AzerothCore: describen el comportamiento REAL del
  core (límite de 12 ítems por correo, convención de baneos, SOAP...). Cambiarlos
  a NightSpire los volvería falsos. Además AzerothCore no aparece en la web: las
  14 menciones son todas comentarios de código.
- El regex de brandify() sigue buscando los nombres VIEJOS: son los que hay
  escritos en las noticias de la BD.
- lib/changelog.ts REPO='Inna/NovaWoW': es la ruta real del repo en Gitea.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 15:15:57 +00:00
Inna cccc70338d Quitar el prefijo home_ de las 41 tablas del portal
El prefijo venía del nombre de la app Django (`home`), jubilada y borrada hoy,
así que ya no significaba nada: home_stripelog -> stripelog. La BD se sigue
llamando django_wow por historia (renombrarla es otra operación).

Comprobado antes de tocar nada: 0 colisiones con nombres existentes, 0 palabras
reservadas (contrastado contra las 260 de MySQL), sin vistas ni triggers que
dependieran de ellas. El RENAME va en una sola sentencia porque así es atómico,
y MySQL reapunta solo las 4 claves ajenas entre estas tablas.

170 referencias actualizadas en 37 ficheros. Se dejó a propósito
sql/drop_home_securitytoken_authuser_fk.sql sin tocar: es una migración ya
aplicada y reescribirla sería falsear el historial.

De paso salió un fallo previo: prices.ts consultaba `home_restoreitemprice`,
una tabla que NO existe. El try/catch de priceFrom se comía el error y devolvía
siempre el precio por defecto, así que el precio de restaurar objetos nunca fue
configurable. Se deja documentado en el código; crear la tabla es otra decisión.

Verificado tras aplicar: 0 tablas home_, las 51 siguen ahí, y el conteo exacto
de filas cuadra con el volcado previo (store_item 3068, item_data 44873,
stripelog 35, store_order 26, noticia 50). La web responde 200 en todas las
rutas y la portada carga las noticias sin errores.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 14:49:19 +00:00
Inna 57dd48e308 fix: /expired-link, el botón descuadrado y el mensaje que no se veía
Dos cosas, y la segunda dejaba la página sin contenido:

- El botón "Solicitar nuevo cambio de correo" salía de 250x100 pegado a la
  derecha. `.back-to-account` es el botón de la CABECERA de una caja (250px de
  ancho, line-height 50, float right); suelto en el CUERPO el float lo pega a un
  lado, y el texto no cabe en 250px, se parte en dos líneas y cada una se lleva
  sus 50px. Se le deja el aspecto del tema pero ajustado a su texto: pasa a
  336x50 y centrado.
- El mensaje rojo ("el enlace ha expirado") NO SE VEÍA: el tema deja
  `.alert-message` con display:none porque es el hueco que rellena y revela el
  JS, y aquí el aviso es estático. Se pone display:block a mano, igual que hacen
  todos los formularios del proyecto. Comprobado que al original le pasa lo
  mismo: su página de enlace caducado enseña el título y el botón, y la
  explicación queda invisible.

Medido en el navegador: botón 336x50, centrado, sin desbordar, y el mensaje
visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 13:14:47 +00:00
Inna c4f951561f web: páginas de enlace caducado y mantenimiento (las que faltaban vs Django)
Réplica de los partials `expired_link.html` y `maintenance.html`, con las clases
del tema. Con esto web-next ya tiene todo lo que tiene Django: las demás rutas
que faltaban eran los *-success/*-cancel por servicio, que aquí sustituye el
/service-success unificado, y novawow-realm/players, que los sirve la ruta
dinámica [realm].

Dos erratas del original que NO se copian:
- "Enlace Expirado" enseñaba un «No hay códigos de promoción disponibles» que es
  de otra página.
- Su botón "Solicitar nuevo cambio de correo" enlazaba a `expired-link`, o sea a
  sí misma. Aquí va a /change-email, que es lo que pretendía.

Verificado: /es/ y /en/ de las dos dan 200 (Django también), la imagen de
mantenimiento carga y cabe (437x371, sin desbordar), y el botón apunta a
/es/change-email.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 13:07:17 +00:00
Inna 25ced0c2f6 web: la página de creadores de contenido (el menú VIDEOS), que daba 404
SiteHeader enlazaba /content-creators y esa página no existía en Next: el menú
VIDEOS llevaba a un 404. Réplica del partial Django `partials/videos.html`, con
sus clases del tema (cc-box, cc-avatar, cc-name...). Lee el primer registro de
home_contentcreator, igual que la vista de Django.

La descripción es HTML de CKEditor (lo edita un admin), así que se sanea con
`cleanPostHtml`, la misma allowlist que los mensajes del foro. Comprobado
metiendo un <script>alert(1)</script> a propósito: no aparece en el HTML servido.

Dos arreglos sobre el diseño de Django, que copiaba tal cual:

- El título va SIEMPRE. Django lo metía dentro del `if content_creator`, así que
  sin datos la página quedaba con un <p> suelto, sin cabecera ni caja. Ahora sin
  creador sale el título de la sección y el aviso centrado en su box-content.
- Los enlaces de YouTube/Facebook se salían por abajo de la caja. El tema le da a
  `.cc-box` una altura FIJA de 172px contando con el content-box del navegador, y
  el border-box de Tailwind le restaba el padding y el borde: 24px menos de alto
  útil. Es el mismo caso que .item-box en la tienda. Medido: la caja pasa de 172
  a 196px de alto y los enlaces dejan de desbordar.

Verificado con un creador de prueba (borrado después): /es/ y /en/ dan 200, el
título, el avatar, el iframe y los enlaces caen dentro de la caja, y con la tabla
vacía sale el mismo texto que Django.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 13:03:32 +00:00
Inna 67faa1a266 send-gift: rediseño al original — es la tienda, pero regalando
El original lo dice en su propio texto ("los objetos disponibles son los mismos
de la tienda") y lo confirma su JS, que usa #store-list y .store-add-button: el
regalo NO tiene catálogo propio, es la tienda con el correo a otro personaje.
Nosotros teníamos una tabla aparte (home_item) con 8 objetos y precios en euros.

Ahora send-gift monta StoreBrowser en modo `gift`: mismo catálogo (3068 ítems,
PD/PV), mismo carrito con cantidades y las cuatro formas de pago. Antes NO tenía
Stripe: solo SumUp, PD y PV.

Dos pasos, como el original: primero #char-select-div (personaje de origen,
destino, confirmación y token) y solo al pulsar "Mostrar Regalos" aparece el
catálogo. Se borran SendGiftForm, /api/gift/checkout y lib/gift (ya no los usa
nadie); la tabla home_item se queda en la BD, sin usar.

Tres cosas que el usuario pidió y que estaban mal:
- El formulario va con `noValidate`: los avisos los damos nosotros en rojo
  (#show-gif-response), no el navegador.
- "Mostrar Regalos" ahora valida contra el SERVIDOR (que el personaje de origen
  sea tuyo, que el destino exista y que el token sea correcto). Antes elegías
  objetos para descubrir al final que el destino no existía.
- BUG REAL: el nombre del destino distinguía mayúsculas. `characters.name` es
  `utf8mb4_bin`, así que "innakh" NO encontraba a "Innakh" (comprobado: 0
  resultados; con COLLATE, 1). `findCharacterByName` busca sin distinguir y
  devuelve el nombre CANÓNICO, que es el que va al comando SOAP.

La validación vive en lib/gift-check y la usan las dos rutas: /api/gift/check (el
botón) y /api/gift/send, que revalida porque el cliente puede saltarse el paso 1.

También se quita del texto "El pago se realiza mediante SumUp": el original no lo
dice y además ya era falso con cuatro formas de pago.

Verificado: innakh / INNAKH / InNaKh resuelven a "Innakh"; token malo y destino
inexistente salen en rojo sin pasar al catálogo. Con 500 PD de saldo de prueba,
2 copias de un ítem de 200 pasan el cobro (deliveryFailed por el worldserver
caído, con su reembolso) y 3 dan insufficientPd: el servidor cobra por copias.
Datos de prueba borrados.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:45:01 +00:00
Inna 2c78489e4d fix: 47 errores de lint, y uno de ellos era un bug de verdad
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>
2026-07-15 12:03:25 +00:00
Inna e10f73b12a store: la ruta pasa a ser /store-<reino> y /store da 404
Como el original (/store-bennu). El slug sale de acore_auth.realmlist, así que
no puede ser una carpeta fija: lo resuelve la ruta dinámica `[realm]`, que ya
servía /<slug>-realm y /<slug>-players con este mismo patrón. Solo hubo que
añadirle el tipo `store`; el contenido de la página se mueve a
components/StorePage.tsx.

/store da 404 sin código extra: al no existir ya la carpeta estática `store`,
entra por `[realm]`, no resuelve y cae en el notFound() que ya estaba.

Enlaces actualizados: el de /my-account (AccountTools, que es cliente y recibe
el href ya calculado) y el cancelUrl de la pasarela, que devolvía a /store.

Verificado: /es/store-trinity y /en/store-trinity dan 200 y la tienda carga el
catálogo; /es/store y /en/store dan 404; un slug ajeno (/es/store-bennu) también
404; /es/trinity-realm y /es/trinity-players siguen bien; y el enlace de
/my-account apunta a /es/store-trinity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:48:33 +00:00
Inna 8e16ba8391 store: iguala el carrito y el cuadro de ítem al diseño original
Comparado contra el store original (ultimowow /es/store-bennu) con una sesión
real: markup, CSS y clases que aplica su power.js. Cinco causas distintas:

- Los nombres de ítem salían azules: el layout declaraba `const whTooltips`,
  que queda en el ámbito léxico del script y NO crea propiedad en `window`,
  que es de donde tooltips.js lee la config -> se ignoraba entera. Ahora
  `window.whTooltips`, con colorLinks+iconizeLinks (el original no define
  whTooltips y usa esos defaults; sus enlaces acaban con clases `icontinyl q4`).
  renameLinks sigue en false: el texto sale de nuestra BD, en español.
- tooltips.js solo recorre el DOM al cargar, así que los enlaces que monta
  React (hoja del árbol, carrito) no se teñían. Se pasan por
  $WowheadPower.refreshLinks(), igual que hace el original tras cada AJAX.
- globals.css redefinía .item-box/.item-name/.item-img-box/.max-left-table2,
  que el tema nw-ryu ya trae del original, y con más especificidad ganaba la
  cascada. El peor: un `border-collapse` que anulaba el `border-spacing: 0 8px`
  del tema, que es lo que separa las filas del carrito.
- Faltaban la caja "Carrito - <REINO>" y "Personaje seleccionado: <nombre>"
  (coloreado por clase), que el original sí tiene.
- El total de la columna "Cant" mostraba el número de líneas en vez de la suma
  de cantidades: un ítem que entrega un mazo de 20 debe contar 20, no 1.

Verificado con navegador contra un build de producción: el nombre del ítem sale
con clases `icontinyl q4` y color rgb(163,53,238), el mismo valor exacto que
devuelve el original.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 07:11:07 +00:00
Inna 5a903ef627 store: añade la sección plegable "Novedades" (changelog)
Replica el bloque Novedades del diseño original: fieldset plegable con el
changelog (Noviembre 2024) y sus objetos enlazados a wowhead (icono zamimg tiny
+ color por calidad). components/StoreNews.tsx (client, toggle con flecha
rotate/rotate2 del tema), incluido en la caja de info de /store. i18n
Store.newsTitle/newsIntro/newsDate (es/en).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 00:00:11 +00:00
Inna 026fbcb81c Implementa la tienda /store (catálogo BENNU: 750 categorías, 3068 ítems)
Porta la tienda de ítems/transfiguración del sistema antiguo: elegir personaje →
árbol de categorías (3 niveles) → carrito → enviar por correo. Se paga con el
saldo PD o PV de la cuenta (cada ítem lleva su precio y moneda).

- BD: home_store_category (árbol por code "1-1-1") + home_store_item
  (mismos item_id, nombres, iconos, precios y moneda que el original;
  2947 en PD, 121 en PV). Seed en sql/store_catalog.sql, extraído del HTML real.
- lib/store.ts: getStoreCatalog (árbol anidado), getStoreBalances, priceStoreCart
  (precios REALES de BD), purchaseStoreCart (cobro atómico PD+PV con FOR UPDATE y
  reembolso si el envío SOAP falla; `.send items` troceado a 12/correo).
- API: GET /api/store/catalog (tras elegir personaje) y POST /api/store/send.
- components/StoreBrowser.tsx: selector de personaje, árbol colapsable (ítems
  montados solo al abrir), carrito con totales PD/PV, enviar, modal de resultado.
- página /store con la info y el título "Tienda - REINO". Enlaces de ítems e
  iconos por wowhead/zamimg (con tooltip y locale). El enlace ya existía en
  AccountTools. i18n namespace Store (es/en). CSS del árbol/carrito/modal.

Verificado: catálogo E2E (750 cats/3068 ítems), cobro atómico + reembolso y
detección de saldo insuficiente (cuenta 15). SOAP real sin probar (worldserver
caído en este entorno).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 23:54:57 +00:00
Inna 033ebc6861 Fix: tooltips de wowhead en el idioma de la web (no siempre inglés)
El script de tooltips prioriza el atributo data-wowhead sobre el subdominio del
href, y su parámetro `domain` codifica idioma + versión: el 1.er segmento va al
mapa de locales ({es:6,...}) y el resto a la versión (wotlk=WRATH). Antes se
enviaba `domain=wotlk` (sin idioma) → locale 0 = inglés siempre.

Ahora `wowheadData(type,id,locale)` genera `domain=es.wotlk` en español (locale 6)
y `domain=wotlk` en inglés. Verificado: el endpoint de datos con locale 6 devuelve
los nombres en español.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 22:41:58 +00:00
Inna 7d1abab9f1 Enlaces de ítems por wowhead (locale + tooltips) en toda la web
Sustituye la base de datos de ítems del sistema antiguo (AoWoW en
wotlk.novawow/wotlk.ultimowow) y los iconos de mirrors por wowhead / zamimg,
con el idioma del tooltip según el locale de la web (es. / www.).

Sistema reutilizable para rutas actuales y futuras:
- lib/wowhead.ts: wowheadUrl/wowheadItemUrl/wowheadWotlkHome (subdominio por
  locale), wowheadIcon (zamimg) y wowheadData (atributo data-wowhead, rama WotLK).
- components/WowheadLink.tsx: enlace con tooltip en el locale actual.
- layout raíz: config whTooltips (solo tooltips) + script tooltips.js global;
  funciona con contenido añadido por React (carrito, búsquedas).

Aplicado en: send-gift (catálogo + carrito), restore-items, recruit
(RecruitPanel, enlace por item_id + icono zamimg), quest-character (misión/npc/
facción + iconos), y el enlace "WOTLK DB" de la cabecera.

Verificado en prod (:3001): /es -> es.wowhead.com, /en -> www.wowhead.com,
data-wowhead presente, iconos por zamimg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 22:34:26 +00:00
Inna 809eb756c8 send-gift: permitir pagar con PV (puntos de voto), más caro que PD
Solo en /send-gift se añade una 4ª forma de pago: PV (vote points, gratis por
votar). Cuesta más que PD para preservar su valor: coste_PV = coste_PD ×
VP_PRICE_FACTOR (=2, configurable en lib/pay-with-dpoints).

- lib/dpoints: getVPointsBalance, creditVPoints y spendVPoints (débito atómico
  del campo vp, con FOR UPDATE), espejo de las de PD.
- lib/pay-with-dpoints: VP_PRICE_FACTOR, vpointsCost() y payServiceWithVPoints()
  (descuenta PV, ejecuta el envío y reembolsa si falla).
- gift/checkout: rama provider='vp' (además de 'pd').
- service-success: la entrega inmediata cubre 'pd' y 'vp'.
- PaymentMethodSelect: opción VP opcional (solo si el form pasa vpBalance);
  muestra coste en PV, insuficiencia y el saldo VP.
- SendGiftForm + página: pasan el saldo VP.
- i18n Pay: vp, vpCost, insufficientVp, balanceVp, errors.insufficientVp (es/en).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 22:18:14 +00:00
Inna 0244d0f8ef rename-guild: el texto informativo refleja las 3 formas de pago
El párrafo "Requiere 1000 PD" era estático y contradecía el nuevo selector.
Ahora indica el coste equivalente (1000 PD o 10 €) y que se puede elegir la
forma de pago (PD, tarjeta con Stripe o SumUp), que ya se muestra en el
formulario.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 22:01:56 +00:00
Inna 20ee5a83c8 rename-guild: permitir pagar con PD, Stripe o SumUp
Renombrar hermandad ya no es solo PD: ahora ofrece el mismo selector de
forma de pago que el resto de servicios (saldo PD / tarjeta Stripe / tarjeta
SumUp). El coste es equivalente: 1000 PD = 10 €.

- lib/guild: se separa la validación (checkGuildRenameEligibility) del
  renombrado por SOAP (renameGuildBySoap); nuevo GUILD_RENAME_EUR (10 €).
- paid-services: entrada 'rename-guild' para que la reconciliación/webhook
  entregue el servicio tras el pago con tarjeta.
- nueva ruta /api/guild/rename/checkout con las 3 pasarelas (valida
  elegibilidad antes de cobrar); se elimina la antigua /api/guild/rename.
- RenameGuildForm usa PaymentMethodSelect y redirige a service-success.
- i18n: Paid.rename-guild (title/success) para la página de éxito.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 21:56:44 +00:00
Inna 1b0920d11f Permitir pagar los servicios con PD, Stripe o SumUp (elección)
Los 9 servicios con precio en euros (rename, customize, change-race,
change-faction, level-up, gold, transfer, restore-item, send-gift) ahora
ofrecen un selector de forma de pago con las 3 opciones: saldo PD, tarjeta
(Stripe) o tarjeta (SumUp).

- lib/dpoints: spendDPoints() descuenta PD de forma atómica (FOR UPDATE).
- lib/pay-with-dpoints: paga con saldo PD, ejecuta la acción al momento y
  reembolsa si la ejecución falla; 100 PD = 1 €.
- rutas checkout (character/[service] y gift): rama provider='pd' que valida
  saldo, descuenta, ejecuta fulfill y devuelve la URL de éxito (sin pasarela).
- service-success: rama provider='pd' (la entrega ya se hizo en el checkout).
- PaymentMethodSelect: componente compartido con coste por método y saldo;
  desactiva PD si no hay saldo suficiente.
- Formularios (PaidServiceForm, Gold, Transfer, RestoreItems, SendGift) y sus
  páginas pasan el saldo PD y envían el método elegido + locale.
- i18n: namespace Pay (es/en); añadidas claves Paid.restore-item que faltaban.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 21:44:31 +00:00
Inna 942fe2e397 Fix captcha: resetear Turnstile tras un fallo (token de un solo uso)
Al fallar (p.ej. contraseña incorrecta) el token de Turnstile ya se consumió en el
servidor; el reintento reenviaba el mismo token y daba "captcha fallida" aunque
estuviera resuelto. Se resetea el widget (setCaptcha('') + captchaKey++ con
<Turnstile key={captchaKey}>, mismo patrón que recover/restore/quest) en login,
create-account y trade-points (venta y canje). El check de código no usa captcha,
no se resetea ahí.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 21:17:37 +00:00
Inna 20e1d5a6b5 Renombrar /login -> /log-in en toda la web (/login da 404)
Mueve la página de login a /log-in y actualiza todas las referencias (enlace
CONECTAR del header y los redirect de auth de ~45 páginas). /login ahora da 404.
La API /api/auth/login no se toca (solo la ruta de página).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 21:03:31 +00:00
Inna 14125dbd72 Formularios: noValidate en todos para usar red-response del sitio
Desactiva la validación nativa del navegador ("Rellene este campo", formato de
email) en el resto de formularios (recover, reset-password, trade-points,
rename-guild, transfer-dp, gold, promo, transfer, quest, send-gift, 2FA, restore).
Los errores se muestran como red-form-response (por validación en cliente o del
servidor), consistente con login y create-account. Botones ya gateados por campos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:57:39 +00:00
Inna 071b1d5f0e login: noValidate para usar red-response en vez del aviso nativo
El navegador mostraba "Rellene este campo" (required) en lugar del error del sitio.
noValidate en el form deja que la validación en cliente ya existente muestre el
red-form-response (missingFields).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:55:14 +00:00
Inna 8ca3975cac create-account: desactivar validación nativa (noValidate) para usar red-response
El navegador mostraba su tooltip nativo "Rellene este campo" en los inputs required
en vez del mensaje de error del sitio. Con noValidate en el form, la validación en
cliente (clientError) muestra el red-form-response también para campos vacíos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:52:12 +00:00
Inna c1f865dfa5 create-account: validación en cliente con red-response inmediato
Antes los errores (contraseñas/correos que no coinciden, campos vacíos) solo se
mostraban en rojo tras el envío al servidor. Ahora se validan en el cliente y se
muestran al instante como red-form-response, sin llamada al servidor. Nueva clave
emailMismatch. El servidor sigue haciendo la validación final (Gmail, existe, etc.).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:50:30 +00:00
Inna 5d0d2bb089 Renombrar /register -> /create-account + rediseño (Battle.net)
Mueve la página de registro a /create-account (los enlaces del header y del login
apuntan ahí; /register ahora da 404 con el 404 del sitio). Rediseño según el
diseño aportado, adaptado a Battle.net (cuenta por email, sin usuario): caja de
info bnet (tipo bnet, reglas de contraseña/Gmail, activación, login por correo),
ojos para mostrar/ocultar contraseña, checkbox de "no accedo desde EEUU" +
términos (con enlaces), ambos requeridos para habilitar el botón, y bloqueo de
pegar en confirmar contraseña/correo. i18n ES/EN. No incluye FingerprintJS
(antiabuso por huella de dispositivo, requiere backend; la protección es Turnstile
+ Gmail + activación).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:44:15 +00:00
Inna 8a5ce82afa Rango de cuenta por TOTAL DONADO (Stripe + SumUp), no por saldo
El nivel/rango usaba el saldo de PD actual, que baja al gastar. Ahora se basa en
el total donado = suma de pagos confirmados (Stripe mode test/live + SumUp),
convertidos a PD (amount * 100). Cuenta todas las pasarelas y donaciones mixtas y
no baja al gastar PD. Se usa max(donatedPD, saldo) para no rebajar el rango a
nadie (p.ej. PD de promo). getAccountDashboard expone donatedPD. Verificado:
cuenta 15 con €43 en pagos -> Nivel 11 aunque el saldo sea 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:25:54 +00:00
Inna 2e9106f0bf Ficha de personaje: zona/raza/clase multiidioma (ES/EN)
Añade mapa de zonas en inglés (lib/data/zone-names-en.json, 9675 zonas de
FusionCMS wow_zones.php; cobertura 100% de los ids del mapa ES) y nombres de
raza/clase EN (wow_constants.php). getZoneName/getRaceName/getClassName aceptan
locale (EN cae a ES si falta). getAccountDashboard(session, locale) lo propaga y
my-account pasa el idioma. El dinero (oro/plata/cobre) usa iconos, universal.
Verificado: Innadin -> Human/Paladin/Stormwind City en /en.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 20:11:30 +00:00
Inna 794021f9a3 changelogs: fecha por idioma (es-ES / en-US)
formatFecha usaba es-ES fijo; ahora recibe el locale y formatea en en-US para
inglés. /en/changelogs muestra "July 14, 2026 at 07:55 PM"; /es/ en español.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 19:58:31 +00:00
Inna cc6c76859c Foro multiidioma: nombres/descripciones de categorías y foros (ES/EN)
forum_categories.name_en y forums.name_en/description_en (sql/add_forum_en.sql,
aplicado en prod y traducidos los existentes: Comunidad/Anuncios/Discusión general).
getForumIndex(locale) y getForum(id, locale) usan EN si existe, si no caen al
español. Las páginas del foro pasan el locale. Labels (Temas/Mensajes) ya estaban
traducidos. Verificado: /en/forum en inglés, /es/forum en español.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 19:50:20 +00:00
Inna 1d2aae5df2 ban-history: alcance y estado multiidioma
getSanctions devuelve claves (scopeKey account/battlenet/character + scopeName;
statusKey active/activePermanent/expired) en vez de español. La página traduce
alcance ("Character: {name}") y estado; "Permanente" ya usaba clave. Motivo y
autor son datos del GM (no se traducen). Nuevas claves History.ban.scope/status.
Verificado con sanciones de prueba (todos los combos resuelven en es y en).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 19:44:09 +00:00
Inna 16ae9f0db8 security-history: acción y estado web multiidioma
La lib devolvía la acción ("Token de seguridad solicitado") y el estado del login
web ("Conexión exitosa"/"Contraseña incorrecta") en español. Ahora devuelve claves
(actionKey='tokenRequested'; statusKey='success'/'wrongPassword'/'other' + statusRaw)
y la página las traduce (History.security.action/webStatus). El coloreado rojo pasa
a depender de statusKey==='wrongPassword'. Verificado con datos reales.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 19:39:46 +00:00
Inna 19d3c43a76 trans-history: concepto multiidioma (helper de concepto compartido)
Extrae la lógica de concepto traducible a lib/tx-concept.ts (buildConcept +
purchasedPD + SERVICE_CONCEPT) y la reutilizan points-history y trans-history.
La columna Concepto de trans-history ahora se traduce (reusa History.points.concept.*)
en vez de mostrar el product_name en español. Corrige también la sombra de
variable en PlatformBox (map t -> tx). Verificado con la cuenta 15.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 19:35:18 +00:00
Inna b2e7ab1b88 points-history: concepto/método/estado multiidioma
Los valores de las columnas Concepto/Método/Estado venían en español desde la lib.
Ahora getPointsHistory devuelve CLAVES (conceptKey+args, method 'vote'/'promo',
status 'delivered'/'pending'/'credited') y la página las traduce. Concepto por
servicio ("Rename character: {char}", "Vote on {site}", "Code {code}", "{n} PD");
los pagos heredados sin servicio caen a conceptRaw. Claves nuevas en History.points.
Verificado con la cuenta 15: todas las combinaciones resuelven en es y en.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 19:29:20 +00:00