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>
El paso 1 de la guía de macOS enlazaba a insmac.org, un repositorio de software de
pago pirateado. Ahora apunta a la web oficial de CodeWeavers, que además tiene
prueba gratuita.
Se cambia solo el destino del enlace: el texto de macStep1 ya era neutro («Descarga
e instala CrossOver»), así que no hay que tocar es.json ni en.json.
Motivo, por orden de importancia: metíamos a nuestros propios jugadores en un sitio
de warez con el riesgo de malware que eso supone, y de paso era una infracción de
copyright en una web que quiere servir anuncios de AdSense.
Verificado en producción: el chunk servido contiene codeweavers.com/crossover y no
queda ninguna referencia a insmac en el repo ni en el bundle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
`oldEmailNotificationHtml` estaba portada del Django original pero NO la llamaba
nadie: `confirmNewEmail` cambiaba el correo de la cuenta y al antiguo no le
llegaba nada. Importa porque al entrar se usa el correo: quien secuestrara una
sesión podía cambiarlo y el dueño se quedaba sin cuenta en silencio, sin ningún
aviso en ninguna parte.
Se envía DESPUÉS de que el cambio haya cuajado (si el UPDATE falla, no hay nada
que avisar) y sin enlaces: solo informa, y dice a quién contactar. `sendMail`
nunca lanza (devuelve false), así que un fallo de correo no puede tumbar un
cambio ya hecho.
Verificado ejecutando confirmNewEmail() contra la BD con el SMTP interceptado y
cuentas de usar y tirar: 1 correo al antiguo (antes 0), la cuenta cambia, y las
filas de prueba quedan borradas.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Decían «Hola 15#1»: el username interno, que no le dice nada a nadie. Ahora
«Hola INNA@INNA.CL (WOW1)» — el correo, que es la cuenta con Battle.net, y la
cuenta de juego con su nombre visible. Si no se sabe cuál es, va el correo solo,
sin paréntesis vacío.
Afecta a las 3 plantillas del cambio de correo. En la que va al correo NUEVO se
saluda con `old_email`: el cambio aún no ha terminado, así que la cuenta se sigue
identificando con el correo viejo (hay que añadirlo al SELECT).
El original de Django también decía «Hola {{ username }}», así que esto es un
arreglo, no una copia.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Se admite cualquier dominio de correo. Quedaba en dos validaciones (registro y
cambio de correo) y en tres textos:
- Register.email «Correo electrónico de Gmail» -> «Correo electrónico»
- Register.emailRule «debe pertenecer a Gmail y con acceso al mismo» -> «debe ser
válido y tener acceso al mismo» (el correo de activación sigue siendo real, así
que se conserva el «con acceso»)
- ChangeEmail.info se cae la frase «El nuevo correo debe ser de Gmail»
`GMAIL_RE` se queda sin uso y desaparece: ahora `EMAIL_RE` es la ÚNICA validación
de correo del sitio (registro, cambio, recuperación y login), así que no pueden
volver a divergir como pasaba (recuperar exigía Gmail y dejaba fuera a 16 de las
17 cuentas existentes).
Verificado: pasan inna@inna.cl, hotmail, outlook, proton, nightspire.gg y
dominios con doble punto; siguen fuera 15#1, textos sin @, dobles arrobas y
espacios. En la /es/create-account que sirve producción ya no aparece «Gmail».
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
- 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>
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>
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>
- Token: el asunto era «Token de seguridad - NightSpire» a secas. El original
decía «Token de seguridad de la cuenta {username} - {NOMBRE_SERVIDOR}»; la
reescritura se dejó la cuenta por el camino. Ese correo sale de
`session.bnetEmail`, que ya va normalizado, así que las MAYÚSCULAS son gratis.
- Activación: asunto y cuerpo pasan a normalizar el correo, para que se vea igual
que en el del token. El DESTINATARIO va sin normalizar: es la dirección tal
cual la escribió el usuario.
El correo de activación se manda desde DOS sitios: `registerAccount` y
`recover.ts` (el reenvío del enlace, «no me llegó»). Se cambian los dos o saldría
distinto según por dónde lo pidieras. Lo delató el build: /api/auth/activate
cargaba un chunk y /api/auth/recover otro con la versión vieja.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
- Éxito: «Se ha enviado el Token de seguridad al correo de la cuenta.» (decía
«el token de seguridad a tu correo»).
- Cooldown: «Sólo puedes solicitar un nuevo Token de seguridad cada 7 días.»
(decía «Solo puedes solicitar un token cada 7 días»).
El flujo ya funcionaba: tras enviarlo el botón queda desactivado («Token
enviado»), así que el aviso rojo sale al recargar y volver a pulsar dentro de
los 7 días. Comprobado que MySQL y Node van ambos en UTC sin desfase, que es de
lo que depende el cálculo de los 7 días (NOW() al insertar, Date.now() al
comparar).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`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>
- El <h1> decía «Bienvenido» a secas; ahora lleva la cuenta detrás, como la
plantilla original (`Bienvenido, {{ username }}`), donde `username` era el
mismo correo que sale en «Cuenta:».
- «Para empezar a usarla, actívala pulsando el botón» -> «Para poder empezar a
usar tu cuenta, deberás activarla haciendo clic en el botón ACTIVAR LA CUENTA».
- La segunda línea de «Recuerda» pasa a hablar del panel de usuario en vez de
«hasta 10 cuentas de juego».
Verificado renderizando el correo y extrayendo su texto visible.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Salía literal «La cuenta {email} ha sido creada.». En ICU MessageFormat la
comilla simple es el carácter de escape, así que `'{email}'` no son unas
comillas alrededor del placeholder: le dicen a ICU que trate `{email}` como
texto literal. Por eso la segunda línea, sin comillas, sí interpolaba.
La comilla literal en ICU se escribe doblada: `''{email}''`.
Barridos los dos ficheros de mensajes (consumiendo antes los `''` para no dar
falsos positivos): no había ningún otro caso.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
El token se generaba con `randomBytes(4).toString('base64url').slice(0, 6)`,
portado tal cual del Django original (`secrets.token_urlsafe(4)[:6]`), y tenía
dos taras para un código que se lee y se teclea desde un correo:
- Metía `-` y `_`.
- 4 bytes son 32 bits, pero 6 caracteres base64 codifican 36: al último solo
le llegaban 2 bits reales, así que SIEMPRE terminaba en A, Q, g o w
(comprobado sobre 20.000 tokens: 4 valores distintos en esa posición).
Ahora son 6 letras A-Z/a-z uniformes, con muestreo por rechazo porque 256 no es
múltiplo de 52 y `byte % 52` favorecería a las primeras letras. Verificado con
300.000 tokens: todos casan /^[A-Za-z]{6}$/, las 52 letras aparecen en la última
posición y la desviación por letra se queda en el 1,4% (ruido).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
checkSecurityToken guardaba expires_at pero NO lo comprobaba: un token
caducado seguía siendo válido para regalos y servicios. Se comprueba en SQL
(`expires_at > NOW()`) y no en JS, para que los dos lados de la comparación
sean hora del servidor: expires_at se inserta como Date de JS y compararlo
contra Date.now() dependería de que Node y MySQL tuvieran la misma zona.
Además faltaban `errors.notAuthenticated` e `invalidRequest` en el namespace
Store (ES y EN): si se caducaba la sesión, send-gift decía «Ha ocurrido un
error» en vez de pedir que vuelvas a iniciar sesión — justo el mensaje que
hace pensar que el botón sigue roto.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
El botón «Mostrar regalos» parecía no hacer nada: setFormError se llamaba
bien, pero el tema deja `.alert-message` con display:none esperando que la
rellene y la revele el JS antiguo (jQuery), que ya no existe. La caja se
pintaba y no se veía. Se fuerza el display desde React, como el resto de
formularios.
De paso, `.modal-content` estaba definida en globals.css Y en theme.css.
Como theme.css se importa el último (a propósito, para ganarle al preflight
de Tailwind), ganaba él a igual especificidad y el bloque de globals.css era
código muerto: el modal de la tienda salía como hoja pegada al fondo con
borde gris en vez de tarjeta centrada dorada. Se acota a
`.modal-div .modal-content` para ganar por especificidad y no por orden.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
De los 11 css/js del tema solo se usa uno: ns-css/nightspire-style.css, que
carga layout.tsx. Los 10 .js no los pide nadie. Medido en un navegador sobre 10
rutas de producción, no deducido con grep: la web no solicita ni un solo JS del
tema.
Son los manejadores jQuery del portal Django, que se borró hoy; los sustituyeron
los componentes React.
Dos detalles que el grep no habría resuelto:
- ns-scripts.js parecía referenciado desde SiteHeader.tsx, pero la coincidencia
era un COMENTARIO que documenta de dónde salió el toggle móvil, no una carga.
Se ajusta el comentario, que si no citaría un fichero inexistente.
- power.js era la copia que UltimoWoW tenía de los tooltips de Wowhead, con
g_host = 'https://wotlk.novawow.com' (dominio muerto) escrito dentro. La web
usa el oficial de zamimg, que carga layout.tsx.
Verificado tras borrar: build limpio, 7 rutas sin errores 4xx ni de JS, el tema
se sigue aplicando y el menú móvil (lo que replicaba nwNavBar) despliega bien.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
El directorio del proyecto se renombró, así que las rutas absolutas de
sql/db2/*.py y la cabecera de sql/item_data.sql apuntaban a un sitio que ya no
existe.
Fuera del repo (no versionado, se anota aquí): el directorio pasó de
/root/NovaWoW a /root/NightSpire y las unidades systemd de novawow-next /
novawow-dpoints-reconcile a nightspire-*. De paso, nightspire-next.service ya no
declara After/Wants de novawow.service, que era el Django borrado y ya no
existía.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
El cutover se hizo hoy: Caddy manda www.nightspire.gg a :3001 (Next), así que
Django se quedó sin dominio y sin uso. Se comprobó antes de borrar que nada
dependía de él: ninguna referencia a :8001 en Caddy, el timer de reconciliación
llama a Next, DJANGO_API_BASE no lo leía ni una línea del código (se quita
también de la unidad de systemd), web-next/public es autónomo (736 ficheros
reales, 0 symlinks) y no hay ni una referencia a /static/. Con Django parado la
web siguió dando 200 en todas las rutas.
Se va: home/, novawow/, forum/, wotlk_db/, frontend/ (las islas React que Next
sustituyó), static/, staticfiles/, manage.py, requirements.txt, db.sqlite3 y el
Docker de Django.
Se quedan docs/ y sql/: no son código Django sino documentación y esquemas, y
sql/forum_schema.sql describe la BD acore_web que Next usa hoy.
La BASE DE DATOS no se toca. django_wow y sus 41 tablas home_* son ahora de
Next, que las consulta con SQL directo. El nombre se queda por historia.
README reescrito: describía cómo montar un Django que ya no existe (venv,
manage.py migrate, gunicorn, Docker).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Los correos usaban un shell inline distinto en cada remitente. Ahora salen
todos de una sola maqueta, la de security_token, portada de las plantillas de
Django. De las 7 de Django solo security_token y activation traían el diseño;
las otras 5 eran HTML soso.
Las plantillas de Django eran un volcado del DOM de Gmail. Al portarlas se
quitó lo que no era diseño: los enlaces iban envueltos en el rastreador de
Mailjet (079xk.mjt.lu), que además redirigía a ultimowow.com en vez de a
nosotros; había un píxel de apertura, un "Click on me" oculto y atributos que
mete Gmail al mostrar el correo. Las imágenes apuntaban al proxy de Gmail
(ci3.googleusercontent.com), no a nuestro /static/, así que el diseño se
rompía el día que Google dejara de servirlas.
Ninguna URL va a fuego: todas cuelgan de SITE_URL, así que cambiar de dominio
es tocar el .env. En un correo han de ser absolutas (se abre desde Gmail), así
que se construyen a partir de esa variable.
El logo nw-mail-logo.png daba 404: solo existía el .webp del tema, que es el de
UltimoWoW (león negro + letras "UW"). El auténtico de NovaWoW, un león de acero
sin letras, sobrevivía únicamente en la caché de Gmail y se recuperó de ahí
(600x320 RGBA, íntegro).
Verificado renderizando el original y el nuestro en un navegador y comparando:
cuadran pixel a pixel. Eso destapó dos fallos que se corrigen aquí: faltaba la
etiqueta <h1> de apertura, y el estilo de los párrafos de datos era el de la
letra pequeña del pie (11px/17px) en vez del real (14px/21px).
El activation de Django mandaba la contraseña en texto plano; aquí no se manda.
mail.ts: leía EMAIL_USE_SSL pero ignoraba EMAIL_USE_TLS, así que en el puerto
587 el STARTTLS era oportunista y, si el servidor no lo ofrecía, nodemailer
enviaba en claro. Ahora requireTLS lo hace obligatorio.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
Tienda · BNet inna@inna.cl · Cuenta 15#1 · Innadin · IP 213.94.48.99 · Grito de la sirena (45327) x2
La IP ya se capturaba, pero solo se guardaba en home_stripelog.acore_ip. OJO:
meterla en el concepto SÍ se la manda a la pasarela, cosa que antes no pasaba
(ni Stripe ni SumUp la recibían). Va a propósito, para identificar al pagador en
su panel si hay fraude o una devolución a mano.
Se omite si no se conoce y también el 0.0.0.0 de relleno, que no aporta nada.
Verificado contra checkouts reales de Stripe (test): IPv4 con 1 ítem = 100
chars; IPv6 (39 chars) con 1 ítem = 124; IPv6 con 30 ítems = 172, recortando los
ítems. Todos por debajo del límite de 200.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ponía "Compra en la tienda (1 objeto)", que no identifica nada. Ahora:
Tienda · BNet inna@inna.cl · Cuenta 15#1 · Innadin · Grito de la sirena (45327) x2
Cuenta Battle.net, cuenta de juego, personaje, y el nombre de cada ítem con su
id. Es lo único que Stripe/SumUp enseñan en su panel, así que es lo que hay para
identificar un pago cuando toca reclamar o devolver algo a mano.
Se recorta a 200 caracteres porque las dos pasarelas limitan el campo (~250) y
un carrito grande se pasaría. Se recortan solo los ÍTEMS ("+37 más"), nunca la
cabecera: quién ha pagado importa más que el listado, que además queda entero en
home_store_order. El recorte quita ítems hasta que quepa TODO con el sufijo
incluido; comprobarlo antes de añadir el "+N más" se pasaba igual (206 chars con
12 ítems, visto en un checkout real).
priceStoreCart trae ahora también el nombre del ítem, que no leía.
Verificado contra checkouts reales de Stripe (test) con 1, 12 y 40 ítems: 82,
167 y 168 caracteres.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
El original rotula esa cabecera "Item" (comprobado en su HTML: <th
class="width-50-td justified">Item</th>). El inglés ya estaba bien.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Investigadas las 750 categorías de la BD contra el HTML real del original. Los
ítems estaban perfectos (554 hojas, 3068 ítems, mismo contenido en cada una: no
faltaba ninguno). Todo lo roto era de categorías:
- 45 CATEGORÍAS FANTASMA, todas con nombre de código ("11-1", "23-10"). El seed
dedujo el árbol de los códigos de las hojas ("11-1-1" => padre "11-1"), pero
el original NO siempre pinta ese nivel: en 45 hojas el "-1-" del código no
corresponde a nada y cuelgan directas de su raíz. Ahora se lee el anidamiento
real (<ul>/<li>): 705 categorías (20 + 131 + 554) en vez de 750.
- El nombre de una categoría NO es texto plano, es HTML, y lo estábamos
aplanando: 141 nombres perdían su color y 40 cabeceras el suyo. Hay dos
mecanismos y los dos hacen falta: la clase en la CABECERA, que sustituye a
first-brown para colorear la categoría entera ("Brujo" en color de brujo), y
los <span class="no-toggle X"> dentro del nombre (clases y las etiquetas
[Nivel 232 a 245] / (DPS) en otro tono).
- 21 nombres TRUNCADOS en Tokens de Armadura: guardábamos "Guerrero" donde el
original dice "Guerrero, Sacerdote, Druida" (solo se quedó el primer span).
El HTML se trocea en el SERVIDOR (`parseCategoryName`) y al cliente le llegan
solo texto + nombre de clase, así que no hay HTML de la BD entrando en el DOM.
El texto sin color va suelto, NO envuelto en <span>: el tema tiene
`span { color: #fff }` y lo pondría blanco en vez de heredar el de la cabecera.
`name` pasa a VARCHAR(512): con el HTML, el más largo mide 198 y no cabía en 191.
El name_en se genera en el propio seed traduciendo solo el TEXTO y respetando
las etiquetas, así que sql/store_category_en.sql sobra y se borra.
gen_store_catalog.py regenera el seed entero (categorías del HTML, ítems de la
BD, que ya estaban verificados) y es reproducible byte a byte.
Verificado en el navegador contra el original: mismo árbol, 0 categorías con
nombre de código, "[Nivel 232 a 245]" en marrón tenue y los colores de clase
oficiales (Guerrero #C79C6E, Sacerdote blanco, Druida #FF7D0A). En inglés
también, conservando los colores.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Faltaba la otra mitad de 0be3dc5: con saldo ya se reembolsaba lo que no llegaba
a enviarse, pero con tarjeta el dinero lo tiene la pasarela y el cliente se
quedaba pagando de más. Ambas tienen API de reembolso parcial, así que ahora
`fulfill` puede pedir que se devuelva un importe y lo hace quien conoce la
pasarela (lib/fulfill para Stripe, lib/sumup para SumUp).
Para saber CUÁNTO devolver hay que saber qué costó cada entrada, así que el
pedido pasa a guardarse como `itemId:qty:precio:moneda`. Releer el precio del
catálogo al entregar no vale por dos razones: 114 item_id son ambiguos (el mismo
objeto se vende a 50 PV y a 100 PD), y hay que devolver lo que se COBRÓ, no lo
que valga el ítem el día de la entrega. Los pedidos con el formato viejo se
entregan igual, pero no se puede calcular su reembolso: se registra para hacerlo
a mano.
`fulfill` devuelve ahora `boolean | {ok, refundEur}`; los servicios que no
entregan a medias siguen devolviendo boolean y no se tocan.
Sobre SumUp, todo comprobado contra su API en sandbox y nada de esto está en
sitios obvios:
- El reembolso NO va por referencia de checkout sino por transacción.
- Hay que usar el endpoint de v1.0; el viejo `/v0.1/me/refund/{txn}` responde
409 a CUALQUIER reembolso parcial (el total sí funciona).
- v1.0 quiere el importe en CÉNTIMOS y ENTERO, al revés que el resto de la API
v0.1, que usa euros. Y mandar 0.10 en vez de 10 no da error: se trunca a 0,
SumUp lo lee como "sin importe" y DEVUELVE EL PAGO ENTERO. Casi me lo comí.
- Hay un mínimo por reembolso (20 cts en esta cuenta, la API lo dice en
`min_refundable_amount`): por debajo responde 400 y se registra para hacerlo
a mano.
El host sale de SUMUP_API_BASE (por defecto api.sumup.com) en vez de ir a fuego.
Verificado de punta a punta con pagos reales de prueba, parcheando el SOAP para
que solo saliera el primer correo:
- Stripe (sk_test): pagados 1,30 €, entregadas 12/13 -> reembolso de 0,10 €,
status succeeded en la API de Stripe.
- SumUp (sandbox): pagados 1,50 €, entregadas 12/15 -> evento REFUND 0.3
REFUNDED en la API de SumUp.
De paso: el concepto del cobro decía "1 objeto" al comprar 15 copias (contaba
líneas, no copias).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Un carrito de más de 12 entradas se envía en varios correos. Si fallaba el 3.º
de 9, los 2 primeros ya estaban en el buzón del jugador y aun así se le devolvía
el carrito ENTERO: se quedaba los objetos gratis. Con las cantidades por línea
(f232f0f) llegar ahí es fácil: 100 copias son 9 correos.
sendStoreItems devuelve ahora CUÁNTAS entradas entregó en vez de un booleano: un
correo enviado no se puede deshacer, así que quien llama necesita saber dónde se
cortó. Cada `.send items` es un correo, así que el troceo es atómico por correo:
un chunk sale entero o no sale. purchaseStoreCart reembolsa solo las entradas
que quedaron sin enviar (cada una lleva su precio y su moneda) y devuelve
`partialDelivery`, con un mensaje que dice la verdad: parte llegó al correo del
juego y solo se ha devuelto el resto. Si no sale ni un correo, sigue siendo el
`deliveryFailed` de siempre con la devolución completa.
En el pago con tarjeta no se puede hacer lo mismo (el dinero ya lo cobró la
pasarela): fulfillStoreOrder devuelve false si no salió todo. No puede duplicar
porque claimPaidCheckout reclama el pago una sola vez, pero por eso mismo
tampoco hay reintento y un envío a medias hay que rescatarlo a mano desde
home_store_order. Queda anotado en el código.
Verificado de punta a punta, no solo razonado: con un parche temporal del SOAP
que deja salir el 1.er correo y tumba el resto, 13 copias de un ítem de 10 PD
(130 PD, 12+1 entradas) dejan el saldo en 500 -> 380, o sea 500-130+10: se
devuelve SOLO la entrada que no salió y se cobran las 12 entregadas. El parche
se revirtió y producción se reconstruyó limpia.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
El carrito era un conjunto: añadir dos veces el mismo ítem no hacía nada y el
botón se quedaba en "Añadido". Ahora cada línea lleva sus copias, editables con
un <input number> en la columna Cant, y volver a pulsar Añadir suma una (mismo
patrón que el carrito de send-gift, que ya lo hacía así).
Ojo con los dos "cantidad" que conviven, que no son lo mismo:
`home_store_item.quantity` es el LOTE que entrega cada copia (Paño de lino = 20)
y `copies` es cuántas veces se compra la línea. Por eso la fila enseña "3 × 20"
y el total de la columna Cant sigue siendo unidades (60), como en el original.
Cada copia se manda al SOAP como una entrada propia (`2589:20 2589:20`), que es
justo lo que ya sabía trocear sendStoreItems (12 por correo) y parsear
fulfillStoreOrder: el formato del pedido de tarjeta no cambia.
El servidor no se fía del cliente: priceStoreCart recibe {id, copies}, valida las
copias (1..MAX_COPIES, como el MAX_QTY de send-gift), rechaza el carrito si algún
id no existe y recalcula los totales como precio*copias contra la BD.
Verificado contra la API con saldo de prueba, no solo en la interfaz: con 500 PD
y un ítem de 200, 2 copias (400) pasan el cobro y 3 (600) dan insufficientPd. Si
el servidor ignorase las copias ambas darían lo mismo, así que el corte exacto
entre 2 y 3 prueba que multiplica. Comprobado también que el reembolso del
deliveryFailed devuelve los 400 PD, y que copias=0 o 101 se rechazan.
El CSS del input va con `#cart-list` por especificidad, no por gusto: el tema
define `input[type=number] { width: 290px }` y se carga el último para ganar la
cascada, así que un `.store-copies` a secas perdía y el input salía de 290px
reventando la tabla (send-gift se libra porque usa estilo en línea).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
El carrito enseñaba PD/PV aunque eligieras tarjeta, y por defecto ya presuponía
saldo. Ahora se muestran SIEMPRE las dos monedas y se apaga la que no se va a
cobrar con el método elegido, así que no presupone nada ni se contradice.
Los euros por línea van con hasta 3 decimales a propósito: un ítem en PV con
precio impar cuesta medio céntimo (75 PV = 0,375 €), y redondear cada línea a 2
haría que las líneas no sumaran el total (0,38 + 0,38 = 0,76 contra un total de
0,75). El total sí va a céntimos: es el importe que se cobra de verdad. Y con
mínimo 2 decimales, que es dinero: "1,50 €", no "1,5 €". El formateo va por
Intl con el locale de la web, así que en español sale con coma; antes el
selector interpolaba el número crudo y decía "0.75 €" con punto.
Mínimos de las pasarelas: SumUp ya estaba en 1 € y Stripe estaba en 0,50 €,
ahora también 1 €. Estaban sueltos en la ruta; se centralizan en
`lib/store-pricing`, un módulo PURO que importan tanto la ruta como el
componente, para que el carrito enseñe exactamente lo que se valida y se cobra.
`storeEuroTotal` pasa a vivir ahí (una sola implementación) y lib/store lo
envuelve con un guard: si alguien cambia PD_PER_UNIT o VP_PRICE_FACTOR sin tocar
store-pricing, revienta al primer uso en vez de cobrar mal en silencio (esos
módulos tocan BD y no pueden llegar al bundle del cliente, de ahí la
duplicación, igual que en PaymentMethodSelect).
Por debajo del mínimo, la opción de tarjeta se desactiva y dice "Mínimo 1,00 €"
en vez de dejarte pulsar Enviar para que la API la rechace. El método efectivo
se deriva en el render (si el carrito baja del mínimo con tarjeta ya elegida,
cae al saldo) en vez de sincronizarlo con un setState en un efecto, que
provocaría renders en cascada.
Verificado en el navegador: con 0,375 € las dos tarjetas salen desactivadas y
elegido el saldo; con 1,50 € se habilitan. Las dos líneas de 0,375 € suman el
total de 0,75 €.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
El tooltip se inyecta en el <body>, así que le caen encima las reglas globales
del tema. La culpable era `th { height: 40px }` (global, viene tal cual del tema
original): el tooltip usa <th> para la columna de la derecha (fase, tipo de
arma, velocidad...), y cada una de esas filas se estiraba a 40px en vez de ~17.
Cuatro filas de esas = los ~100px que sobraban.
El store original no lo sufre porque su tooltip lo pinta otro script, con otro
markup: es un problema que aparece al usar el tooltips.js de wowhead de verdad.
Afectaba a TODAS las páginas con tooltips, no solo a la tienda.
Medido contra una página desnuda con solo tooltips.js (misma versión, mismo
ítem), que es la referencia de cómo debe verse: el tooltip pasa de 390px de alto
a 289, exactamente el de la referencia. Las tablas del tema no se tocan: la
regla va acotada a .wowhead-tooltip th, y el <th> del carrito sigue midiendo
40px.
De paso, el tooltip no tenía fondo y se leía la página a través de él. No era
cosa nuestra —pasa igual en la página desnuda—: universal.css solo le da layout
y colores de calidad, y el fondo lo pone el CSS de wowhead.com, que aquí no se
carga. Se le da el fondo y el borde de las cajas del tema.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>