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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Selector de forma de pago en el carrito (Saldo PD/PV · Stripe · SumUp), como el
resto de servicios. Con tarjeta, el carrito se convierte a euros (100 PD = 1 €,
200 PV = 1 €), se guarda como pedido y se entrega tras confirmarse el pago.
- BD: tabla home_store_order (ref, cuenta, personaje, items, importe) para no
depender del límite de metadata de la pasarela (sql/home_store_order.sql).
- lib/store: storeEuroTotal, createStoreOrder, fulfillStoreOrder.
- paid-services: servicio 'store' (fulfill = enviar el pedido por order_ref);
la entrega la dispara service-success/reconciliación/webhook como los demás.
- /api/store/send: rama provider stripe/sumup (crea pedido + checkout, mínimo
1 €/0,50 €) además del pago con saldo.
- StoreBrowser: selector Saldo/Stripe/SumUp; con tarjeta redirige a la pasarela.
- i18n Store (payMethod/payBalance/payStripe/paySumUp/confirmCard/amountTooLow)
y Paid.store (title/success). Verificado: euros 200PD+28PV=2,14 €, pedido y
fulfill por order_ref (SOAP real pendiente, worldserver caído).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
La tabla de niveles mostraba solo PD ("Hasta 100 PD"); ahora añade el equivalente
en la moneda (100 PD = 1 €): "Hasta 100 PD (1 €)" / "Up to 100 PD (1 €)". Se
sustituyen los 14 mensajes range1..14 por 3 plantillas parametrizadas
(rangeFrom/rangeUpTo/rangeOver) con {pd}/{price}/{sym}; RANK_ROWS lleva el umbral
en PD y el tipo. Adaptado a la moneda (DP_CURRENCY).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Los formularios de crear categoría/foro aceptan nombre/descripción en inglés, y
se añade EDICIÓN en línea de categorías (name, name_en, order) y foros (name,
name_en, description, description_en). lib/admin-forum: createCategory/createForum
con EN + updateCategory/updateForum; rutas PUT en category/[id] y forum/[id].
Claves Admin nuevas (nombre/descripción EN). Verificado: listado con EN + update
round-trip; PUT protegido (403 sin admin).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
home_noticia gana columnas titulo_en/contenido_en (sql/add_news_en.sql). getNoticias(locale)
usa la versión EN si existe, si no cae al español (título y contenido independientes); la fecha
también se formatea por idioma. El panel de admin permite escribir la traducción al crear y EDITAR
noticias existentes (nuevo PUT /api/admin/news/[id], updateNews). Claves Admin nuevas (título/
contenido EN, editar/guardar). Probado: /en/ muestra EN, /es/ ES; PUT guardado (403 sin sesión).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Los <title> estáticos estaban en español fijo. Se migran 31 páginas a
generateMetadata reutilizando la clave de título ya traducida de cada página
(o del <h1>). Se traducen además los PageShell title hardcodeados de las 6
páginas CharServiceB y los títulos de download-client y changelogs (+ su
mensaje de error). tsc/build OK, paridad es/en, <title> cambia por idioma.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Se externaliza el texto español hardcodeado de ~55 archivos a next-intl y se añaden
traducciones al inglés, con paridad de claves es/en. Nuevos namespaces: History,
CharService, CharServiceB, Points, Legal, UI, Misc (+ altas en Common/Admin).
- Historiales (PD/PV, transacciones, sanciones, seguridad), servicios de personaje
(transfer, send-gift, quest, restore-*, change-*, customize, level-up, gold, rename),
PD/pagos (d-points, trade, promo, transfer-dp, rename-guild, DPointsTabs), páginas
legales (cookies, privacidad, términos, reembolsos, aviso legal, contacto), layout
(cabecera, footer, cookies, 2FA, descargas, jugadores, recluta) y páginas varias
(home, reino, ayuda, addons, 2falogin).
- Textos con markup inline via t.rich; interpolación con ICU.
- Componente <NoteLegend/> para la leyenda NOTA/NOTE compartida.
- payLabel/confirmText de los servicios de pago traducidos.
- Verificado: tsc OK, next build OK, todas las claves t() resuelven en es y en,
todos los t.rich casan etiquetas, páginas 200 en /es/ y /en/ sin claves crudas.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Envía ítems de la tienda por correo al personaje de un amigo, pagado por
SumUp. lib/gift.ts (catálogo por categoría, priceCart con precios reales de
BD, sendGiftByMail vía SOAP .send items troceado a 12/correo + anti-inyección);
servicio send-gift en PAID_SERVICES; ruta dedicada /api/gift/checkout (origen
propio, destino existe, token de seguridad, carrito); SendGiftForm + página con
el diseño; i18n Paid[send-gift]; seed_giftitems.sql (4 cat, 8 ítems).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- /change-race-character (3€), /change-faction-character (5€, valida condiciones:
sin hermandad/correo/subastas/capitán-arena y oro <= cap por nivel),
/level-up-character (10€), /gold-character (oro por correo SOAP). Cada uno borra
su ruta antigua y actualiza el link del panel.
- SumUp: hook precheck en PaidServiceConfig (condiciones cambio de facción);
columna home_stripelog.metadata para campos extra (gold_amount).
- /quest-character: rastreador de estado de misiones/cadenas (clase, Hijos de
Hodir, misión por ID) en personaje offline; cooldowns por categoría, Turnstile,
IDs de misión verificados en wotlkdb 3.3.5a. Tabla home_questsearchhistory.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- /rename-character y /customize-character: renombrar/personalizar pagados por SumUp
(getRenamePrice/getCustomizePrice). SumUp ahora es service-aware: columna
home_stripelog.service; createSumUpCheckout guarda service+character;
fulfillSumUpCheckout despacha a la acción del servicio (.char rename/customi) o,
sin service, acredita PD. [service]/checkout acepta provider:'sumup';
PaidServiceForm acepta provider+confirmText; service-success maneja el return SumUp.
Se eliminan las antiguas /rename y /customize (Stripe).
- /revive-character (comparte ReviveServiceContent); se elimina /revive.
- Renombres de ruta + todos sus enlaces:
/account -> /my-account, /recruit -> /recruit-a-friend, /unstuck -> /unstuck-character.
(/select-account y /api/account/* intactos.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- CharacterSelect: componente reutilizable que colorea las opciones por clase
(class="priest big-font"…) y el <select> con la clase seleccionada. Usado en
unstuck, revive, gold, transfer, trade-points, servicios de pago y recruit.
getGameCharacters ahora devuelve classCss.
- /unstuck (+revive): diseño del original (info + NOTA, prompt, opciones coloreadas,
botón "Desbloqueado"/"Revivido" con refresh).
- /vote-points: seed de los 4 sitios con logos y URLs (sql/seed_votesites.sql);
botón voted-button para sitios en cooldown; etiqueta "Nota:" separada.
- account-info.png (imagen nueva) y CSS de los paneles de cuenta: se añade
background-blend-mode: saturation y background-size en #account-settings y
.account-fieldset para que la imagen salga como el original.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- /security-token: diseño completo del original (info + advertencia + NOTA 7 días),
fecha en formato HH:MM:SS DD-MM-YYYY, botón "Token enviado", enlace a /recover.
- /recover: 4 opciones (contraseña por usuario, nombre de cuenta, token de seguridad
y enlace de activación por correo); recuperación de token nueva; Turnstile por envío.
- /vote-points: caja de info con las 7 secciones Q&A + panel "Sitios de votación" con
tarjetas inline-div (imagen, PV, último voto), botón refrescar; abre el sitio y acredita
PV al volver. lib/vote.ts + getVoteSitesForAccount.
- /rename-guild: renombra una hermandad de la que la cuenta es Maestro por 1000 PD +
token de seguridad; SOAP .guild rename con comillas; reembolsa los PD si SOAP falla.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- /transfer-d-points: transferir PD a la cuenta dueña de un personaje
(requiere token de seguridad; movimiento atómico con FOR UPDATE).
- /trade-points: comercio PD<->oro mediante códigos de 1 solo uso
(contraseña + token + Turnstile; oro al creador por SOAP; expira 1h;
lock de 5s tras canjear; rollback total si falla la entrega).
- /promo-code: canje de códigos por PD/PV (sensibles a mayúsculas/minúsculas,
usos limitados, 1 canje por cuenta).
- /admin/promo: panel para crear / editar / activar / desactivar / borrar
códigos de promoción.
- Tablas nuevas (sql/): home_tradecode, home_promocode, home_promoredemption.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Junto al «Nombre de la cuenta» hay un botón «+» que pide la contraseña y crea
una nueva cuenta de juego bajo la Battle.net. Ruta /api/account/add-game-account:
verifica la contraseña (authenticate contra battlenet_accounts), calcula el
siguiente battlenet_index (máx 8) y crea la fila `account` con SRP6 Grunt
derivado de la contraseña (gameMakeRegistration) — igual que la activación, así
la cuenta sirve para entrar al juego. Recarga completa al crear/cambiar.
GameAccountSwitcher siempre presente (select si >1, texto si 1) + «+».
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Si la Battle.net tiene más de una cuenta de juego, /account muestra un
desplegable «Cargar datos de la cuenta» (WOW1, WOW2…). Al cambiar, fija la
cuenta activa en la sesión vía /api/auth/select-account y refresca → toda la
página (datos, personajes, servicios, compras) opera sobre la cuenta elegida
(la sesión.accountId manda). Con una sola cuenta no aparece (como hasta ahora).
components/GameAccountSwitcher.tsx (client) + getGameAccounts en la page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Los fieldsets Datos básicos/Estado de la cuenta ahora replican el markup nuevo:
iconos Font Awesome por campo (account-ficon), .separate account-info,
«Nombre de la cuenta», correos enmascarados (maskEmail), PD | PV en una línea
(dp-color/vp-color), Token, «2FA: Desactivado» (fa-lock-open red-info), Cuenta
baneada con enlace «Consultar historial» (/ban-history), reclutada y amigos.
Se quita Créditos Battlepay. Clases account-ficon/account-info al tema; claves
i18n accountName/twofaLabel/twofaOff/banHistoryLink.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
accountTools en el orden del markup: cambiar contraseña, 2FA (email-icon,
label purple-info), transferir PD, comercio de PD, código promoción, token,
recluta, renombrar hermandad, votar, adquirir PD. «Cambiar correo» (que
tenemos y no está en la lista) al final; panel admin el último (solo admin).
Tool.labelClass para el morado del 2FA. Iconos nuevos del sprite: trade-pd-icon
(-299 -928) y color .purple-info (#b172ff) añadidos al tema. Claves i18n nuevas
(svc2FA/TransferDP/TradePD/Promo/RenameGuild/DPoints). Rutas nuevas → 404 aún.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- characterTools reordenado y completado según el markup del usuario (14 items):
...gold, Rastreador de misiones (/quest), transfer, Recuperar personaje
(/restore-character), Recuperar ítems (/restore-items), Tienda (/store),
Enviar regalo (/send-gift). Se retira battlepay del panel (queda en cabecera).
Claves i18n nuevas (svcQuest/RestoreChar/RestoreItems/StoreItems/SendGift).
- Sprite de iconos actualizado a account-icons.png (de uw-ryu) que incluye el
icono de regalo; CSS: .acc-icon → .png, send-gift-icon (-424 -553) y promo-icon
reubicado (-174 -928), verificado que las 25 posiciones restantes coinciden.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>