i18n: traducir toda la app (ES + EN) — páginas y componentes con texto hardcodeado
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>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
// Lista de trackers común a todos los magnet (idéntica en el original).
|
||||
const TRACKERS =
|
||||
@@ -17,14 +18,15 @@ function magnetFor(lang: string): string {
|
||||
return `magnet:?xt=urn:btih:${BTIH[lang]}${TRACKERS}`
|
||||
}
|
||||
|
||||
const LANG_INFO: Record<string, string> = {
|
||||
wotlkeses: 'Idioma esES: Español de España.',
|
||||
wotlkesmx: 'Idioma esMX: Español de Latinoamérica.',
|
||||
wotlkenus: 'Idioma enUS: Inglés de EEUU.',
|
||||
wotlkengb: 'Idioma enGB: Inglés británico.',
|
||||
const LANG_INFO_KEY: Record<string, string> = {
|
||||
wotlkeses: 'clientDownload.langEses',
|
||||
wotlkesmx: 'clientDownload.langEsmx',
|
||||
wotlkenus: 'clientDownload.langEnus',
|
||||
wotlkengb: 'clientDownload.langEngb',
|
||||
}
|
||||
|
||||
export function ClientDownload() {
|
||||
const t = useTranslations('UI')
|
||||
const [lang, setLang] = useState('')
|
||||
const [os, setOs] = useState('')
|
||||
const [error, setError] = useState(false)
|
||||
@@ -45,45 +47,45 @@ export function ClientDownload() {
|
||||
<>
|
||||
<div className="box-content">
|
||||
<div className="title-box-content" id="torrent">
|
||||
<h2>Descarga por Torrent</h2>
|
||||
<h2>{t('clientDownload.torrentTitle')}</h2>
|
||||
</div>
|
||||
<div className="body-box-content justified">
|
||||
<p><span>Información</span></p>
|
||||
<p>- No requiere instalación.</p>
|
||||
<p>- El realmlist ya está configurado.</p>
|
||||
<p>- La descarga se gestiona a través de magnet link. Si ya conoces cómo realizar descargas de este tipo, los pasos a seguir son opcionales.</p>
|
||||
<p><span>{t('clientDownload.infoLabel')}</span></p>
|
||||
<p>{t('clientDownload.info1')}</p>
|
||||
<p>{t('clientDownload.info2')}</p>
|
||||
<p>{t('clientDownload.info3')}</p>
|
||||
<br />
|
||||
<p><span>Requisitos para la descarga</span></p>
|
||||
<p>Se necesitará un programa que gestione descargas por Torrent y un programa que gestione archivos .rar.</p>
|
||||
<p><span>{t('clientDownload.requirementsLabel')}</span></p>
|
||||
<p>{t('clientDownload.requirementsText')}</p>
|
||||
<ul>
|
||||
<li><a href="https://www.qbittorrent.org/download.php" target="_blank" rel="noopener noreferrer">qBittorrent</a></li>
|
||||
<li><a href="https://www.winrar.es/descargas" target="_blank" rel="noopener noreferrer">WinRAR</a></li>
|
||||
</ul>
|
||||
<p>Adicionalmente, necesitarás que esta página pueda abrir ventanas emergentes.</p>
|
||||
<p>{t('clientDownload.popupNote')}</p>
|
||||
<br />
|
||||
<p><span>Paso a paso:</span></p>
|
||||
<p>Opcional: Si no tienes Winrar, haz clic en <a href="https://www.winrar.es/descargas" target="_blank" rel="noopener noreferrer">WinRAR</a> e instala el programa.</p>
|
||||
<p>1) Haz clic en <a href="https://www.qbittorrent.org/download.php" target="_blank" rel="noopener noreferrer">qBittorrent</a>.</p>
|
||||
<p>2) Descarga la versión adecuada para tu sistema operativo.</p>
|
||||
<p>3) Ejecuta el archivo descargado y realiza la instalacion del programa.</p>
|
||||
<p>4) Ejecuta qBitTorrent.</p>
|
||||
<p>5) Ve a la parte final de esta página y escoge el idioma de tu preferencia.</p>
|
||||
<p>6) Debajo del idioma, escoge el sistema operativo.</p>
|
||||
<p>7) Haz clic en Descargar.</p>
|
||||
<p>8) En la ventana emergente, acepta Abrir qBitTorrent.</p>
|
||||
<p>9) Espera a que qBitTorrent cargue el archivo correspondiente en la ventana de Enlace magnet.</p>
|
||||
<p>10) Haz clic en Aceptar.</p>
|
||||
<p>11) En la ventana de qBitTorrent espera a que la descarga se haya completado.</p>
|
||||
<p>12) Ingresa en la carpeta de descargas de qBitTorrent (Por defecto Descargas).</p>
|
||||
<p>13) Haz clic derecho en el archivo .rar y usa la opción Extraer aquí.</p>
|
||||
<p>14) Ingresa en la carpeta extraída y allí encontrarás el ejecutable para comenzar a jugar.</p>
|
||||
<p>15) Conecta usando tu cuenta de la web, en el campo email no pongas tu email sino tu nombre de cuenta.</p>
|
||||
<p><span>{t('clientDownload.stepByStep')}</span></p>
|
||||
<p>{t.rich('clientDownload.stepOptional', { winrar: (c) => <a href="https://www.winrar.es/descargas" target="_blank" rel="noopener noreferrer">{c}</a> })}</p>
|
||||
<p>{t.rich('clientDownload.step1', { qbit: (c) => <a href="https://www.qbittorrent.org/download.php" target="_blank" rel="noopener noreferrer">{c}</a> })}</p>
|
||||
<p>{t('clientDownload.step2')}</p>
|
||||
<p>{t('clientDownload.step3')}</p>
|
||||
<p>{t('clientDownload.step4')}</p>
|
||||
<p>{t('clientDownload.step5')}</p>
|
||||
<p>{t('clientDownload.step6')}</p>
|
||||
<p>{t('clientDownload.step7')}</p>
|
||||
<p>{t('clientDownload.step8')}</p>
|
||||
<p>{t('clientDownload.step9')}</p>
|
||||
<p>{t('clientDownload.step10')}</p>
|
||||
<p>{t('clientDownload.step11')}</p>
|
||||
<p>{t('clientDownload.step12')}</p>
|
||||
<p>{t('clientDownload.step13')}</p>
|
||||
<p>{t('clientDownload.step14')}</p>
|
||||
<p>{t('clientDownload.step15')}</p>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<div className="centered">
|
||||
<form name="uw-client-dowload" id="uw-client-download" autoComplete="off" onSubmit={handleSubmit}>
|
||||
<p><span>Idioma</span></p>
|
||||
<p><span>{t('clientDownload.languageLabel')}</span></p>
|
||||
<br />
|
||||
<select
|
||||
name="game-language"
|
||||
@@ -92,19 +94,19 @@ export function ClientDownload() {
|
||||
value={lang}
|
||||
onChange={(e) => { setLang(e.target.value); setOs(''); }}
|
||||
>
|
||||
<option value="" disabled>Elegir idioma</option>
|
||||
<option value="wotlkeses">Cliente esES</option>
|
||||
<option value="wotlkesmx">Cliente esMX</option>
|
||||
<option value="wotlkenus">Cliente enUS</option>
|
||||
<option value="wotlkengb">Cliente enGB</option>
|
||||
<option value="" disabled>{t('clientDownload.chooseLanguage')}</option>
|
||||
<option value="wotlkeses">{t('clientDownload.clientEses')}</option>
|
||||
<option value="wotlkesmx">{t('clientDownload.clientEsmx')}</option>
|
||||
<option value="wotlkenus">{t('clientDownload.clientEnus')}</option>
|
||||
<option value="wotlkengb">{t('clientDownload.clientEngb')}</option>
|
||||
</select>
|
||||
|
||||
{lang && (
|
||||
<div id="select-op">
|
||||
<br />
|
||||
<p id="game-info">{LANG_INFO[lang] ?? ' '}</p>
|
||||
<p id="game-info">{lang ? t(LANG_INFO_KEY[lang]) :' '}</p>
|
||||
<br />
|
||||
<p><span>Sistema operativo</span></p>
|
||||
<p><span>{t('clientDownload.osLabel')}</span></p>
|
||||
<br />
|
||||
<select
|
||||
name="operating-system"
|
||||
@@ -113,19 +115,19 @@ export function ClientDownload() {
|
||||
value={os}
|
||||
onChange={(e) => setOs(e.target.value)}
|
||||
>
|
||||
<option value="" disabled>Elegir sistema operativo</option>
|
||||
<option value="windows">Windows</option>
|
||||
<option value="macosc">Mac OS Catalina</option>
|
||||
<option value="" disabled>{t('clientDownload.chooseOs')}</option>
|
||||
<option value="windows">{t('clientDownload.windows')}</option>
|
||||
<option value="macosc">{t('clientDownload.macCatalina')}</option>
|
||||
</select>
|
||||
<br />
|
||||
<button type="submit" className="g-download-button" id="download-c-button">DESCARGAR</button>
|
||||
<button type="submit" className="g-download-button" id="download-c-button">{t('clientDownload.downloadBtn')}</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
<br />
|
||||
<hr />
|
||||
<div id="error-message" className="red-form-response">
|
||||
{error ? 'Debes escoger un cliente y un sistema operativo.' : ''}
|
||||
{error ? t('clientDownload.errorChoose') : ''}
|
||||
</div>
|
||||
<br />
|
||||
</div>
|
||||
@@ -134,24 +136,24 @@ export function ClientDownload() {
|
||||
{os === 'macosc' && (
|
||||
<div id="op-info" className="box-content">
|
||||
<div className="title-box-content">
|
||||
<h2>Guía para ejecutar el cliente en mac OS Catalina</h2>
|
||||
<h2>{t('clientDownload.macGuideTitle')}</h2>
|
||||
</div>
|
||||
<div className="body-box-content">
|
||||
<p>macOS Catalina ya no soporta aplicaciones que trabajan en 32bits, y el cliente se ejecuta en 32bits.</p>
|
||||
<p>Siguiendo los pasos a continuación podrás disfrutar UltimoWoW sin problemas en macOS Catalina.</p>
|
||||
<p>{t('clientDownload.mac1')}</p>
|
||||
<p>{t('clientDownload.mac2')}</p>
|
||||
<br />
|
||||
<span>Instrucciones</span>
|
||||
<p>1 - Descarga e instala <a href="https://insmac.org/macosx/3728-crossover-19.html" target="_blank" rel="noopener noreferrer">CrossOver</a>.</p>
|
||||
<p>2 - Una vez que tengas descargado el cliente, ejecuta CrossOver y haz clic en "Instalar una aplicación para Windows".</p>
|
||||
<p>3 - En el campo "Seleccionar una aplicación para instalar", busca la descarga, selecciónalo y haz clic en continuar. (No hagas clic en instalar aún).</p>
|
||||
<p>4 - Ve a la pestaña donde dice "Seleccionar instalador", haz clic en "Elegir archivo de instalador", y ahora busca WoW.exe y selecciónalo.</p>
|
||||
<p>5 - En la pestaña Botella, puedes seleccionar Windows 7 64bits.</p>
|
||||
<p>6 - Ve a la pestaña "Instalar y finalizar", haz clic en Instalar, se instalarán todas las fuentes, archivos y Visual Redistributables necesarios que WoW usa en un sistema operativo Windows estándar.</p>
|
||||
<p>7 - Te pedirá instalar y aceptar los FONTS, Microsoft Visual C ++, haz clic en Sí para cada solicitud que aparezca.</p>
|
||||
<p>8 - El cliente se iniciará automáticamente.</p>
|
||||
<p>9 - Una vez que termines y salgas del cliente, haz clic en "Listo" en la aplicación CrossOver.</p>
|
||||
<p>10 - La próxima vez que desees volver, abre la aplicación CrossOver, mira el panel izquierdo y selecciona en Botella el WoW que se creó anteriormente, y haz clic en RUN COMMAND, selecciona WoW.exe y haz clic en ¨Guardar comando como iniciador¨, luego haz clic en Abrir.</p>
|
||||
<p>11 - Finalmente, verás el ícono de WoW debajo de la pestaña Botella de WoW, ahora puedes arrastrar el lanzador de WoW al Dock, de esta manera, la próxima vez que quieras entrar, simplemente ejecuta WoW desde el Dock.</p>
|
||||
<span>{t('clientDownload.macInstructions')}</span>
|
||||
<p>{t.rich('clientDownload.macStep1', { crossover: (c) => <a href="https://insmac.org/macosx/3728-crossover-19.html" target="_blank" rel="noopener noreferrer">{c}</a> })}</p>
|
||||
<p>{t('clientDownload.macStep2')}</p>
|
||||
<p>{t('clientDownload.macStep3')}</p>
|
||||
<p>{t('clientDownload.macStep4')}</p>
|
||||
<p>{t('clientDownload.macStep5')}</p>
|
||||
<p>{t('clientDownload.macStep6')}</p>
|
||||
<p>{t('clientDownload.macStep7')}</p>
|
||||
<p>{t('clientDownload.macStep8')}</p>
|
||||
<p>{t('clientDownload.macStep9')}</p>
|
||||
<p>{t('clientDownload.macStep10')}</p>
|
||||
<p>{t('clientDownload.macStep11')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -93,6 +94,7 @@ function revokeConsent() {
|
||||
// Banner de consentimiento (fijo abajo, en todo el sitio).
|
||||
// ---------------------------------------------------------------------------
|
||||
export function CookieBanner() {
|
||||
const t = useTranslations('UI')
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [details, setDetails] = useState(false)
|
||||
// Estado de los checkboxes granulares (categorías no necesarias).
|
||||
@@ -129,45 +131,45 @@ export function CookieBanner() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="uw-cookie-content" role="dialog" aria-label="Consentimiento de cookies">
|
||||
<div className="uw-cookie-content" role="dialog" aria-label={t('cookie.dialogLabel')}>
|
||||
<div className="uw-cookie-text">
|
||||
<h3>Utilizamos cookies</h3>
|
||||
<p>Este sitio utiliza cookies propias y de terceros para garantizar el funcionamiento del sitio, analizar el tráfico y ofrecerte publicidad personalizada. Consulta nuestra política de cookies para más información.</p>
|
||||
<Link href="/cookies" className="uw-cookie-link">Política de Cookies</Link>
|
||||
<h3>{t('cookie.title')}</h3>
|
||||
<p>{t('cookie.description')}</p>
|
||||
<Link href="/cookies" className="uw-cookie-link">{t('cookie.policyLink')}</Link>
|
||||
|
||||
{details && (
|
||||
<div className="uw-cookie-details" style={{ display: 'block' }}>
|
||||
<div className="uw-cookie-category">
|
||||
<label>
|
||||
<input type="checkbox" checked disabled readOnly />
|
||||
<span className="uw-cookie-cat-name">Necesarias</span>
|
||||
<span className="uw-cookie-cat-name">{t('cookie.catNecessary')}</span>
|
||||
</label>
|
||||
<p>Esenciales para el funcionamiento del sitio. No se pueden desactivar.</p>
|
||||
<p>{t('cookie.descNecessary')}</p>
|
||||
</div>
|
||||
<div className="uw-cookie-category">
|
||||
<label>
|
||||
<input type="checkbox" id="uw-cookie-analytics" checked={!!prefs.analytics} onChange={(e) => setPrefs((s) => ({ ...s, analytics: e.target.checked }))} />
|
||||
<span className="uw-cookie-cat-name">Analíticas</span>
|
||||
<span className="uw-cookie-cat-name">{t('cookie.catAnalytics')}</span>
|
||||
</label>
|
||||
<p>Nos ayudan a entender cómo usas el sitio para mejorarlo.</p>
|
||||
<p>{t('cookie.descAnalytics')}</p>
|
||||
</div>
|
||||
<div className="uw-cookie-category">
|
||||
<label>
|
||||
<input type="checkbox" id="uw-cookie-marketing" checked={!!prefs.marketing} onChange={(e) => setPrefs((s) => ({ ...s, marketing: e.target.checked }))} />
|
||||
<span className="uw-cookie-cat-name">Marketing</span>
|
||||
<span className="uw-cookie-cat-name">{t('cookie.catMarketing')}</span>
|
||||
</label>
|
||||
<p>Se utilizan para mostrarte anuncios relevantes.</p>
|
||||
<p>{t('cookie.descMarketing')}</p>
|
||||
</div>
|
||||
<div className="uw-cookie-actions-detail">
|
||||
<button type="button" className="uw-cookie-btn uw-cookie-btn-save" data-action="save" onClick={savePrefs}>Guardar preferencias</button>
|
||||
<button type="button" className="uw-cookie-btn uw-cookie-btn-save" data-action="save" onClick={savePrefs}>{t('cookie.savePrefs')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="uw-cookie-actions">
|
||||
<button type="button" className="uw-cookie-btn uw-cookie-btn-reject" data-action="reject" onClick={() => { rejectAll(); setVisible(false) }}>Rechazar</button>
|
||||
<button type="button" className="uw-cookie-btn uw-cookie-btn-customize" data-action="customize" onClick={() => setDetails((v) => !v)}>Personalizar</button>
|
||||
<button type="button" className="uw-cookie-btn uw-cookie-btn-accept" data-action="accept" onClick={() => { acceptAll(); setVisible(false) }}>Aceptar todas</button>
|
||||
<button type="button" className="uw-cookie-btn uw-cookie-btn-reject" data-action="reject" onClick={() => { rejectAll(); setVisible(false) }}>{t('cookie.reject')}</button>
|
||||
<button type="button" className="uw-cookie-btn uw-cookie-btn-customize" data-action="customize" onClick={() => setDetails((v) => !v)}>{t('cookie.customize')}</button>
|
||||
<button type="button" className="uw-cookie-btn uw-cookie-btn-accept" data-action="accept" onClick={() => { acceptAll(); setVisible(false) }}>{t('cookie.acceptAll')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -177,16 +179,17 @@ export function CookieBanner() {
|
||||
// Botones «Cambiar / Retirar consentimiento» de la página /cookies.
|
||||
// ---------------------------------------------------------------------------
|
||||
export function CookieConsentButtons() {
|
||||
const t = useTranslations('UI')
|
||||
useEffect(() => {
|
||||
window.UWCookies = { showBanner, revokeConsent, acceptAll, rejectAll }
|
||||
}, [])
|
||||
return (
|
||||
<div className="uw-consent-buttons">
|
||||
<button type="button" onClick={showBanner} className="btn-uw btn-uw-form">
|
||||
Cambiar consentimiento
|
||||
{t('cookie.changeConsent')}
|
||||
</button>
|
||||
<button type="button" onClick={revokeConsent} className="btn-uw btn-uw-form btn-uw-secondary">
|
||||
Retirar consentimiento
|
||||
{t('cookie.revokeConsent')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
@@ -197,6 +200,7 @@ export function CookieConsentButtons() {
|
||||
// (o, si Zaraz no está, la preferencia guardada en la cookie uw_cookie_consent).
|
||||
// ---------------------------------------------------------------------------
|
||||
export function ConsentStatus() {
|
||||
const t = useTranslations('UI')
|
||||
const [state, setState] = useState<{ source: string; values: Consent | null }>({
|
||||
source: 'cookie',
|
||||
values: null,
|
||||
@@ -224,15 +228,20 @@ export function ConsentStatus() {
|
||||
|
||||
const zarazData = state.source === 'zaraz' && state.values
|
||||
const values = state.values
|
||||
const catLabel: Record<string, string> = {
|
||||
necessary: t('cookie.catNecessary'),
|
||||
analytics: t('cookie.catAnalytics'),
|
||||
marketing: t('cookie.catMarketing'),
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="uw-consent-status">
|
||||
<p className="uw-consent-source">
|
||||
{zarazData
|
||||
? 'Estado actual (Cloudflare Zaraz Consent API):'
|
||||
? t('cookie.statusZaraz')
|
||||
: values
|
||||
? 'Estado actual (preferencia guardada):'
|
||||
: 'Cloudflare Zaraz no está disponible en este dominio; aún no hay preferencia guardada.'}
|
||||
? t('cookie.statusSaved')
|
||||
: t('cookie.statusUnavailable')}
|
||||
</p>
|
||||
<ul>
|
||||
{zarazData ? (
|
||||
@@ -241,7 +250,7 @@ export function ConsentStatus() {
|
||||
<li key={purpose}>
|
||||
<span>{purpose}</span>
|
||||
<span className={granted ? 'uw-consent-on' : 'uw-consent-off'}>
|
||||
{granted ? 'Aceptada' : 'Rechazada'}
|
||||
{granted ? t('cookie.accepted') : t('cookie.rejected')}
|
||||
</span>
|
||||
</li>
|
||||
))
|
||||
@@ -251,9 +260,9 @@ export function ConsentStatus() {
|
||||
const on = cat.always || Boolean(values?.[cat.key])
|
||||
return (
|
||||
<li key={cat.key}>
|
||||
<span>{cat.label}</span>
|
||||
<span>{catLabel[cat.key] ?? cat.label}</span>
|
||||
<span className={cat.always ? 'uw-consent-always' : on ? 'uw-consent-on' : 'uw-consent-off'}>
|
||||
{cat.always ? 'Siempre activas' : values ? (on ? 'Aceptada' : 'Rechazada') : '—'}
|
||||
{cat.always ? t('cookie.alwaysOn') : values ? (on ? t('cookie.accepted') : t('cookie.rejected')) : '—'}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
|
||||
const LOGOS = '/nw-themes/nw-ryu/nw-images/nw-logos'
|
||||
@@ -8,24 +9,25 @@ const RANKS = '/nw-themes/nw-ryu/nw-images/nw-ranks'
|
||||
|
||||
type TabId = 'Info' | 'Stripe' | 'SumUp'
|
||||
|
||||
const RANK_ROWS = [
|
||||
['1_Newbie', 'Nivel 1', 'Desde 0 PD'],
|
||||
['2_Rookie', 'Nivel 2', 'Hasta 100 PD'],
|
||||
['3_Apprentice', 'Nivel 3', 'Hasta 200 PD'],
|
||||
['4_Explorer', 'Nivel 4', 'Hasta 300 PD'],
|
||||
['5_Contributor', 'Nivel 5', 'Hasta 400 PD'],
|
||||
['6_Enthusiast', 'Nivel 6', 'Hasta 500 PD'],
|
||||
['7_Collaborator', 'Nivel 7', 'Hasta 600 PD'],
|
||||
['8_Regular', 'Nivel 8', 'Hasta 700 PD'],
|
||||
['9_RisingStar', 'Nivel 9', 'Hasta 800 PD'],
|
||||
['10_Proficient', 'Nivel 10', 'Hasta 900 PD'],
|
||||
['11_Experienced', 'Nivel 11', 'Hasta 1000 PD'],
|
||||
['12_Mentor', 'Nivel 12', 'Hasta 5000 PD'],
|
||||
['13_Veteran', 'Nivel 13', 'Hasta 10000 PD'],
|
||||
['14_GrandMaster', 'Nivel 14', 'Más de 10000 PD'],
|
||||
const RANK_ROWS: [string, number, string][] = [
|
||||
['1_Newbie', 1, 'range1'],
|
||||
['2_Rookie', 2, 'range2'],
|
||||
['3_Apprentice', 3, 'range3'],
|
||||
['4_Explorer', 4, 'range4'],
|
||||
['5_Contributor', 5, 'range5'],
|
||||
['6_Enthusiast', 6, 'range6'],
|
||||
['7_Collaborator', 7, 'range7'],
|
||||
['8_Regular', 8, 'range8'],
|
||||
['9_RisingStar', 9, 'range9'],
|
||||
['10_Proficient', 10, 'range10'],
|
||||
['11_Experienced', 11, 'range11'],
|
||||
['12_Mentor', 12, 'range12'],
|
||||
['13_Veteran', 13, 'range13'],
|
||||
['14_GrandMaster', 14, 'range14'],
|
||||
]
|
||||
|
||||
export function DPointsTabs({ realm, currency }: { realm: string; currency: string }) {
|
||||
const t = useTranslations('Points')
|
||||
const [active, setActive] = useState<TabId>('Info')
|
||||
const [showRanks, setShowRanks] = useState(false)
|
||||
const sym = currency === 'usd' ? '$' : '€'
|
||||
@@ -38,10 +40,10 @@ export function DPointsTabs({ realm, currency }: { realm: string; currency: stri
|
||||
className={`dnt-button-2 tablink${active === 'Info' ? ' dnt-selected' : ''}`}
|
||||
onClick={() => setActive('Info')}
|
||||
>
|
||||
Información
|
||||
{t('tabs.info')}
|
||||
</button>
|
||||
<Link className="back-to-account" href="/my-account">Regresar a mi cuenta</Link>
|
||||
<Link className="back-to-account-responsive" href="/my-account">Regresar</Link>
|
||||
<Link className="back-to-account" href="/my-account">{t('tabs.backToAccount')}</Link>
|
||||
<Link className="back-to-account-responsive" href="/my-account">{t('tabs.back')}</Link>
|
||||
</div>
|
||||
<div className="dnt-sidebar dnt-methods">
|
||||
<button className={`dnt-button-2 tablink${active === 'Stripe' ? ' dnt-selected' : ''}`} onClick={() => setActive('Stripe')}>
|
||||
@@ -58,26 +60,26 @@ export function DPointsTabs({ realm, currency }: { realm: string; currency: stri
|
||||
{active === 'Info' && (
|
||||
<div className="box-content dnt-tab dnt-animate">
|
||||
<div className="body-box-content">
|
||||
<span>¿Qué son los PD?</span>
|
||||
<p>Ofrecemos a nuestros usuarios la oportunidad de obtener niveles que les brindan beneficios exclusivos dentro de nuestra comunidad en línea. Estos beneficios incluyen acceso a herramientas especiales, contenido exclusivo y mejoras significativas para su experiencia en nuestro sitio web. Al realizar una compra, nuestros usuarios reciben PD, una unidad de reconocimiento interna que refleja su compromiso y progreso en nuestra plataforma. Los PD les permiten alcanzar nuevos niveles y desbloquear recompensas exclusivas. Cabe destacar que estos PD son una forma única de valorar y premiar la participación activa de nuestros usuarios en nuestra comunidad.</p>
|
||||
<span>{t('tabs.whatArePd')}</span>
|
||||
<p>{t('tabs.whatArePdText')}</p>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<span>¿Cuáles son los niveles disponibles?</span>
|
||||
<span>{t('tabs.availableLevels')}</span>
|
||||
{!showRanks ? (
|
||||
<p id="rank-div-show" onClick={() => setShowRanks(true)}>Mostrar</p>
|
||||
<p id="rank-div-show" onClick={() => setShowRanks(true)}>{t('tabs.show')}</p>
|
||||
) : (
|
||||
<p id="rank-div-hide" style={{ display: 'block' }} onClick={() => setShowRanks(false)}>Ocultar</p>
|
||||
<p id="rank-div-hide" style={{ display: 'block' }} onClick={() => setShowRanks(false)}>{t('tabs.hide')}</p>
|
||||
)}
|
||||
{showRanks && (
|
||||
<div className="rank-div" id="rank-div" style={{ display: 'block' }}>
|
||||
<table className="rank-table">
|
||||
<tbody>
|
||||
{RANK_ROWS.map(([file, nivel, desde]) => (
|
||||
{RANK_ROWS.map(([file, level, rangeKey]) => (
|
||||
<tr key={file}>
|
||||
<td><img src={`${RANKS}/${file}.svg`} alt={nivel} title={nivel} width="24px" /></td>
|
||||
<td>{nivel}</td>
|
||||
<td className="second-brown">{desde}</td>
|
||||
<td><img src={`${RANKS}/${file}.svg`} alt={t('tabs.level', { n: level })} title={t('tabs.level', { n: level })} width="24px" /></td>
|
||||
<td>{t('tabs.level', { n: level })}</td>
|
||||
<td className="second-brown">{t(`tabs.${rangeKey}`)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -87,21 +89,21 @@ export function DPointsTabs({ realm, currency }: { realm: string; currency: stri
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<span>¿Cuáles son los métodos de compra de PD?</span>
|
||||
<p>Los métodos de compra disponibles actualmente son:</p>
|
||||
<p>- <img className="pay-inline-logo" src={`${LOGOS}/stripe-p-logo.svg`} alt="Stripe" /> Stripe (tarjeta de crédito/débito, para todos los países).</p>
|
||||
<p>- <img className="pay-inline-logo" src={`${LOGOS}/sumup-p-logo.svg`} alt="SumUp" /> SumUp (tarjeta de crédito/débito).</p>
|
||||
<span>{t('tabs.purchaseMethods')}</span>
|
||||
<p>{t('tabs.purchaseMethodsText')}</p>
|
||||
<p>- <img className="pay-inline-logo" src={`${LOGOS}/stripe-p-logo.svg`} alt="Stripe" /> {t('tabs.stripeMethod')}</p>
|
||||
<p>- <img className="pay-inline-logo" src={`${LOGOS}/sumup-p-logo.svg`} alt="SumUp" /> {t('tabs.sumupMethod')}</p>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<span>¿Cuántos PD se reciben?</span>
|
||||
<p>Por cada {sym}1 recibes 100 PD. La cantidad final se indica antes de confirmar el pago.</p>
|
||||
<span>{t('tabs.howManyPd')}</span>
|
||||
<p>{t('tabs.howManyPdText', { sym })}</p>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<span>¿Tienes dudas o problemas con una compra?</span>
|
||||
<p>No realices una compra hasta estar completamente seguro de que has leído esta información y sabes cómo es el proceso que vayas a escoger.</p>
|
||||
<p>Si a pesar de leer este instructivo aún tienes dudas, el equipo de {realm} podrá asesorarte en cualquier momento. Puedes colocar un ticket en el servidor, o contactarnos mediante nuestra plataforma de Discord.</p>
|
||||
<span>{t('tabs.doubts')}</span>
|
||||
<p>{t('tabs.doubtsText1')}</p>
|
||||
<p>{t('tabs.doubtsText2', { realm })}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -114,20 +116,22 @@ export function DPointsTabs({ realm, currency }: { realm: string; currency: stri
|
||||
}
|
||||
|
||||
/* --------------------------- Lógica de pago común --------------------------- */
|
||||
function checkoutError(error?: string): string {
|
||||
function checkoutErrorKey(error?: string): string {
|
||||
switch (error) {
|
||||
case 'notConfigured':
|
||||
return 'Este método de pago aún no está disponible. Por favor, utiliza otro método o inténtalo más tarde.'
|
||||
return 'notConfigured'
|
||||
case 'invalidAmount':
|
||||
return 'La cantidad debe ser un número entero entre 1 y 200.'
|
||||
return 'invalidAmount'
|
||||
case 'notAuthenticated':
|
||||
return 'Tu sesión ha expirado. Inicia sesión de nuevo.'
|
||||
return 'notAuthenticated'
|
||||
default:
|
||||
return 'No se pudo iniciar el pago. Inténtalo de nuevo.'
|
||||
return 'generic'
|
||||
}
|
||||
}
|
||||
|
||||
function usePayment(provider: 'stripe' | 'sumup') {
|
||||
const t = useTranslations('Points')
|
||||
const checkoutError = (error?: string) => t(`tabs.checkoutErrors.${checkoutErrorKey(error)}`)
|
||||
const [amount, setAmount] = useState('')
|
||||
const [accepted, setAccepted] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -136,7 +140,7 @@ function usePayment(provider: 'stripe' | 'sumup') {
|
||||
async function pay(e: React.FormEvent<HTMLFormElement>, sym: string) {
|
||||
e.preventDefault()
|
||||
if (!accepted) {
|
||||
alert('Debes aceptar los Términos y Condiciones y la Política de Reembolso')
|
||||
alert(t('tabs.mustAccept'))
|
||||
return
|
||||
}
|
||||
const n = Number(amount)
|
||||
@@ -144,7 +148,7 @@ function usePayment(provider: 'stripe' | 'sumup') {
|
||||
setErr(checkoutError('invalidAmount'))
|
||||
return
|
||||
}
|
||||
if (!window.confirm(`Vas a adquirir ${n * 100} PD por ${sym}${n}.\n¿Deseas continuar al pago?`)) return
|
||||
if (!window.confirm(t('tabs.confirmPurchase', { points: n * 100, sym, amount: n }))) return
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
try {
|
||||
@@ -180,26 +184,28 @@ function AmountForm({
|
||||
sym: string
|
||||
buttonLabel: string
|
||||
}) {
|
||||
const t = useTranslations('Points')
|
||||
const st = usePayment(provider)
|
||||
const points = Number(st.amount) > 0 ? Number(st.amount) * 100 : 0
|
||||
const currency = sym === '$' ? 'USD' : 'EUR'
|
||||
return (
|
||||
<>
|
||||
<p className="yellow-info centered">{sym}1 = 100 PD</p>
|
||||
<p className="centered second-brown">Valor mínimo: {sym}1 · Valor máximo por transacción: {sym}200</p>
|
||||
<p className="yellow-info centered">{t('tabs.rate', { sym })}</p>
|
||||
<p className="centered second-brown">{t('tabs.minMax', { sym })}</p>
|
||||
<br />
|
||||
<fieldset>
|
||||
<div className="separate2">
|
||||
<p className="centered red-info2">¡Importante!</p>
|
||||
<p className="centered">Introduce sólo valores enteros (sin decimales).</p>
|
||||
<p className="centered">Ejemplo: para 200 PD escribe 2.</p>
|
||||
<p className="centered red-info2">{t('tabs.important')}</p>
|
||||
<p className="centered">{t('tabs.integersOnly')}</p>
|
||||
<p className="centered">{t('tabs.example')}</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
<br />
|
||||
<p className="centered">Cantidad en {sym === '$' ? 'USD' : 'EUR'} a adquirir</p>
|
||||
<p className="centered">{t('tabs.amountLabel', { currency })}</p>
|
||||
<form className="centered" onSubmit={(e) => st.pay(e, sym)}>
|
||||
<input
|
||||
type="number"
|
||||
placeholder={`Cantidad en ${sym === '$' ? 'USD' : 'EUR'}`}
|
||||
placeholder={t('tabs.amountPlaceholder', { currency })}
|
||||
min="1"
|
||||
max="200"
|
||||
step="1"
|
||||
@@ -207,8 +213,8 @@ function AmountForm({
|
||||
onChange={(e) => st.setAmount(e.target.value)}
|
||||
required
|
||||
/>{' '}
|
||||
{sym === '$' ? 'USD' : 'EUR'}
|
||||
{points > 0 && <p className="centered green-info2">Recibirás {points} PD</p>}
|
||||
{currency}
|
||||
{points > 0 && <p className="centered green-info2">{t('tabs.willReceive', { points })}</p>}
|
||||
<br /><br />
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -218,11 +224,14 @@ function AmountForm({
|
||||
onChange={(e) => st.setAccepted(e.target.checked)}
|
||||
required
|
||||
/>
|
||||
<label htmlFor={`accept-${provider}`}> Acepto los <Link href="/terms-and-conditions" target="_blank">Términos y Condiciones</Link> y la <Link href="/refund-policy" target="_blank">Política de Reembolso</Link></label>
|
||||
<label htmlFor={`accept-${provider}`}>{t.rich('tabs.acceptTerms', {
|
||||
terms: (c) => <Link href="/terms-and-conditions" target="_blank">{c}</Link>,
|
||||
refund: (c) => <Link href="/refund-policy" target="_blank">{c}</Link>,
|
||||
})}</label>
|
||||
<br /><br />
|
||||
{st.err && <p className="red-form-response">{st.err}</p>}
|
||||
<button type="submit" className="dnt-button" disabled={!st.accepted || st.busy}>
|
||||
{st.busy ? 'Redirigiendo…' : buttonLabel}
|
||||
{st.busy ? t('tabs.redirecting') : buttonLabel}
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
@@ -231,28 +240,33 @@ function AmountForm({
|
||||
|
||||
/* ---------------------------------- Stripe ---------------------------------- */
|
||||
function StripeTab({ realm, sym }: { realm: string; sym: string }) {
|
||||
const t = useTranslations('Points')
|
||||
return (
|
||||
<div className="box-content dnt-tab dnt-animate">
|
||||
<div className="body-box-content">
|
||||
<span>¿Qué es Stripe?</span><img className="pp-icon" src={`${LOGOS}/stripe-logo.svg`} alt="Stripe" />
|
||||
<p>Stripe es una plataforma de pagos en línea segura que procesa tarjetas de crédito y débito de todo el mundo. Tus datos de pago se gestionan directamente en Stripe; {realm} nunca los almacena.<br />Más información sobre Stripe <a href="https://stripe.com/es" target="_blank" rel="noopener noreferrer">aquí</a>.</p>
|
||||
<span>{t('tabs.whatIsStripe')}</span><img className="pp-icon" src={`${LOGOS}/stripe-logo.svg`} alt="Stripe" />
|
||||
<p>{t.rich('tabs.stripeAboutText', {
|
||||
realm,
|
||||
br: () => <br />,
|
||||
link: (c) => <a href="https://stripe.com/es" target="_blank" rel="noopener noreferrer">{c}</a>,
|
||||
})}</p>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<span>¿Cómo adquirir PD a través de Stripe?</span>
|
||||
<p>Al ser un sistema auto-gestionado, las adquisiciones son automáticas y en cuestión de minutos tras completar el pago recibirás los PD en tu cuenta.</p>
|
||||
<span>{t('tabs.howStripe')}</span>
|
||||
<p>{t('tabs.stripeHowText')}</p>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<p>- Introduce en el campo de más abajo la cantidad que deseas adquirir.</p>
|
||||
<p>- Al presionar <span>ADQUIRIR PD</span>, serás redirigido a la pasarela segura de Stripe para completar el pago con tu tarjeta.</p>
|
||||
<p>- Una vez que Stripe valide tu pago, tendrás los PD en tu cuenta de forma automática.</p>
|
||||
<p>{t('tabs.step1')}</p>
|
||||
<p>{t.rich('tabs.stripeStep2', { s: (c) => <span>{c}</span> })}</p>
|
||||
<p>{t('tabs.stripeStep3')}</p>
|
||||
<br />
|
||||
<p>Recuerda que la cantidad sólo acepta números enteros y el mínimo posible es {sym}1.</p>
|
||||
<p>Ejemplos de valor incorrecto: <span className="red-info2">{sym}5,90 | {sym}22,30 | {sym}53,10</span></p>
|
||||
<p>Ejemplos de valor correcto: <span className="green-info2">{sym}6 | {sym}22 | {sym}53</span></p>
|
||||
<p>{t('tabs.reminderMin', { sym })}</p>
|
||||
<p>{t.rich('tabs.wrongExamples', { sym, s: (c) => <span className="red-info2">{c}</span> })}</p>
|
||||
<p>{t.rich('tabs.rightExamples', { sym, s: (c) => <span className="green-info2">{c}</span> })}</p>
|
||||
<br /><br /><br />
|
||||
<AmountForm provider="stripe" sym={sym} buttonLabel="ADQUIRIR PD" />
|
||||
<AmountForm provider="stripe" sym={sym} buttonLabel={t('tabs.acquireButton')} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -260,28 +274,33 @@ function StripeTab({ realm, sym }: { realm: string; sym: string }) {
|
||||
|
||||
/* ---------------------------------- SumUp ----------------------------------- */
|
||||
function SumUpTab({ realm, sym }: { realm: string; sym: string }) {
|
||||
const t = useTranslations('Points')
|
||||
return (
|
||||
<div className="box-content dnt-tab dnt-animate">
|
||||
<div className="body-box-content">
|
||||
<span>¿Qué es SumUp?</span><img className="pp-icon" src={`${LOGOS}/sumup-logo.svg`} alt="SumUp" />
|
||||
<p>SumUp es una plataforma de pagos europea que permite pagar de forma rápida y segura con tarjeta de crédito o débito. Tus datos de pago se gestionan directamente en SumUp; {realm} nunca los almacena.<br />Más información sobre SumUp <a href="https://sumup.com/es-es/" target="_blank" rel="noopener noreferrer">aquí</a>.</p>
|
||||
<span>{t('tabs.whatIsSumup')}</span><img className="pp-icon" src={`${LOGOS}/sumup-logo.svg`} alt="SumUp" />
|
||||
<p>{t.rich('tabs.sumupAboutText', {
|
||||
realm,
|
||||
br: () => <br />,
|
||||
link: (c) => <a href="https://sumup.com/es-es/" target="_blank" rel="noopener noreferrer">{c}</a>,
|
||||
})}</p>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<span>¿Cómo adquirir PD a través de SumUp?</span>
|
||||
<p>Al ser un sistema auto-gestionado, las adquisiciones son automáticas y tras completar el pago recibirás los PD en tu cuenta.</p>
|
||||
<span>{t('tabs.howSumup')}</span>
|
||||
<p>{t('tabs.sumupHowText')}</p>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<p>- Introduce en el campo de más abajo la cantidad que deseas adquirir.</p>
|
||||
<p>- Al presionar <span>ADQUIRIR PD</span>, serás redirigido a la pasarela segura de SumUp para completar el pago con tu tarjeta.</p>
|
||||
<p>- Una vez que SumUp valide tu pago, tendrás los PD en tu cuenta de forma automática.</p>
|
||||
<p>{t('tabs.step1')}</p>
|
||||
<p>{t.rich('tabs.sumupStep2', { s: (c) => <span>{c}</span> })}</p>
|
||||
<p>{t('tabs.sumupStep3')}</p>
|
||||
<br />
|
||||
<p>Recuerda que la cantidad sólo acepta números enteros y el mínimo posible es {sym}1.</p>
|
||||
<p>Ejemplos de valor incorrecto: <span className="red-info2">{sym}5,90 | {sym}22,30 | {sym}53,10</span></p>
|
||||
<p>Ejemplos de valor correcto: <span className="green-info2">{sym}6 | {sym}22 | {sym}53</span></p>
|
||||
<p>{t('tabs.reminderMin', { sym })}</p>
|
||||
<p>{t.rich('tabs.wrongExamples', { sym, s: (c) => <span className="red-info2">{c}</span> })}</p>
|
||||
<p>{t.rich('tabs.rightExamples', { sym, s: (c) => <span className="green-info2">{c}</span> })}</p>
|
||||
<br /><br /><br />
|
||||
<AmountForm provider="sumup" sym={sym} buttonLabel="ADQUIRIR PD" />
|
||||
<AmountForm provider="sumup" sym={sym} buttonLabel={t('tabs.acquireButton')} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { getRealmName } from '@/lib/realm'
|
||||
|
||||
/**
|
||||
@@ -5,40 +6,41 @@ import { getRealmName } from '@/lib/realm'
|
||||
* (enlaces legales + copyright + crédito de diseño) con las clases del tema real.
|
||||
*/
|
||||
export async function Footer() {
|
||||
const t = await getTranslations('UI')
|
||||
const year = new Date().getFullYear()
|
||||
const realmName = await getRealmName()
|
||||
|
||||
return (
|
||||
<footer>
|
||||
<p>
|
||||
<a href="/terms-and-conditions">Términos y Condiciones</a>
|
||||
<a href="/terms-and-conditions">{t('footer.terms')}</a>
|
||||
<span className="third-brown">
|
||||
<i className="fas fa-grip-lines-vertical"></i> {' '}
|
||||
</span>
|
||||
<a href="/privacy-policy">Política de Privacidad</a>
|
||||
<a href="/privacy-policy">{t('footer.privacy')}</a>
|
||||
<span className="third-brown">
|
||||
<i className="fas fa-grip-lines-vertical"></i> {' '}
|
||||
</span>
|
||||
<a href="/refund-policy">Política de Reembolso</a>
|
||||
<a href="/refund-policy">{t('footer.refund')}</a>
|
||||
<span className="third-brown">
|
||||
<i className="fas fa-grip-lines-vertical"></i> {' '}
|
||||
</span>
|
||||
<a href="/cookies">Declaración de Cookies</a>
|
||||
<a href="/cookies">{t('footer.cookies')}</a>
|
||||
<span className="third-brown">
|
||||
<i className="fas fa-grip-lines-vertical"></i> {' '}
|
||||
</span>
|
||||
<a href="/legal-notice">Aviso legal</a>
|
||||
<a href="/legal-notice">{t('footer.legal')}</a>
|
||||
<span className="third-brown">
|
||||
<i className="fas fa-grip-lines-vertical"></i> {' '}
|
||||
</span>
|
||||
<a href="/contact-us">Contáctanos</a>
|
||||
<a href="/contact-us">{t('footer.contact')}</a>
|
||||
</p>
|
||||
<br />
|
||||
<p className="third-brown">
|
||||
© Copyright {realmName}™ {year}. Todos los derechos reservados
|
||||
{t('footer.copyright', { realmName, year: String(year) })}
|
||||
</p>
|
||||
<p className="third-brown">
|
||||
Diseño: "{realmName}" Por Inna Hoover Brown <i className="fas fa-cat"></i>
|
||||
{t('footer.design', { realmName })} <i className="fas fa-cat"></i>
|
||||
</p>
|
||||
</footer>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
|
||||
/** Leyenda «NOTA» / «NOTE» traducida para los <fieldset> de aviso. */
|
||||
export async function NoteLegend() {
|
||||
const t = await getTranslations('Common')
|
||||
return <legend>{t('note')}</legend>
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import {
|
||||
getPlayersData,
|
||||
CLASS_INFO,
|
||||
@@ -11,19 +12,20 @@ import {
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
|
||||
export async function PlayersBoard() {
|
||||
const t = await getTranslations('UI')
|
||||
const data = await getPlayersData()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="box-content">
|
||||
<div className="body-box-content centered">
|
||||
<p><i className="fas fa-exclamation-triangle"></i> La información de esta página se actualiza 1 vez al día automáticamente</p>
|
||||
<p><i className="fas fa-exclamation-triangle"></i> {t('players.updateNotice')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Personajes creados por clase */}
|
||||
<div className="box-content">
|
||||
<div className="title-box-content centered"><h2>Personajes creados por clase</h2></div>
|
||||
<div className="title-box-content centered"><h2>{t('players.charsByClass')}</h2></div>
|
||||
<div className="body-box-content centered">
|
||||
<div className="show-players">
|
||||
{data.classStats.map((s) => (
|
||||
@@ -31,10 +33,10 @@ export async function PlayersBoard() {
|
||||
<table className="players-table">
|
||||
<tbody>
|
||||
<tr><td><img className="img-med-icon chest-medium" src={classChest(s.id)} alt={CLASS_INFO[s.id].label} /></td></tr>
|
||||
<tr><td><span className={s.key}>Total: <span>{s.total}</span></span></td></tr>
|
||||
<tr><td><span className={s.key}>{t('players.total')} <span>{s.total}</span></span></td></tr>
|
||||
<tr><td><hr /></td></tr>
|
||||
<tr><td className="lefted"><img className="img-small-icon-m img-align" src={ALLIANCE_ICON} alt="Alianza" /> <span className={s.key}>Alianzas: <span>{s.alliance}</span></span></td></tr>
|
||||
<tr><td className="lefted"><img className="img-small-icon-m img-align" src={HORDE_ICON} alt="Horda" /> <span className={s.key}>Hordas: <span>{s.horde}</span></span></td></tr>
|
||||
<tr><td className="lefted"><img className="img-small-icon-m img-align" src={ALLIANCE_ICON} alt={t('players.allianceAlt')} /> <span className={s.key}>{t('players.alliances')} <span>{s.alliance}</span></span></td></tr>
|
||||
<tr><td className="lefted"><img className="img-small-icon-m img-align" src={HORDE_ICON} alt={t('players.hordeAlt')} /> <span className={s.key}>{t('players.hordes')} <span>{s.horde}</span></span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -42,9 +44,9 @@ export async function PlayersBoard() {
|
||||
<table className="max-center-table2">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Personajes totales: <span>{data.totals.total}</span></td>
|
||||
<td><span className="alliance-color">Personajes alianzas:</span> <span>{data.totals.alliance}</span></td>
|
||||
<td><span className="horde-color">Personajes hordas:</span> <span>{data.totals.horde}</span></td>
|
||||
<td>{t('players.totalChars')} <span>{data.totals.total}</span></td>
|
||||
<td><span className="alliance-color">{t('players.allianceChars')}</span> <span>{data.totals.alliance}</span></td>
|
||||
<td><span className="horde-color">{t('players.hordeChars')}</span> <span>{data.totals.horde}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -56,23 +58,23 @@ export async function PlayersBoard() {
|
||||
|
||||
{/* Primeros del Reino - Nivel 80 */}
|
||||
<div className="box-content">
|
||||
<div className="title-box-content centered"><h2>Primeros del Reino - Nivel 80</h2></div>
|
||||
<div className="title-box-content centered"><h2>{t('players.firstsTitle')}</h2></div>
|
||||
<div className="body-box-content justified">
|
||||
{data.firsts.length === 0 ? (
|
||||
<p className="centered second-brown">Aún no hay personajes de nivel 80 registrados.</p>
|
||||
<p className="centered second-brown">{t('players.noLevel80')}</p>
|
||||
) : (
|
||||
<table className="max-left-table2">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Logro</th>
|
||||
<th>Personaje</th>
|
||||
<th className="responsive-td">Fecha</th>
|
||||
<th>{t('players.thAchievement')}</th>
|
||||
<th>{t('players.thCharacter')}</th>
|
||||
<th className="responsive-td">{t('players.thDate')}</th>
|
||||
</tr>
|
||||
{data.firsts.map((f, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<img className="img-small-icon img-small-icon-h img-align" src={f.icon} alt="" />{' '}
|
||||
<span className="yellow-info"><span className="yellow-info responsive-td">¡Primero del reino!</span><span className="yellow-info"> {f.label}</span></span>
|
||||
<span className="yellow-info"><span className="yellow-info responsive-td">{t('players.firstOfRealm')}</span><span className="yellow-info"> {f.label}</span></span>
|
||||
</td>
|
||||
<td className={`${f.classKey} big-font`}>{f.name}</td>
|
||||
<td className="responsive-td"><span>{f.date}</span></td>
|
||||
@@ -88,16 +90,16 @@ export async function PlayersBoard() {
|
||||
|
||||
{/* Top de logros */}
|
||||
<div className="box-content">
|
||||
<div className="title-box-content centered"><h2>Top de logros</h2></div>
|
||||
<div className="title-box-content centered"><h2>{t('players.topAchTitle')}</h2></div>
|
||||
<div className="body-box-content centered">
|
||||
<p>Los 10 jugadores con más logros</p>
|
||||
<p>{t('players.topAchSub')}</p>
|
||||
<table className="char-center-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Nombre</th>
|
||||
<th>Raza</th>
|
||||
<th>Clase</th>
|
||||
<th>Puntos de logros</th>
|
||||
<th>{t('players.thName')}</th>
|
||||
<th>{t('players.thRace')}</th>
|
||||
<th>{t('players.thClass')}</th>
|
||||
<th>{t('players.thAchPoints')}</th>
|
||||
</tr>
|
||||
{data.topAch.map((r, i) => (
|
||||
<tr key={i}>
|
||||
@@ -116,17 +118,17 @@ export async function PlayersBoard() {
|
||||
|
||||
{/* Top de muertes con honor */}
|
||||
<div className="box-content">
|
||||
<div className="title-box-content centered"><h2>Top de muertes con honor</h2></div>
|
||||
<div className="title-box-content centered"><h2>{t('players.topHonorTitle')}</h2></div>
|
||||
<div className="body-box-content centered">
|
||||
<p>Los 10 jugadores con más muertes con honor</p>
|
||||
<p>{t('players.topHonorSub')}</p>
|
||||
<table className="char-center-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Nombre</th>
|
||||
<th>Raza</th>
|
||||
<th>Clase</th>
|
||||
<th>Total de muertes</th>
|
||||
<th>Total de muertes hoy</th>
|
||||
<th>{t('players.thName')}</th>
|
||||
<th>{t('players.thRace')}</th>
|
||||
<th>{t('players.thClass')}</th>
|
||||
<th>{t('players.thTotalKills')}</th>
|
||||
<th>{t('players.thTodayKills')}</th>
|
||||
</tr>
|
||||
{data.topHonor.map((r, i) => (
|
||||
<tr key={i}>
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useRouter } from '@/i18n/navigation'
|
||||
|
||||
function promoError(error?: string): string {
|
||||
function promoErrorKey(error?: string): string {
|
||||
switch (error) {
|
||||
case 'missingCode':
|
||||
return 'Escribe un código de promoción.'
|
||||
case 'notFound':
|
||||
return 'El código no existe o ya no está disponible.'
|
||||
case 'expired':
|
||||
return 'Este código ha caducado.'
|
||||
case 'exhausted':
|
||||
return 'Este código ya ha alcanzado su límite de usos.'
|
||||
case 'alreadyUsed':
|
||||
return 'Ya has canjeado este código.'
|
||||
case 'notAuthenticated':
|
||||
return 'Tu sesión ha expirado. Inicia sesión de nuevo.'
|
||||
return error
|
||||
default:
|
||||
return 'No se pudo canjear el código. Inténtalo de nuevo.'
|
||||
return 'generic'
|
||||
}
|
||||
}
|
||||
|
||||
export function PromoCodeForm() {
|
||||
const t = useTranslations('Points')
|
||||
const promoError = (error?: string) => t(`promo.errors.${promoErrorKey(error)}`)
|
||||
const router = useRouter()
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -43,10 +41,10 @@ export function PromoCodeForm() {
|
||||
const data: { success?: boolean; error?: string; pd?: number; pv?: number } = await res.json()
|
||||
if (data.success) {
|
||||
const parts: string[] = []
|
||||
if (data.pd) parts.push(`${data.pd} PD`)
|
||||
if (data.pv) parts.push(`${data.pv} PV`)
|
||||
const reward = parts.length ? parts.join(' y ') : 'la recompensa'
|
||||
setMessage({ ok: true, text: `¡Código canjeado! Has recibido ${reward}.` })
|
||||
if (data.pd) parts.push(t('promo.rewardPd', { pd: data.pd }))
|
||||
if (data.pv) parts.push(t('promo.rewardPv', { pv: data.pv }))
|
||||
const reward = parts.length ? parts.join(` ${t('promo.and')} `) : t('promo.rewardDefault')
|
||||
setMessage({ ok: true, text: t('promo.success', { reward }) })
|
||||
setCode('')
|
||||
router.refresh()
|
||||
} else {
|
||||
@@ -66,14 +64,14 @@ export function PromoCodeForm() {
|
||||
<input
|
||||
type="text"
|
||||
maxLength={64}
|
||||
placeholder="Escribe tu código de promoción"
|
||||
placeholder={t('promo.placeholder')}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<br />
|
||||
<button type="submit" className="dnt-button" disabled={busy || !code.trim()}>
|
||||
{busy ? 'Canjeando…' : 'CANJEAR CÓDIGO'}
|
||||
{busy ? t('promo.redeeming') : t('promo.redeemButton')}
|
||||
</button>
|
||||
</form>
|
||||
<hr />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { CharacterSelect } from '@/components/CharacterSelect'
|
||||
import { Turnstile } from '@/components/Turnstile'
|
||||
|
||||
@@ -23,35 +24,36 @@ interface QuestStatus {
|
||||
state: 'rewarded' | 'inProgress' | 'none'
|
||||
}
|
||||
|
||||
function trackerError(error?: string, minutes?: number): string {
|
||||
function trackerError(t: ReturnType<typeof useTranslations>, error?: string, minutes?: number): string {
|
||||
switch (error) {
|
||||
case 'missingFields':
|
||||
return 'Selecciona un personaje y una opción.'
|
||||
return t('quest.errors.missingFields')
|
||||
case 'characterNotOwned':
|
||||
return 'El personaje seleccionado no es válido.'
|
||||
return t('quest.errors.characterNotOwned')
|
||||
case 'characterOnline':
|
||||
return 'El personaje debe estar desconectado.'
|
||||
return t('quest.errors.characterOnline')
|
||||
case 'wrongClass':
|
||||
return 'Esa opción no corresponde a la clase del personaje.'
|
||||
return t('quest.errors.wrongClass')
|
||||
case 'levelTooLow':
|
||||
return 'El personaje no cumple el nivel mínimo de esa opción.'
|
||||
return t('quest.errors.levelTooLow')
|
||||
case 'invalidQuestId':
|
||||
return 'Introduce un ID de misión válido.'
|
||||
return t('quest.errors.invalidQuestId')
|
||||
case 'invalidOption':
|
||||
case 'noData':
|
||||
return 'Esa opción no está disponible todavía.'
|
||||
return t('quest.errors.noData')
|
||||
case 'cooldown':
|
||||
return `Debes esperar ${minutes ?? 0} min para repetir esta búsqueda en este personaje.`
|
||||
return t('quest.errors.cooldown', { minutes: minutes ?? 0 })
|
||||
case 'captcha':
|
||||
return 'Verifica que no eres un robot.'
|
||||
return t('quest.errors.captcha')
|
||||
case 'notAuthenticated':
|
||||
return 'Tu sesión ha expirado. Inicia sesión de nuevo.'
|
||||
return t('quest.errors.notAuthenticated')
|
||||
default:
|
||||
return 'No se pudo completar la búsqueda. Inténtalo de nuevo.'
|
||||
return t('quest.errors.default')
|
||||
}
|
||||
}
|
||||
|
||||
export function QuestTrackerForm({ characters, options }: { characters: QuestCharacter[]; options: QuestOptionClient[] }) {
|
||||
const t = useTranslations('CharService')
|
||||
const [character, setCharacter] = useState('')
|
||||
const [option, setOption] = useState('')
|
||||
const [questId, setQuestId] = useState('')
|
||||
@@ -70,9 +72,9 @@ export function QuestTrackerForm({ characters, options }: { characters: QuestCha
|
||||
const list = options
|
||||
.filter((o) => o.hasData && (o.classCss === null || o.classCss === selected.classCss) && selected.level >= o.minLevel)
|
||||
.map((o) => ({ key: o.key, label: o.label }))
|
||||
if (selected.level >= 10) list.push({ key: 'specific', label: 'Por misión específica' })
|
||||
if (selected.level >= 10) list.push({ key: 'specific', label: t('quest.specificLabel') })
|
||||
return list
|
||||
}, [selected, options])
|
||||
}, [selected, options, t])
|
||||
|
||||
function onCharacter(v: string) {
|
||||
setCharacter(v)
|
||||
@@ -100,10 +102,10 @@ export function QuestTrackerForm({ characters, options }: { characters: QuestCha
|
||||
if (data.success && data.statuses) {
|
||||
setResult({ label: data.label ?? '', statuses: data.statuses })
|
||||
} else {
|
||||
setMessage({ ok: false, text: trackerError(data.error, data.minutes) })
|
||||
setMessage({ ok: false, text: trackerError(t, data.error, data.minutes) })
|
||||
}
|
||||
} catch {
|
||||
setMessage({ ok: false, text: trackerError() })
|
||||
setMessage({ ok: false, text: trackerError(t) })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setCaptcha('')
|
||||
@@ -115,7 +117,7 @@ export function QuestTrackerForm({ characters, options }: { characters: QuestCha
|
||||
return (
|
||||
<div className="centered">
|
||||
<br />
|
||||
<span>No hay personajes disponibles para esta herramienta</span>
|
||||
<span>{t('quest.noCharacters')}</span>
|
||||
<br />
|
||||
</div>
|
||||
)
|
||||
@@ -123,18 +125,18 @@ export function QuestTrackerForm({ characters, options }: { characters: QuestCha
|
||||
|
||||
const stateText = (s: QuestStatus['state']) =>
|
||||
s === 'rewarded'
|
||||
? { text: 'Completada', cls: 'green-info2' }
|
||||
? { text: t('quest.stateCompleted'), cls: 'green-info2' }
|
||||
: s === 'inProgress'
|
||||
? { text: 'En curso', cls: 'yellow-info' }
|
||||
: { text: 'Sin empezar', cls: 'third-brown' }
|
||||
? { text: t('quest.stateInProgress'), cls: 'yellow-info' }
|
||||
: { text: t('quest.stateNotStarted'), cls: 'third-brown' }
|
||||
|
||||
return (
|
||||
<div className="centered">
|
||||
<form id="uw-quest-form" onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<CharacterSelect characters={characters} value={character} onChange={onCharacter} placeholder="Selecciona un personaje" />
|
||||
<CharacterSelect characters={characters} value={character} onChange={onCharacter} placeholder={t('quest.selectPlaceholder')} />
|
||||
<br />
|
||||
<select value={option} onChange={(e) => setOption(e.target.value)} required disabled={!selected}>
|
||||
<option value="" disabled>Selecciona una opción</option>
|
||||
<option value="" disabled>{t('quest.selectOption')}</option>
|
||||
{available.map((o) => (
|
||||
<option key={o.key} value={o.key}>{o.label}</option>
|
||||
))}
|
||||
@@ -145,7 +147,7 @@ export function QuestTrackerForm({ characters, options }: { characters: QuestCha
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="ID de misión"
|
||||
placeholder={t('quest.questIdPlaceholder')}
|
||||
value={questId}
|
||||
onChange={(e) => setQuestId(e.target.value)}
|
||||
required
|
||||
@@ -155,7 +157,7 @@ export function QuestTrackerForm({ characters, options }: { characters: QuestCha
|
||||
)}
|
||||
<Turnstile key={captchaKey} onVerify={setCaptcha} />
|
||||
<button type="submit" className="quest-button" disabled={busy || !character || !option}>
|
||||
{busy ? 'Buscando misiones' : 'BUSCAR MISIONES'}
|
||||
{busy ? t('quest.submitting') : t('quest.submit')}
|
||||
</button>
|
||||
</form>
|
||||
<hr />
|
||||
@@ -172,7 +174,7 @@ export function QuestTrackerForm({ characters, options }: { characters: QuestCha
|
||||
<tr key={s.quest}>
|
||||
<td className="lefted">
|
||||
<a href={`${QUEST_DB}/?quest=${s.quest}`} target="_blank" rel="noopener noreferrer">
|
||||
Misión #{s.quest}
|
||||
{t('quest.questNumber', { quest: s.quest })}
|
||||
</a>
|
||||
</td>
|
||||
<td className={`real-info-box ${st.cls}`}>{st.text}</td>
|
||||
|
||||
@@ -1,27 +1,30 @@
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
|
||||
/** Información del reino (Rates + Horarios) — contenido de /<slug>-realm. */
|
||||
export function RealmInfo() {
|
||||
export async function RealmInfo() {
|
||||
const t = await getTranslations('UI')
|
||||
return (
|
||||
<div className="box-content centered">
|
||||
<div className="realm-information realm-information-half">
|
||||
<h2>Rates</h2>
|
||||
<p>Experiencia: <span>x8 modificable con .xp (Con Recluta a un Amigo x16)</span></p>
|
||||
<p>Drop de objetos verdes y azules: <span>x4</span></p>
|
||||
<p>Drop de oro en NPCs: <span>x2</span></p>
|
||||
<p>Drop de oro en misiones: <span>x1</span></p>
|
||||
<p>Habilidades: <span>x3</span></p>
|
||||
<p>Profesiones: <span>x3</span></p>
|
||||
<p>Reputaciones: <span>x3 (Bonus con Recluta a un amigo)</span></p>
|
||||
<p>Honor: <span>x3</span></p>
|
||||
<h2>{t('realmInfo.ratesTitle')}</h2>
|
||||
<p>{t('realmInfo.experience')}: <span>{t('realmInfo.experienceValue')}</span></p>
|
||||
<p>{t('realmInfo.greenBlueDrop')}: <span>{t('realmInfo.greenBlueDropValue')}</span></p>
|
||||
<p>{t('realmInfo.goldNpc')}: <span>{t('realmInfo.goldNpcValue')}</span></p>
|
||||
<p>{t('realmInfo.goldQuest')}: <span>{t('realmInfo.goldQuestValue')}</span></p>
|
||||
<p>{t('realmInfo.skills')}: <span>{t('realmInfo.skillsValue')}</span></p>
|
||||
<p>{t('realmInfo.professions')}: <span>{t('realmInfo.professionsValue')}</span></p>
|
||||
<p>{t('realmInfo.reputations')}: <span>{t('realmInfo.reputationsValue')}</span></p>
|
||||
<p>{t('realmInfo.honor')}: <span>{t('realmInfo.honorValue')}</span></p>
|
||||
</div>
|
||||
<div className="realm-information realm-information-half">
|
||||
<h2>Horarios</h2>
|
||||
<p>Los siguientes horarios son basados en la hora del reino.</p>
|
||||
<h2>{t('realmInfo.scheduleTitle')}</h2>
|
||||
<p>{t('realmInfo.scheduleNote')}</p>
|
||||
<br />
|
||||
<p>Misiones diarias: <span>7 am</span></p>
|
||||
<p>Reinicio de mazmorras diarias: <span>9 am</span></p>
|
||||
<p>Reinicio de bandas: <span>9 am (Miércoles)</span></p>
|
||||
<p>Campos de Batalla: <span>10 am</span></p>
|
||||
<p>Reparto de Puntos de Arena: <span>11 am (Viernes)</span></p>
|
||||
<p>{t('realmInfo.dailyQuests')}: <span>{t('realmInfo.dailyQuestsValue')}</span></p>
|
||||
<p>{t('realmInfo.dailyDungeons')}: <span>{t('realmInfo.dailyDungeonsValue')}</span></p>
|
||||
<p>{t('realmInfo.raidReset')}: <span>{t('realmInfo.raidResetValue')}</span></p>
|
||||
<p>{t('realmInfo.battlegrounds')}: <span>{t('realmInfo.battlegroundsValue')}</span></p>
|
||||
<p>{t('realmInfo.arenaPoints')}: <span>{t('realmInfo.arenaPointsValue')}</span></p>
|
||||
<br />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import type { RecruitRewardView } from '@/lib/recruit-claim'
|
||||
import type { CharOption } from '@/components/CharacterSelect'
|
||||
|
||||
const ICON_BASE = 'https://wotlk.ultimowow.com/static/images/wow/icons/tiny'
|
||||
|
||||
function claimError(message?: string): string {
|
||||
function claimErrorKey(message?: string): string {
|
||||
switch (message) {
|
||||
case 'requirementsNotMet':
|
||||
return 'Aún no cumples los requisitos para esta recompensa.'
|
||||
return 'recruit.errRequirements'
|
||||
case 'alreadyClaimed':
|
||||
return 'Esta recompensa ya fue reclamada.'
|
||||
return 'recruit.errAlreadyClaimed'
|
||||
case 'invalidCharacter':
|
||||
return 'Personaje no válido.'
|
||||
return 'recruit.errInvalidCharacter'
|
||||
case 'notFound':
|
||||
return 'Recompensa no encontrada.'
|
||||
return 'recruit.errNotFound'
|
||||
default:
|
||||
return 'No se pudo reclamar la recompensa. Inténtalo de nuevo.'
|
||||
return 'recruit.errGeneric'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +39,7 @@ export function RecruitPanel({
|
||||
isRecruited: boolean
|
||||
recruitedCount: number
|
||||
}) {
|
||||
const t = useTranslations('UI')
|
||||
const [character, setCharacter] = useState(characters[0]?.name ?? '')
|
||||
const selectedCss = characters.find((c) => c.name === character)?.classCss
|
||||
const [busyId, setBusyId] = useState<number | null>(null)
|
||||
@@ -62,10 +64,10 @@ export function RecruitPanel({
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
setMsg({ ok: false, text: claimError(d.message) })
|
||||
setMsg({ ok: false, text: t(claimErrorKey(d.message)) })
|
||||
setBusyId(null)
|
||||
} catch {
|
||||
setMsg({ ok: false, text: claimError() })
|
||||
setMsg({ ok: false, text: t(claimErrorKey()) })
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
@@ -90,15 +92,15 @@ export function RecruitPanel({
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="lefted">Cuenta reclutada: <span>{isRecruited ? 'Sí' : 'No'}</span></p>
|
||||
<p className="lefted">Amigos reclutados: <span>{recruitedCount}</span></p>
|
||||
<p className="lefted">Amigos reclutados al nivel 80: <span>{level80Count}</span></p>
|
||||
<p className="lefted">Recompensas reclamadas: <span>{claimedCount}/{rewards.length}</span></p>
|
||||
<p className="lefted">{t('recruit.accountRecruited')} <span>{isRecruited ? t('recruit.yes') : t('recruit.no')}</span></p>
|
||||
<p className="lefted">{t('recruit.friendsRecruited')} <span>{recruitedCount}</span></p>
|
||||
<p className="lefted">{t('recruit.friendsAtLevel80')} <span>{level80Count}</span></p>
|
||||
<p className="lefted">{t('recruit.rewardsClaimed')} <span>{claimedCount}/{rewards.length}</span></p>
|
||||
<br />
|
||||
|
||||
{characters.length > 0 && (
|
||||
<p className="lefted">
|
||||
Recibir recompensas en:{' '}
|
||||
{t('recruit.receiveRewardsOn')}{' '}
|
||||
<select value={character} onChange={(e) => setCharacter(e.target.value)} className={['account-select', selectedCss].filter(Boolean).join(' ')}>
|
||||
{characters.map((c) => (
|
||||
<option key={c.name} value={c.name} className={c.classCss ? `${c.classCss} big-font` : undefined}>{c.name}</option>
|
||||
@@ -111,25 +113,25 @@ export function RecruitPanel({
|
||||
<table className="char-center-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Amigos reclutados</th>
|
||||
<th>Recompensa</th>
|
||||
<th>Estado</th>
|
||||
<th>{t('recruit.thFriends')}</th>
|
||||
<th>{t('recruit.thReward')}</th>
|
||||
<th>{t('recruit.thStatus')}</th>
|
||||
</tr>
|
||||
{rewards.map((r) => {
|
||||
const eligible = level80Count >= r.required_friends
|
||||
return (
|
||||
<tr key={r.id}>
|
||||
<td><span>{r.required_friends} {r.required_friends === 1 ? 'amigo' : 'amigos'} al nivel 80</span></td>
|
||||
<td><span>{t('recruit.friendsAtLevel80Row', { count: r.required_friends })}</span></td>
|
||||
<td>{rewardCell(r)}</td>
|
||||
<td>
|
||||
{r.claimed ? (
|
||||
<span className="green-info"><i className="fas fa-check"></i> Reclamado</span>
|
||||
<span className="green-info"><i className="fas fa-check"></i> {t('recruit.claimed')}</span>
|
||||
) : eligible ? (
|
||||
<button type="button" className="nw-tool-btn" onClick={() => claim(r)} disabled={busyId !== null}>
|
||||
{busyId === r.id ? '…' : 'Reclamar'}
|
||||
{busyId === r.id ? '…' : t('recruit.claim')}
|
||||
</button>
|
||||
) : (
|
||||
<span>Sin reclamar</span>
|
||||
<span>{t('recruit.unclaimed')}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -140,13 +142,13 @@ export function RecruitPanel({
|
||||
|
||||
<br />
|
||||
<hr />
|
||||
<p>RECOMPENSAS DISPONIBLES</p>
|
||||
<p>{t('recruit.availableRewards')}</p>
|
||||
<hr />
|
||||
{available.length === 0 ? (
|
||||
<span>Recluta amigos para poder reclamar las recompensas</span>
|
||||
<span>{t('recruit.recruitToClaim')}</span>
|
||||
) : (
|
||||
<span className="green-info">
|
||||
Tienes {available.length} recompensa{available.length === 1 ? '' : 's'} lista{available.length === 1 ? '' : 's'} para reclamar
|
||||
{t('recruit.readyToClaim', { count: available.length })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,33 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useRouter } from '@/i18n/navigation'
|
||||
import type { GmGuild } from '@/lib/guild'
|
||||
|
||||
function renameError(error?: string): string {
|
||||
function renameErrorKey(error?: string): string {
|
||||
switch (error) {
|
||||
case 'missingFields':
|
||||
return 'Completa todos los campos.'
|
||||
case 'invalidName':
|
||||
return 'El nombre debe tener de 1 a 24 caracteres, solo letras (A-Z, a-z) y espacios.'
|
||||
case 'invalidToken':
|
||||
return 'El token de seguridad no es válido.'
|
||||
case 'notGuildMaster':
|
||||
return 'No eres Maestro de esa hermandad.'
|
||||
case 'nameTaken':
|
||||
return 'Ya existe una hermandad con ese nombre.'
|
||||
case 'insufficientPoints':
|
||||
return 'No tienes suficientes PD (se requieren 1000).'
|
||||
case 'soapError':
|
||||
return 'No se pudo aplicar el cambio (servidor de juego no disponible). Inténtalo más tarde.'
|
||||
case 'notAuthenticated':
|
||||
return 'Tu sesión ha expirado. Inicia sesión de nuevo.'
|
||||
return error
|
||||
default:
|
||||
return 'No se pudo renombrar la hermandad. Inténtalo de nuevo.'
|
||||
return 'generic'
|
||||
}
|
||||
}
|
||||
|
||||
export function RenameGuildForm({ guilds }: { guilds: GmGuild[] }) {
|
||||
const t = useTranslations('Points')
|
||||
const renameError = (error?: string) => t(`renameGuild.errors.${renameErrorKey(error)}`)
|
||||
const router = useRouter()
|
||||
const [guildId, setGuildId] = useState('')
|
||||
const [newName, setNewName] = useState('')
|
||||
@@ -41,7 +37,7 @@ export function RenameGuildForm({ guilds }: { guilds: GmGuild[] }) {
|
||||
return (
|
||||
<div className="centered">
|
||||
<br />
|
||||
<span>No hay personajes Maestros de hermandad</span>
|
||||
<span>{t('renameGuild.noGuilds')}</span>
|
||||
<br />
|
||||
</div>
|
||||
)
|
||||
@@ -54,7 +50,7 @@ export function RenameGuildForm({ guilds }: { guilds: GmGuild[] }) {
|
||||
setMessage({ ok: false, text: renameError('missingFields') })
|
||||
return
|
||||
}
|
||||
if (!window.confirm('¿Estás seguro de renombrar la hermandad seleccionada?')) return
|
||||
if (!window.confirm(t('renameGuild.confirm'))) return
|
||||
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
@@ -68,7 +64,7 @@ export function RenameGuildForm({ guilds }: { guilds: GmGuild[] }) {
|
||||
const data: { success?: boolean; error?: string; newName?: string } = await res.json()
|
||||
if (data.success) {
|
||||
setDone(true)
|
||||
setMessage({ ok: true, text: `¡Hermandad renombrada a "${data.newName}"! Puede que los miembros conectados deban reconectar para verlo.` })
|
||||
setMessage({ ok: true, text: t('renameGuild.success', { newName: data.newName ?? '' }) })
|
||||
setNewName('')
|
||||
setToken('')
|
||||
setTimeout(() => router.refresh(), 3000)
|
||||
@@ -90,7 +86,7 @@ export function RenameGuildForm({ guilds }: { guilds: GmGuild[] }) {
|
||||
<tr>
|
||||
<td>
|
||||
<select value={guildId} onChange={(e) => setGuildId(e.target.value)} required>
|
||||
<option value="" disabled>Selecciona una hermandad</option>
|
||||
<option value="" disabled>{t('renameGuild.selectGuild')}</option>
|
||||
{guilds.map((g) => (
|
||||
<option key={g.guildid} value={g.guildid}>{g.name}</option>
|
||||
))}
|
||||
@@ -103,7 +99,7 @@ export function RenameGuildForm({ guilds }: { guilds: GmGuild[] }) {
|
||||
type="text"
|
||||
maxLength={24}
|
||||
name="new-guild-name"
|
||||
placeholder="Nuevo nombre de la hermandad"
|
||||
placeholder={t('renameGuild.newNamePlaceholder')}
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
required
|
||||
@@ -116,7 +112,7 @@ export function RenameGuildForm({ guilds }: { guilds: GmGuild[] }) {
|
||||
type={showToken ? 'text' : 'password'}
|
||||
maxLength={6}
|
||||
id="security-token"
|
||||
placeholder="Token de seguridad"
|
||||
placeholder={t('renameGuild.tokenPlaceholder')}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
required
|
||||
@@ -132,7 +128,7 @@ export function RenameGuildForm({ guilds }: { guilds: GmGuild[] }) {
|
||||
<tr>
|
||||
<td>
|
||||
<button type="submit" className="rename-guild-button" disabled={busy || done} style={done ? { color: '#d79602' } : undefined}>
|
||||
{done ? 'HERMANDAD RENOMBRADA' : busy ? 'RENOMBRANDO HERMANDAD' : 'RENOMBRAR HERMANDAD'}
|
||||
{done ? t('renameGuild.renamed') : busy ? t('renameGuild.renaming') : t('renameGuild.renameButton')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useRouter } from '@/i18n/navigation'
|
||||
import type { DeletedCharacter } from '@/lib/restore-character'
|
||||
|
||||
function restoreError(error?: string): string {
|
||||
function restoreError(t: ReturnType<typeof useTranslations>, error?: string): string {
|
||||
switch (error) {
|
||||
case 'notFound':
|
||||
return 'Ese personaje borrado no es de tu cuenta.'
|
||||
return t('restoreChar.errors.notFound')
|
||||
case 'notDeleted':
|
||||
return 'Ese personaje no está borrado.'
|
||||
return t('restoreChar.errors.notDeleted')
|
||||
case 'levelTooLow':
|
||||
return 'Solo se pueden recuperar personajes de nivel superior a 60.'
|
||||
return t('restoreChar.errors.levelTooLow')
|
||||
case 'tooOld':
|
||||
return 'El personaje se borró hace más de 30 días y ya no es recuperable.'
|
||||
return t('restoreChar.errors.tooOld')
|
||||
case 'dkExists':
|
||||
return 'No puedes recuperar un Caballero de la Muerte: ya tienes uno activo en la cuenta.'
|
||||
return t('restoreChar.errors.dkExists')
|
||||
case 'nameTaken':
|
||||
return 'El nombre original ya está en uso por otro personaje.'
|
||||
return t('restoreChar.errors.nameTaken')
|
||||
case 'cooldown':
|
||||
return 'Solo puedes intentar recuperar el mismo personaje una vez cada 12 horas.'
|
||||
return t('restoreChar.errors.cooldown')
|
||||
case 'notAuthenticated':
|
||||
return 'Tu sesión ha expirado. Inicia sesión de nuevo.'
|
||||
return t('restoreChar.errors.notAuthenticated')
|
||||
default:
|
||||
return 'No se pudo recuperar el personaje. Inténtalo de nuevo.'
|
||||
return t('restoreChar.errors.default')
|
||||
}
|
||||
}
|
||||
|
||||
export function RestoreCharacterForm({ characters }: { characters: DeletedCharacter[] }) {
|
||||
const t = useTranslations('CharService')
|
||||
const router = useRouter()
|
||||
const [guid, setGuid] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
@@ -41,7 +43,7 @@ export function RestoreCharacterForm({ characters }: { characters: DeletedCharac
|
||||
<div className="centered">
|
||||
<br />
|
||||
<br />
|
||||
<span>No hay personajes</span>
|
||||
<span>{t('restoreChar.noCharacters')}</span>
|
||||
<br />
|
||||
</div>
|
||||
)
|
||||
@@ -62,13 +64,13 @@ export function RestoreCharacterForm({ characters }: { characters: DeletedCharac
|
||||
const data: { success?: boolean; error?: string; name?: string } = await res.json()
|
||||
if (data.success) {
|
||||
setDone(true)
|
||||
setMessage({ ok: true, text: `¡Personaje "${data.name}" recuperado! Lo verás al entrar al reino.` })
|
||||
setMessage({ ok: true, text: t('restoreChar.successMsg', { name: data.name ?? '' }) })
|
||||
setTimeout(() => router.refresh(), 3000)
|
||||
} else {
|
||||
setMessage({ ok: false, text: restoreError(data.error) })
|
||||
setMessage({ ok: false, text: restoreError(t, data.error) })
|
||||
}
|
||||
} catch {
|
||||
setMessage({ ok: false, text: restoreError() })
|
||||
setMessage({ ok: false, text: restoreError(t) })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -78,16 +80,16 @@ export function RestoreCharacterForm({ characters }: { characters: DeletedCharac
|
||||
<div className="centered">
|
||||
<form id="uw-restore-form" onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<select className={selectedCss} value={guid} onChange={(e) => setGuid(e.target.value)} required>
|
||||
<option value="" disabled>Selecciona un personaje</option>
|
||||
<option value="" disabled>{t('restoreChar.selectPlaceholder')}</option>
|
||||
{characters.map((c) => (
|
||||
<option key={c.guid} value={c.guid} className={`${c.classCss} big-font`}>
|
||||
{c.name} (nivel {c.level})
|
||||
{t('restoreChar.optionLabel', { name: c.name, level: c.level })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<br />
|
||||
<button type="submit" className="restore-button" disabled={busy || done || !guid} style={done ? { color: '#d79602' } : undefined}>
|
||||
{done ? 'RECUPERADO' : busy ? 'Recuperando' : 'RECUPERAR'}
|
||||
{done ? t('restoreChar.restored') : busy ? t('restoreChar.recovering') : t('restoreChar.recover')}
|
||||
</button>
|
||||
</form>
|
||||
<hr />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { CharacterSelect, type CharOption } from '@/components/CharacterSelect'
|
||||
import { Turnstile } from '@/components/Turnstile'
|
||||
|
||||
@@ -13,26 +14,27 @@ interface DeletedItem {
|
||||
count: number
|
||||
}
|
||||
|
||||
function itemsError(error?: string, minutes?: number): string {
|
||||
function itemsError(t: ReturnType<typeof useTranslations>, error?: string, minutes?: number): string {
|
||||
switch (error) {
|
||||
case 'characterNotOwned':
|
||||
return 'El personaje seleccionado no es válido.'
|
||||
return t('restoreItems.errors.characterNotOwned')
|
||||
case 'cooldown':
|
||||
return `Solo puedes consultar los ítems de este personaje cada 8 horas (faltan ${minutes ?? 0} min).`
|
||||
return t('restoreItems.errors.cooldown', { minutes: minutes ?? 0 })
|
||||
case 'soapError':
|
||||
return 'No se pudo conectar con el servidor de juego. Inténtalo más tarde.'
|
||||
return t('restoreItems.errors.soapError')
|
||||
case 'insufficientPoints':
|
||||
return 'No tienes suficientes PD (se requieren 100 por ítem).'
|
||||
return t('restoreItems.errors.insufficientPoints')
|
||||
case 'captcha':
|
||||
return 'Verifica que no eres un robot.'
|
||||
return t('restoreItems.errors.captcha')
|
||||
case 'notAuthenticated':
|
||||
return 'Tu sesión ha expirado. Inicia sesión de nuevo.'
|
||||
return t('restoreItems.errors.notAuthenticated')
|
||||
default:
|
||||
return 'Algo ha salido mal. Inténtalo de nuevo.'
|
||||
return t('restoreItems.errors.default')
|
||||
}
|
||||
}
|
||||
|
||||
export function RestoreItemsForm({ characters, price }: { characters: CharOption[]; price: number }) {
|
||||
const t = useTranslations('CharService')
|
||||
const [character, setCharacter] = useState('')
|
||||
const [captcha, setCaptcha] = useState('')
|
||||
const [captchaKey, setCaptchaKey] = useState(0)
|
||||
@@ -55,12 +57,12 @@ export function RestoreItemsForm({ characters, price }: { characters: CharOption
|
||||
const data: { success?: boolean; error?: string; minutes?: number; items?: DeletedItem[] } = await res.json()
|
||||
if (data.success) {
|
||||
setItems(data.items ?? [])
|
||||
if ((data.items ?? []).length === 0) setMessage({ ok: true, text: 'Este personaje no tiene ítems borrados recuperables.' })
|
||||
if ((data.items ?? []).length === 0) setMessage({ ok: true, text: t('restoreItems.noDeletedItemsMsg') })
|
||||
} else {
|
||||
setMessage({ ok: false, text: itemsError(data.error, data.minutes) })
|
||||
setMessage({ ok: false, text: itemsError(t, data.error, data.minutes) })
|
||||
}
|
||||
} catch {
|
||||
setMessage({ ok: false, text: itemsError() })
|
||||
setMessage({ ok: false, text: itemsError(t) })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setCaptcha('')
|
||||
@@ -70,7 +72,7 @@ export function RestoreItemsForm({ characters, price }: { characters: CharOption
|
||||
|
||||
async function restore(item: DeletedItem) {
|
||||
if (busy) return
|
||||
if (!window.confirm(`¿Recuperar "${item.name}" por ${price} € (SumUp)?`)) return
|
||||
if (!window.confirm(t('restoreItems.confirm', { name: item.name, price }))) return
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
try {
|
||||
@@ -85,10 +87,10 @@ export function RestoreItemsForm({ characters, price }: { characters: CharOption
|
||||
window.location.assign(data.url) // checkout de SumUp
|
||||
return
|
||||
}
|
||||
setMessage({ ok: false, text: data.message || itemsError(data.error) })
|
||||
setMessage({ ok: false, text: data.message || itemsError(t, data.error) })
|
||||
setBusy(false)
|
||||
} catch {
|
||||
setMessage({ ok: false, text: itemsError() })
|
||||
setMessage({ ok: false, text: itemsError(t) })
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
@@ -106,19 +108,19 @@ export function RestoreItemsForm({ characters, price }: { characters: CharOption
|
||||
{items === null ? (
|
||||
<div id="char-selection">
|
||||
<form id="uw-search-items-form" onSubmit={search} acceptCharset="utf-8">
|
||||
<CharacterSelect characters={characters} value={character} onChange={setCharacter} placeholder="Selecciona un personaje" />
|
||||
<CharacterSelect characters={characters} value={character} onChange={setCharacter} placeholder={t('restoreItems.selectPlaceholder')} />
|
||||
<br />
|
||||
<Turnstile key={captchaKey} onVerify={setCaptcha} />
|
||||
<button type="submit" className="search-items-button" disabled={busy || !character}>
|
||||
{busy ? 'Buscando' : 'BUSCAR ÍTEMS'}
|
||||
{busy ? t('restoreItems.searching') : t('restoreItems.search')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div id="search-items-response">
|
||||
<p className="yellow-info">Ítems borrados de {character}</p>
|
||||
<p className="yellow-info">{t('restoreItems.deletedItemsOf', { character })}</p>
|
||||
{items.length === 0 ? (
|
||||
<p className="second-brown">No hay ítems borrados recuperables.</p>
|
||||
<p className="second-brown">{t('restoreItems.noDeletedItems')}</p>
|
||||
) : (
|
||||
<table className="restore-item-table">
|
||||
<tbody>
|
||||
@@ -130,7 +132,7 @@ export function RestoreItemsForm({ characters, price }: { characters: CharOption
|
||||
</td>
|
||||
<td className="real-info-box">
|
||||
<button type="button" className="restore-item-button" value={it.recoverId} data-char={character} onClick={() => restore(it)} disabled={busy}>
|
||||
Recuperar ({price} €)
|
||||
{t('restoreItems.restoreItem', { price })}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -139,7 +141,7 @@ export function RestoreItemsForm({ characters, price }: { characters: CharOption
|
||||
</table>
|
||||
)}
|
||||
<br />
|
||||
<p><a id="a-select-other" href="#" onClick={(e) => { e.preventDefault(); reset() }}>Consultar otro personaje</a></p>
|
||||
<p><a id="a-select-other" href="#" onClick={(e) => { e.preventDefault(); reset() }}>{t('restoreItems.queryOther')}</a></p>
|
||||
</div>
|
||||
)}
|
||||
<hr />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { NoteLegend } from '@/components/NoteLegend'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getGameCharacters } from '@/lib/characters'
|
||||
@@ -23,7 +24,7 @@ export async function ReviveServiceContent({ locale }: { locale: string }) {
|
||||
<p>{t('reviveInfo2')}</p>
|
||||
<br />
|
||||
<fieldset>
|
||||
<legend>NOTA</legend>
|
||||
<NoteLegend />
|
||||
<div className="separate2">
|
||||
<p>{t('reviveNote1')}</p>
|
||||
<p>{t('reviveNote2')}</p>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import type { GiftCategory, GiftItem } from '@/lib/gift'
|
||||
import { CharacterSelect, type CharOption } from '@/components/CharacterSelect'
|
||||
|
||||
@@ -53,6 +54,7 @@ export function SendGiftForm({
|
||||
catalog: GiftCategory[]
|
||||
currency?: string
|
||||
}) {
|
||||
const t = useTranslations('CharService')
|
||||
const [source, setSource] = useState('')
|
||||
const [destination, setDestination] = useState('')
|
||||
const [token, setToken] = useState('')
|
||||
@@ -106,16 +108,16 @@ export function SendGiftForm({
|
||||
e.preventDefault()
|
||||
if (busy) return
|
||||
if (!source || !destination.trim() || !token.trim()) {
|
||||
setError('Completa el personaje de origen, el de destino y el token de seguridad.')
|
||||
setError(t('sendGift.errMissingFields'))
|
||||
return
|
||||
}
|
||||
if (cart.size === 0) {
|
||||
setError('Añade al menos un objeto al carrito.')
|
||||
setError(t('sendGift.errEmptyCart'))
|
||||
return
|
||||
}
|
||||
if (
|
||||
!window.confirm(
|
||||
`¿Enviar el regalo a "${destination.trim()}" por ${total} ${currency} (SumUp)? Esta acción no es reversible.`,
|
||||
t('sendGift.confirm', { destination: destination.trim(), total, currency }),
|
||||
)
|
||||
)
|
||||
return
|
||||
@@ -137,40 +139,40 @@ export function SendGiftForm({
|
||||
const data: { success?: boolean; url?: string; message?: string } = await res.json()
|
||||
if (data.success && data.url) window.location.href = data.url
|
||||
else {
|
||||
setError(data.message || 'No se pudo iniciar el pago. Inténtalo de nuevo.')
|
||||
setError(data.message || t('sendGift.errPayment'))
|
||||
setBusy(false)
|
||||
}
|
||||
} catch {
|
||||
setError('Algo ha salido mal. Inténtalo más tarde.')
|
||||
setError(t('sendGift.errUnexpected'))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (characters.length === 0) return <p className="second-brown">No tienes personajes disponibles.</p>
|
||||
if (catalog.length === 0) return <p className="second-brown">No hay objetos disponibles para regalar por ahora.</p>
|
||||
if (characters.length === 0) return <p className="second-brown">{t('sendGift.noCharacters')}</p>
|
||||
if (catalog.length === 0) return <p className="second-brown">{t('sendGift.noItems')}</p>
|
||||
|
||||
return (
|
||||
<div className="centered">
|
||||
<form onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<p>Escoge desde qué personaje se envía el regalo:</p>
|
||||
<CharacterSelect characters={characters} value={source} onChange={setSource} placeholder="Personaje de origen" />
|
||||
<p>{t('sendGift.sourcePrompt')}</p>
|
||||
<CharacterSelect characters={characters} value={source} onChange={setSource} placeholder={t('sendGift.sourcePlaceholder')} />
|
||||
<br />
|
||||
<p>Nombre del personaje que recibirá el regalo:</p>
|
||||
<p>{t('sendGift.destPrompt')}</p>
|
||||
<input
|
||||
type="text"
|
||||
maxLength={12}
|
||||
placeholder="Personaje de destino"
|
||||
placeholder={t('sendGift.destPlaceholder')}
|
||||
value={destination}
|
||||
onChange={(e) => setDestination(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<br />
|
||||
<p>Token de seguridad:</p>
|
||||
<SecretInput value={token} onChange={setToken} placeholder="Token de seguridad" />
|
||||
<p>{t('sendGift.tokenPrompt')}</p>
|
||||
<SecretInput value={token} onChange={setToken} placeholder={t('sendGift.tokenPlaceholder')} />
|
||||
<hr />
|
||||
|
||||
{/* Catálogo de objetos: cuadrícula centrada de 4 por fila + paginación */}
|
||||
<h3 className="first-brown centered">Objetos disponibles</h3>
|
||||
<h3 className="first-brown centered">{t('sendGift.availableItems')}</h3>
|
||||
<div className="gift-grid">
|
||||
{pageItems.map((it) => (
|
||||
<div className="item-box" key={it.id}>
|
||||
@@ -183,7 +185,7 @@ export function SendGiftForm({
|
||||
{it.price} {currency}
|
||||
</div>
|
||||
<button type="button" className="store-add-button" onClick={() => addItem(it)}>
|
||||
Añadir
|
||||
{t('sendGift.add')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
@@ -191,7 +193,7 @@ export function SendGiftForm({
|
||||
{totalPages > 1 && (
|
||||
<div className="gift-pagination">
|
||||
<button type="button" onClick={() => setPage((p) => Math.max(1, Math.min(totalPages, p) - 1))} disabled={currentPage === 1}>
|
||||
Atrás
|
||||
{t('sendGift.back')}
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (
|
||||
<button
|
||||
@@ -204,16 +206,16 @@ export function SendGiftForm({
|
||||
</button>
|
||||
))}
|
||||
<button type="button" onClick={() => setPage((p) => Math.min(totalPages, Math.min(totalPages, p) + 1))} disabled={currentPage === totalPages}>
|
||||
Siguiente
|
||||
{t('sendGift.next')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<hr />
|
||||
|
||||
{/* Carrito */}
|
||||
<h3 className="first-brown">Carrito</h3>
|
||||
<h3 className="first-brown">{t('sendGift.cart')}</h3>
|
||||
{cart.size === 0 ? (
|
||||
<p className="second-brown">Aún no has añadido objetos.</p>
|
||||
<p className="second-brown">{t('sendGift.cartEmpty')}</p>
|
||||
) : (
|
||||
<table className="cart-list max-center-table">
|
||||
<tbody>
|
||||
@@ -248,7 +250,7 @@ export function SendGiftForm({
|
||||
<div className="centered">
|
||||
<br />
|
||||
<p>
|
||||
Total: <span className="yellow-info">{total} {currency}</span> (SumUp)
|
||||
{t.rich('sendGift.total', { total, currency, s: (c) => <span className="yellow-info">{c}</span> })}
|
||||
</p>
|
||||
<br />
|
||||
<button
|
||||
@@ -256,7 +258,7 @@ export function SendGiftForm({
|
||||
className="store-send-button"
|
||||
disabled={busy || cart.size === 0 || !source || !destination.trim() || !token.trim()}
|
||||
>
|
||||
{busy ? 'Enviando regalo' : 'ENVIAR REGALO'}
|
||||
{busy ? t('sendGift.submitting') : t('sendGift.submit')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useLocale } from 'next-intl'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { Link } from '@/i18n/navigation'
|
||||
|
||||
const SERVER_NAME = 'NovaWoW'
|
||||
@@ -27,6 +27,7 @@ export function SiteHeader({
|
||||
playersHref: string
|
||||
}) {
|
||||
const locale = useLocale()
|
||||
const t = useTranslations('UI')
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
@@ -46,21 +47,21 @@ export function SiteHeader({
|
||||
</a>
|
||||
|
||||
{loggedIn ? (
|
||||
<Link href="/">INICIO</Link>
|
||||
<Link href="/">{t('header.home')}</Link>
|
||||
) : (
|
||||
<Link href="/register">CREAR CUENTA</Link>
|
||||
<Link href="/register">{t('header.register')}</Link>
|
||||
)}
|
||||
|
||||
<div className="nav-dropdown">
|
||||
<div className="nav-dropdown-btn">
|
||||
DESCARGAS <i className="fas fa-caret-down"></i>
|
||||
{t('header.downloads')} <i className="fas fa-caret-down"></i>
|
||||
</div>
|
||||
<div className="nav-dropdown-content">
|
||||
<p>
|
||||
<Link href="/download-client">CLIENTE</Link>
|
||||
<Link href="/download-client">{t('header.client')}</Link>
|
||||
</p>
|
||||
<p>
|
||||
<Link href="/download-addons">ADDONS</Link>
|
||||
<Link href="/download-addons">{t('header.addons')}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,14 +72,14 @@ export function SiteHeader({
|
||||
</div>
|
||||
<div className="nav-dropdown-content">
|
||||
<p>
|
||||
<Link href="/changelogs">CHANGELOG</Link>
|
||||
<Link href="/changelogs">{t('header.changelog')}</Link>
|
||||
</p>
|
||||
<p>
|
||||
<Link href={realmHref}>REINO</Link>
|
||||
<Link href={realmHref}>{t('header.realm')}</Link>
|
||||
</p>
|
||||
{loggedIn && (
|
||||
<p>
|
||||
<Link href={playersHref}>JUGADORES</Link>
|
||||
<Link href={playersHref}>{t('header.players')}</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -86,23 +87,23 @@ export function SiteHeader({
|
||||
|
||||
<div className="nav-dropdown">
|
||||
<div className="nav-dropdown-btn">
|
||||
COMUNIDAD <i className="fas fa-caret-down"></i>
|
||||
{t('header.community')} <i className="fas fa-caret-down"></i>
|
||||
</div>
|
||||
<div className="nav-dropdown-content">
|
||||
<p>
|
||||
<Link href="/forum">FOROS</Link>
|
||||
<Link href="/forum">{t('header.forums')}</Link>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://wotlk.novawow.com" target="_blank" rel="noopener noreferrer">
|
||||
WOTLK DB
|
||||
{t('header.wotlkDb')}
|
||||
</a>
|
||||
</p>
|
||||
<p>
|
||||
<Link href="/content-creators">VIDEOS</Link>
|
||||
<Link href="/content-creators">{t('header.videos')}</Link>
|
||||
</p>
|
||||
{loggedIn && (
|
||||
<p>
|
||||
<Link href="/help">AYUDA</Link>
|
||||
<Link href="/help">{t('header.help')}</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -115,16 +116,16 @@ export function SiteHeader({
|
||||
</div>
|
||||
<div className="nav-dropdown-content">
|
||||
<p>
|
||||
<Link href="/my-account">MI CUENTA</Link>
|
||||
<Link href="/my-account">{t('header.myAccount')}</Link>
|
||||
</p>
|
||||
<p>
|
||||
<a href={`/${locale}/log-out`}>DESCONECTAR</a>
|
||||
<a href={`/${locale}/log-out`}>{t('header.logout')}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Link href="/login" className="last">
|
||||
CONECTAR
|
||||
{t('header.login')}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
|
||||
/**
|
||||
* Barra de redes sociales — réplica del partial Django `partials/social.html`
|
||||
* con las clases del tema real (social-media, social-button + marca).
|
||||
*/
|
||||
export function Social() {
|
||||
export async function Social() {
|
||||
const t = await getTranslations('UI')
|
||||
return (
|
||||
<div className="social-media">
|
||||
<span className="big-font">SÍGUENOS EN</span>
|
||||
<span className="big-font">{t('social.followUs')}</span>
|
||||
<a
|
||||
href="https://www.facebook.com/NovaWoW/"
|
||||
className="social-button social-button-responsive facebook"
|
||||
|
||||
@@ -1,46 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Turnstile } from '@/components/Turnstile'
|
||||
import { CharacterSelect, type CharOption } from '@/components/CharacterSelect'
|
||||
|
||||
function tradeError(error?: string): string {
|
||||
function tradeErrorKey(error?: string): string {
|
||||
switch (error) {
|
||||
case 'missingFields':
|
||||
return 'Rellena todos los campos.'
|
||||
return 'missingFields'
|
||||
case 'invalidAmount':
|
||||
return 'Las cantidades deben ser números enteros entre 1 y 99999.'
|
||||
return 'invalidAmount'
|
||||
case 'characterNotFound':
|
||||
case 'characterNotOwned':
|
||||
return 'El personaje seleccionado no es válido.'
|
||||
return 'characterInvalid'
|
||||
case 'captcha':
|
||||
return 'Verifica que no eres un robot.'
|
||||
return 'captcha'
|
||||
case 'wrongPassword':
|
||||
return 'La contraseña de tu cuenta es incorrecta.'
|
||||
return 'wrongPassword'
|
||||
case 'invalidToken':
|
||||
return 'El token de seguridad no es válido.'
|
||||
return 'invalidToken'
|
||||
case 'insufficientPoints':
|
||||
return 'No tienes suficientes PD disponibles para crear este código.'
|
||||
return 'insufficientPoints'
|
||||
case 'codeNotFound':
|
||||
return 'No existe ningún código con ese ID.'
|
||||
return 'codeNotFound'
|
||||
case 'codeUnavailable':
|
||||
return 'El código ya no está disponible (canjeado o expirado).'
|
||||
return 'codeUnavailable'
|
||||
case 'cannotRedeemOwn':
|
||||
return 'No puedes canjear tu propio código.'
|
||||
return 'cannotRedeemOwn'
|
||||
case 'characterOnline':
|
||||
return 'El personaje debe estar desconectado para poder cobrar el oro.'
|
||||
return 'characterOnline'
|
||||
case 'insufficientGold':
|
||||
return 'El personaje no tiene suficiente oro.'
|
||||
return 'insufficientGold'
|
||||
case 'creatorInsufficient':
|
||||
return 'El creador del código ya no tiene suficientes PD.'
|
||||
return 'creatorInsufficient'
|
||||
case 'locked':
|
||||
return 'Espera unos segundos antes de canjear otro código.'
|
||||
return 'locked'
|
||||
case 'deliveryFailed':
|
||||
return 'No se pudo entregar el oro (servidor de juego no disponible). Inténtalo de nuevo más tarde.'
|
||||
return 'deliveryFailed'
|
||||
case 'notAuthenticated':
|
||||
return 'Tu sesión ha expirado. Inicia sesión de nuevo.'
|
||||
return 'notAuthenticated'
|
||||
default:
|
||||
return 'No se pudo completar la operación. Inténtalo de nuevo.'
|
||||
return 'generic'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +81,8 @@ function PasswordInput({
|
||||
|
||||
/* ---------------------------------- VENTA ---------------------------------- */
|
||||
function SellForm({ characters, visible }: { characters: CharOption[]; visible: boolean }) {
|
||||
const t = useTranslations('Points')
|
||||
const tradeError = (error?: string) => t(`trade.errors.${tradeErrorKey(error)}`)
|
||||
const [points, setPoints] = useState('')
|
||||
const [gold, setGold] = useState('')
|
||||
const [character, setCharacter] = useState('')
|
||||
@@ -113,7 +116,7 @@ function SellForm({ characters, visible }: { characters: CharOption[]; visible:
|
||||
const data: { success?: boolean; error?: string; code?: { code: string; points: number; gold: number } } = await res.json()
|
||||
if (data.success && data.code) {
|
||||
setCreated(data.code)
|
||||
setMessage({ ok: true, text: '¡Código creado!' })
|
||||
setMessage({ ok: true, text: t('trade.codeCreated') })
|
||||
setPoints('')
|
||||
setGold('')
|
||||
setPassword('')
|
||||
@@ -133,18 +136,18 @@ function SellForm({ characters, visible }: { characters: CharOption[]; visible:
|
||||
<form onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<table className="middle-center-table">
|
||||
<tbody>
|
||||
<tr><td>¿Cuánto valdrá el código en PD?</td></tr>
|
||||
<tr><td><input type="number" maxLength={5} min="1" max="99999" step="1" value={points} onChange={(e) => setPoints(e.target.value)} placeholder="Valor del código en PD" required /></td></tr>
|
||||
<tr><td>¿Cuánto costará el código en oro?</td></tr>
|
||||
<tr><td><input type="number" maxLength={5} min="1" max="99999" step="1" value={gold} onChange={(e) => setGold(e.target.value)} placeholder="Valor del código en oro" required /></td></tr>
|
||||
<tr><td>¿A qué personaje se enviará el oro?</td></tr>
|
||||
<tr><td>{t('trade.sellPointsLabel')}</td></tr>
|
||||
<tr><td><input type="number" maxLength={5} min="1" max="99999" step="1" value={points} onChange={(e) => setPoints(e.target.value)} placeholder={t('trade.sellPointsPlaceholder')} required /></td></tr>
|
||||
<tr><td>{t('trade.sellGoldLabel')}</td></tr>
|
||||
<tr><td><input type="number" maxLength={5} min="1" max="99999" step="1" value={gold} onChange={(e) => setGold(e.target.value)} placeholder={t('trade.sellGoldPlaceholder')} required /></td></tr>
|
||||
<tr><td>{t('trade.sellCharLabel')}</td></tr>
|
||||
<tr><td>
|
||||
<CharacterSelect characters={characters} value={character} onChange={setCharacter} placeholder="Selecciona un personaje" />
|
||||
<CharacterSelect characters={characters} value={character} onChange={setCharacter} placeholder={t('trade.selectCharacter')} />
|
||||
</td></tr>
|
||||
<tr><td><br /><PasswordInput id="password-sell" value={password} onChange={setPassword} placeholder="Contraseña de tu cuenta" maxLength={16} /></td></tr>
|
||||
<tr><td><input type="text" maxLength={6} value={token} onChange={(e) => setToken(e.target.value)} placeholder="Token de seguridad" required /></td></tr>
|
||||
<tr><td><br /><PasswordInput id="password-sell" value={password} onChange={setPassword} placeholder={t('trade.passwordPlaceholder')} maxLength={16} /></td></tr>
|
||||
<tr><td><input type="text" maxLength={6} value={token} onChange={(e) => setToken(e.target.value)} placeholder={t('trade.tokenPlaceholder')} required /></td></tr>
|
||||
<tr><td><Turnstile onVerify={setCaptcha} /></td></tr>
|
||||
<tr><td><button type="submit" className="trade-points-sell-button" disabled={busy}>{busy ? 'Creando…' : 'Crear código'}</button></td></tr>
|
||||
<tr><td><button type="submit" className="trade-points-sell-button" disabled={busy}>{busy ? t('trade.creating') : t('trade.createButton')}</button></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
@@ -153,11 +156,11 @@ function SellForm({ characters, visible }: { characters: CharOption[]; visible:
|
||||
{message && <span className={message.ok ? 'ok-form-response' : 'red-form-response'}>{message.text}</span>}
|
||||
{created && (
|
||||
<fieldset>
|
||||
<legend>Código creado</legend>
|
||||
<legend>{t('trade.createdLegend')}</legend>
|
||||
<div className="separate2">
|
||||
<p>ID del código: <span className="green-info2">{created.code}</span></p>
|
||||
<p>Valor: <span>{created.points} PD</span> por <span>{created.gold} de oro</span></p>
|
||||
<p className="second-brown">Envía este ID al comprador. El código caduca en 1 hora.</p>
|
||||
<p>{t('trade.codeIdLabel')} <span className="green-info2">{created.code}</span></p>
|
||||
<p>{t.rich('trade.createdValue', { points: created.points, gold: created.gold, s: (c) => <span>{c}</span> })}</p>
|
||||
<p className="second-brown">{t('trade.createdHint')}</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
@@ -168,6 +171,8 @@ function SellForm({ characters, visible }: { characters: CharOption[]; visible:
|
||||
|
||||
/* ---------------------------------- COMPRA --------------------------------- */
|
||||
function BuyForm({ characters, visible }: { characters: CharOption[]; visible: boolean }) {
|
||||
const t = useTranslations('Points')
|
||||
const tradeError = (error?: string) => t(`trade.errors.${tradeErrorKey(error)}`)
|
||||
const [code, setCode] = useState('')
|
||||
const [character, setCharacter] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -192,7 +197,7 @@ function BuyForm({ characters, visible }: { characters: CharOption[]; visible: b
|
||||
const data: { success?: boolean; error?: string; points?: number; gold?: number } = await res.json()
|
||||
if (data.success && data.points != null && data.gold != null) {
|
||||
setCheckInfo({ points: data.points, gold: data.gold })
|
||||
setMessage({ ok: true, text: `Este código vale ${data.points} PD y cuesta ${data.gold} de oro.` })
|
||||
setMessage({ ok: true, text: t('trade.checkResult', { points: data.points, gold: data.gold }) })
|
||||
} else {
|
||||
setMessage({ ok: false, text: tradeError(data.error) })
|
||||
}
|
||||
@@ -206,7 +211,7 @@ function BuyForm({ characters, visible }: { characters: CharOption[]; visible: b
|
||||
async function handleRedeem(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
if (busy) return
|
||||
if (!window.confirm('Vas a canjear este código. Esta acción no es reversible. ¿Deseas continuar?')) return
|
||||
if (!window.confirm(t('trade.redeemConfirm'))) return
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
try {
|
||||
@@ -218,7 +223,7 @@ function BuyForm({ characters, visible }: { characters: CharOption[]; visible: b
|
||||
})
|
||||
const data: { success?: boolean; error?: string; points?: number; gold?: number } = await res.json()
|
||||
if (data.success) {
|
||||
setMessage({ ok: true, text: `¡Canje correcto! Recibiste ${data.points} PD y se retiraron ${data.gold} de oro de ${character}.` })
|
||||
setMessage({ ok: true, text: t('trade.redeemSuccess', { points: data.points ?? 0, gold: data.gold ?? 0, character }) })
|
||||
setCode('')
|
||||
setPassword('')
|
||||
setToken('')
|
||||
@@ -238,20 +243,20 @@ function BuyForm({ characters, visible }: { characters: CharOption[]; visible: b
|
||||
<form onSubmit={handleRedeem} acceptCharset="utf-8">
|
||||
<table className="middle-center-table">
|
||||
<tbody>
|
||||
<tr><td>Ingresa el ID del código de PD</td></tr>
|
||||
<tr><td><input type="text" maxLength={25} value={code} onChange={(e) => setCode(e.target.value)} placeholder="Código de PD" required /></td></tr>
|
||||
<tr><td>{t('trade.buyCodeLabel')}</td></tr>
|
||||
<tr><td><input type="text" maxLength={25} value={code} onChange={(e) => setCode(e.target.value)} placeholder={t('trade.buyCodePlaceholder')} required /></td></tr>
|
||||
{checkInfo && (
|
||||
<tr><td><p className="green-info2">Vale {checkInfo.points} PD · Cuesta {checkInfo.gold} de oro</p></td></tr>
|
||||
<tr><td><p className="green-info2">{t('trade.checkInfo', { points: checkInfo.points, gold: checkInfo.gold })}</p></td></tr>
|
||||
)}
|
||||
<tr><td>¿De qué personaje se cobrará el oro?</td></tr>
|
||||
<tr><td>{t('trade.buyCharLabel')}</td></tr>
|
||||
<tr><td>
|
||||
<CharacterSelect characters={characters} value={character} onChange={setCharacter} placeholder="Selecciona un personaje" />
|
||||
<CharacterSelect characters={characters} value={character} onChange={setCharacter} placeholder={t('trade.selectCharacter')} />
|
||||
</td></tr>
|
||||
<tr><td><br /><PasswordInput id="password-buy" value={password} onChange={setPassword} placeholder="Contraseña de tu cuenta" maxLength={16} /></td></tr>
|
||||
<tr><td><input type="text" maxLength={6} value={token} onChange={(e) => setToken(e.target.value)} placeholder="Token de seguridad" required /></td></tr>
|
||||
<tr><td><br /><PasswordInput id="password-buy" value={password} onChange={setPassword} placeholder={t('trade.passwordPlaceholder')} maxLength={16} /></td></tr>
|
||||
<tr><td><input type="text" maxLength={6} value={token} onChange={(e) => setToken(e.target.value)} placeholder={t('trade.tokenPlaceholder')} required /></td></tr>
|
||||
<tr><td><Turnstile onVerify={setCaptcha} /></td></tr>
|
||||
<tr><td><button type="button" className="trade-points-check-button" disabled={busy} onClick={handleCheck}>Chequear código</button></td></tr>
|
||||
<tr><td><button type="submit" className="trade-points-buy-button" disabled={busy}>{busy ? 'Procesando…' : 'Canjear código'}</button></td></tr>
|
||||
<tr><td><button type="button" className="trade-points-check-button" disabled={busy} onClick={handleCheck}>{t('trade.checkButton')}</button></td></tr>
|
||||
<tr><td><button type="submit" className="trade-points-buy-button" disabled={busy}>{busy ? t('trade.processing') : t('trade.redeemButton')}</button></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</form>
|
||||
@@ -264,6 +269,7 @@ function BuyForm({ characters, visible }: { characters: CharOption[]; visible: b
|
||||
}
|
||||
|
||||
export function TradePointsForm({ characters }: { characters: CharOption[] }) {
|
||||
const t = useTranslations('Points')
|
||||
// Como el original: al cargar no se muestra ningún formulario; se revela al
|
||||
// pulsar VENTA o COMPRA.
|
||||
const [active, setActive] = useState<'sell' | 'buy' | null>(null)
|
||||
@@ -272,7 +278,7 @@ export function TradePointsForm({ characters }: { characters: CharOption[] }) {
|
||||
return (
|
||||
<div className="centered">
|
||||
<br />
|
||||
<span>No tienes personajes en esta cuenta.</span>
|
||||
<span>{t('trade.noCharacters')}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -287,7 +293,7 @@ export function TradePointsForm({ characters }: { characters: CharOption[] }) {
|
||||
className={`trade-option-button${active === 'sell' ? ' dnt-selected' : ''}`}
|
||||
onClick={() => setActive('sell')}
|
||||
>
|
||||
VENTA
|
||||
{t('trade.sellTab')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -295,7 +301,7 @@ export function TradePointsForm({ characters }: { characters: CharOption[] }) {
|
||||
className={`trade-option-button${active === 'buy' ? ' dnt-selected' : ''}`}
|
||||
onClick={() => setActive('buy')}
|
||||
>
|
||||
COMPRA
|
||||
{t('trade.buyTab')}
|
||||
</button>
|
||||
<br />
|
||||
<SellForm characters={characters} visible={active === 'sell'} />
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { useRouter } from '@/i18n/navigation'
|
||||
|
||||
function transferError(error?: string): string {
|
||||
function transferErrorKey(error?: string): string {
|
||||
switch (error) {
|
||||
case 'missingFields':
|
||||
return 'Rellena el personaje de destino, la cantidad y el token de seguridad.'
|
||||
case 'invalidAmount':
|
||||
return 'La cantidad debe ser un número entero mayor que 0.'
|
||||
case 'invalidToken':
|
||||
return 'El token de seguridad no es válido.'
|
||||
case 'characterNotFound':
|
||||
return 'No existe ningún personaje con ese nombre.'
|
||||
case 'sameAccount':
|
||||
return 'No puedes transferirte PD a tu propia cuenta.'
|
||||
case 'insufficientFunds':
|
||||
return 'No tienes suficientes PD para realizar esta transferencia.'
|
||||
case 'notAuthenticated':
|
||||
return 'Tu sesión ha expirado. Inicia sesión de nuevo.'
|
||||
return error
|
||||
default:
|
||||
return 'No se pudo completar la transferencia. Inténtalo de nuevo.'
|
||||
return 'generic'
|
||||
}
|
||||
}
|
||||
|
||||
export function TransferDPointsForm({ balance }: { balance: number }) {
|
||||
const t = useTranslations('Points')
|
||||
const transferError = (error?: string) => t(`transferDp.errors.${transferErrorKey(error)}`)
|
||||
const router = useRouter()
|
||||
const [character, setCharacter] = useState('')
|
||||
const [amount, setAmount] = useState('')
|
||||
@@ -37,7 +34,7 @@ export function TransferDPointsForm({ balance }: { balance: number }) {
|
||||
<div className="centered">
|
||||
<br />
|
||||
<br />
|
||||
<span>No tienes PD</span>
|
||||
<span>{t('transferDp.noPd')}</span>
|
||||
<br />
|
||||
</div>
|
||||
)
|
||||
@@ -59,7 +56,7 @@ export function TransferDPointsForm({ balance }: { balance: number }) {
|
||||
setMessage({ ok: false, text: transferError('insufficientFunds') })
|
||||
return
|
||||
}
|
||||
if (!window.confirm(`Vas a transferir ${n} PD al personaje "${character.trim()}".\nEsta acción no es reversible. ¿Deseas continuar?`)) return
|
||||
if (!window.confirm(t('transferDp.confirm', { n, character: character.trim() }))) return
|
||||
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
@@ -72,7 +69,7 @@ export function TransferDPointsForm({ balance }: { balance: number }) {
|
||||
})
|
||||
const data: { success?: boolean; error?: string } = await res.json()
|
||||
if (data.success) {
|
||||
setMessage({ ok: true, text: `Has transferido ${n} PD al personaje "${character.trim()}".` })
|
||||
setMessage({ ok: true, text: t('transferDp.success', { n, character: character.trim() }) })
|
||||
setCharacter('')
|
||||
setAmount('')
|
||||
setToken('')
|
||||
@@ -90,12 +87,12 @@ export function TransferDPointsForm({ balance }: { balance: number }) {
|
||||
return (
|
||||
<div className="centered">
|
||||
<br />
|
||||
<p className="yellow-info centered">Tienes {balance} PD disponibles</p>
|
||||
<p className="yellow-info centered">{t('transferDp.available', { balance })}</p>
|
||||
<br />
|
||||
<form onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nombre del personaje de destino"
|
||||
placeholder={t('transferDp.characterPlaceholder')}
|
||||
value={character}
|
||||
onChange={(e) => setCharacter(e.target.value)}
|
||||
required
|
||||
@@ -103,7 +100,7 @@ export function TransferDPointsForm({ balance }: { balance: number }) {
|
||||
<br />
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Cantidad de PD"
|
||||
placeholder={t('transferDp.amountPlaceholder')}
|
||||
min="1"
|
||||
max={balance}
|
||||
step="1"
|
||||
@@ -114,7 +111,7 @@ export function TransferDPointsForm({ balance }: { balance: number }) {
|
||||
<br />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Token de seguridad"
|
||||
placeholder={t('transferDp.tokenPlaceholder')}
|
||||
maxLength={6}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
@@ -122,7 +119,7 @@ export function TransferDPointsForm({ balance }: { balance: number }) {
|
||||
/>
|
||||
<br />
|
||||
<button type="submit" className="transfer-button" disabled={busy}>
|
||||
{busy ? 'Transfiriendo…' : 'TRANSFERIR PD'}
|
||||
{busy ? t('transferDp.transferring') : t('transferDp.transferButton')}
|
||||
</button>
|
||||
</form>
|
||||
<hr />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { CharacterSelect, type CharOption } from '@/components/CharacterSelect'
|
||||
|
||||
/** Campo con ojo para mostrar/ocultar (contraseña / token). */
|
||||
@@ -42,6 +43,7 @@ function SecretInput({
|
||||
}
|
||||
|
||||
export function TransferForm({ characters, price }: { characters: CharOption[]; price: number }) {
|
||||
const t = useTranslations('CharService')
|
||||
const [character, setCharacter] = useState('')
|
||||
const [destination, setDestination] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -52,7 +54,7 @@ export function TransferForm({ characters, price }: { characters: CharOption[];
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (busy || !character || !destination.trim() || !password || !token.trim()) return
|
||||
if (!window.confirm(`¿Estás seguro de transferir el personaje "${character}" a la cuenta ${destination.trim()} por ${price} € (SumUp)?`)) return
|
||||
if (!window.confirm(t('transfer.confirm', { character, destination: destination.trim(), price }))) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -71,30 +73,30 @@ export function TransferForm({ characters, price }: { characters: CharOption[];
|
||||
const data: { success?: boolean; url?: string; message?: string } = await res.json()
|
||||
if (data.success && data.url) window.location.href = data.url
|
||||
else {
|
||||
setError(data.message || 'No se pudo iniciar la transferencia. Inténtalo de nuevo.')
|
||||
setError(data.message || t('transfer.errorGeneric'))
|
||||
setBusy(false)
|
||||
}
|
||||
} catch {
|
||||
setError('Algo ha salido mal. Inténtalo más tarde.')
|
||||
setError(t('transfer.errorUnexpected'))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (characters.length === 0) return <p className="second-brown">No tienes personajes disponibles.</p>
|
||||
if (characters.length === 0) return <p className="second-brown">{t('transfer.noCharacters')}</p>
|
||||
|
||||
return (
|
||||
<div className="centered">
|
||||
<form id="uw-transfer-character-form" onSubmit={handleSubmit} acceptCharset="utf-8">
|
||||
<CharacterSelect characters={characters} value={character} onChange={setCharacter} placeholder="Selecciona un personaje" />
|
||||
<CharacterSelect characters={characters} value={character} onChange={setCharacter} placeholder={t('transfer.selectPlaceholder')} />
|
||||
<br />
|
||||
<input type="text" maxLength={32} placeholder="Cuenta de destino" value={destination} onChange={(e) => setDestination(e.target.value)} required />
|
||||
<input type="text" maxLength={32} placeholder={t('transfer.destinationPlaceholder')} value={destination} onChange={(e) => setDestination(e.target.value)} required />
|
||||
<br />
|
||||
<SecretInput id="password" value={password} onChange={setPassword} placeholder="Contraseña de tu cuenta" maxLength={16} toggleClass="toggle-password" />
|
||||
<SecretInput id="password" value={password} onChange={setPassword} placeholder={t('transfer.passwordPlaceholder')} maxLength={16} toggleClass="toggle-password" />
|
||||
<br />
|
||||
<SecretInput id="security-token" value={token} onChange={setToken} placeholder="Token de seguridad" maxLength={6} toggleClass="toggle-token" />
|
||||
<SecretInput id="security-token" value={token} onChange={setToken} placeholder={t('transfer.tokenPlaceholder')} maxLength={6} toggleClass="toggle-token" />
|
||||
<br />
|
||||
<button type="submit" className="transfer-button" disabled={busy || !character || !destination.trim() || !password || !token.trim()}>
|
||||
{busy ? 'Transfiriendo personaje' : 'TRANSFERIR PERSONAJE'}
|
||||
{busy ? t('transfer.submitting') : t('transfer.submit')}
|
||||
</button>
|
||||
</form>
|
||||
<hr />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
const LOGOS = '/nw-themes/nw-ryu/nw-images/nw-logos'
|
||||
|
||||
@@ -18,13 +19,14 @@ async function post(action: string, extra: Record<string, string>): Promise<{ su
|
||||
|
||||
/** Ojo para mostrar/ocultar un campo de contraseña/token. */
|
||||
function EyeToggle({ shown, onToggle }: { shown: boolean; onToggle: () => void }) {
|
||||
const t = useTranslations('UI')
|
||||
return (
|
||||
<span
|
||||
className={`far ${shown ? 'fa-eye-slash' : 'fa-eye'} toggle-password`}
|
||||
onClick={onToggle}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={shown ? 'Ocultar' : 'Mostrar'}
|
||||
aria-label={shown ? t('twofa.eyeHide') : t('twofa.eyeShow')}
|
||||
></span>
|
||||
)
|
||||
}
|
||||
@@ -41,6 +43,7 @@ export function TwoFactorLogin({ enabled }: { enabled: boolean }) {
|
||||
|
||||
/* ------------------------------ Activar + QR ------------------------------- */
|
||||
function ActivateFlow() {
|
||||
const t = useTranslations('UI')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [token, setToken] = useState('')
|
||||
@@ -66,7 +69,7 @@ function ActivateFlow() {
|
||||
setMsg({ success: !!d.success, text: d.message })
|
||||
if (d.success && d.qrCodeUrl && d.secretKey) setQr({ qrCodeUrl: d.qrCodeUrl, secretKey: d.secretKey })
|
||||
} catch {
|
||||
setMsg({ success: false, text: 'Algo ha salido mal. Por favor intente más tarde.' })
|
||||
setMsg({ success: false, text: t('twofa.errGeneric') })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -82,7 +85,7 @@ function ActivateFlow() {
|
||||
setVMsg({ success: !!d.success, text: d.message })
|
||||
if (d.success) setDone(true)
|
||||
} catch {
|
||||
setVMsg({ success: false, text: 'Algo ha salido mal. Por favor intente más tarde.' })
|
||||
setVMsg({ success: false, text: t('twofa.errGeneric') })
|
||||
} finally {
|
||||
setVBusy(false)
|
||||
}
|
||||
@@ -108,7 +111,7 @@ function ActivateFlow() {
|
||||
maxLength={16}
|
||||
name="password"
|
||||
id="password"
|
||||
placeholder="Contraseña"
|
||||
placeholder={t('twofa.passwordPlaceholder')}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={!!qr}
|
||||
@@ -123,7 +126,7 @@ function ActivateFlow() {
|
||||
maxLength={6}
|
||||
name="security-token"
|
||||
id="security-token"
|
||||
placeholder="Token de seguridad"
|
||||
placeholder={t('twofa.tokenPlaceholder')}
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
disabled={!!qr}
|
||||
@@ -135,7 +138,7 @@ function ActivateFlow() {
|
||||
<tr>
|
||||
<td>
|
||||
<button type="submit" className="activate-2fa-button" disabled={busy}>
|
||||
{busy ? 'Activando 2FA' : 'Activar 2FA'}
|
||||
{busy ? t('twofa.activating') : t('twofa.activate')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -152,7 +155,7 @@ function ActivateFlow() {
|
||||
|
||||
{qr && (
|
||||
<div id="qr-code-container">
|
||||
<p>Paso 1) Descarga la aplicación de autenticación:</p>
|
||||
<p>{t('twofa.step1')}</p>
|
||||
<a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2" target="_blank" rel="noopener noreferrer" title="Google Authenticator (Android)">
|
||||
<img src={`${LOGOS}/google-play.png`} alt="Google Play" className="google-play-logo" />
|
||||
</a>
|
||||
@@ -161,16 +164,16 @@ function ActivateFlow() {
|
||||
</a>
|
||||
<br />
|
||||
<br />
|
||||
<p>Paso 2) Escanea este código QR con tu aplicación de autenticación:</p>
|
||||
<p>{t('twofa.step2')}</p>
|
||||
<img id="qr-code" src={qr.qrCodeUrl} alt="QR Code" />
|
||||
<br />
|
||||
<br />
|
||||
<p>Si no puedes escanear el código QR copia y pega esta clave en tu aplicación de autenticación:</p>
|
||||
<p>{t('twofa.copyKeyNote')}</p>
|
||||
<div className="copy-secret-container">
|
||||
<input type="text" id="secret-key" className="centered" value={qr.secretKey} readOnly />
|
||||
<span
|
||||
className={`fa ${copied ? 'fa-check' : 'fa-copy'} copy-btn second-brown`}
|
||||
title={copied ? '¡Copiado!' : 'Copiar clave'}
|
||||
title={copied ? t('twofa.copied') : t('twofa.copyKey')}
|
||||
onClick={copySecret}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -178,7 +181,7 @@ function ActivateFlow() {
|
||||
</div>
|
||||
<br />
|
||||
<br />
|
||||
<p>Paso 3) Ingresa el código generado por tu aplicación de autenticación:</p>
|
||||
<p>{t('twofa.step3')}</p>
|
||||
<form onSubmit={verify} id="uw-2fa-verify-form" autoComplete="off">
|
||||
<table className="middle-center-table">
|
||||
<tbody>
|
||||
@@ -189,7 +192,7 @@ function ActivateFlow() {
|
||||
maxLength={6}
|
||||
name="authenticator-code"
|
||||
id="authenticator-code"
|
||||
placeholder="Código de 6 dígitos"
|
||||
placeholder={t('twofa.codePlaceholder')}
|
||||
required
|
||||
className="centered"
|
||||
inputMode="numeric"
|
||||
@@ -203,7 +206,7 @@ function ActivateFlow() {
|
||||
<tr>
|
||||
<td>
|
||||
<button type="submit" className="verify-2fa-button" disabled={vBusy || done}>
|
||||
{done ? 'Verificado ✔️' : vBusy ? 'Verificando...' : 'Verificar código'}
|
||||
{done ? t('twofa.verified') : vBusy ? t('twofa.verifying') : t('twofa.verifyCode')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -225,6 +228,7 @@ function ActivateFlow() {
|
||||
|
||||
/* ------------------------------- Desactivar -------------------------------- */
|
||||
function DisableForm() {
|
||||
const t = useTranslations('UI')
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [msg, setMsg] = useState<Msg>(null)
|
||||
@@ -240,7 +244,7 @@ function DisableForm() {
|
||||
setMsg({ success: !!d.success, text: d.message })
|
||||
if (d.success) setDone(true)
|
||||
} catch {
|
||||
setMsg({ success: false, text: 'Algo ha salido mal. Por favor intente más tarde.' })
|
||||
setMsg({ success: false, text: t('twofa.errGeneric') })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
@@ -248,8 +252,8 @@ function DisableForm() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="green-info"><i className="fas fa-shield-alt"></i> La verificación en 2 pasos está activada en esta cuenta.</p>
|
||||
<p>Para desactivarla, introduce un código actual de tu aplicación de autenticación.</p>
|
||||
<p className="green-info"><i className="fas fa-shield-alt"></i> {t('twofa.enabledNotice')}</p>
|
||||
<p>{t('twofa.disableInstruction')}</p>
|
||||
<br />
|
||||
<form onSubmit={disable} id="uw-2fa-disable-form">
|
||||
<table className="middle-center-table">
|
||||
@@ -260,7 +264,7 @@ function DisableForm() {
|
||||
type="text"
|
||||
maxLength={6}
|
||||
name="authenticator-code"
|
||||
placeholder="Código de 6 dígitos"
|
||||
placeholder={t('twofa.codePlaceholder')}
|
||||
required
|
||||
className="centered"
|
||||
inputMode="numeric"
|
||||
@@ -274,7 +278,7 @@ function DisableForm() {
|
||||
<tr>
|
||||
<td>
|
||||
<button type="submit" className="deactivate-2fa-button" disabled={busy || done}>
|
||||
{done ? '2FA desactivado ✔️' : busy ? 'Desactivando...' : 'Desactivar 2FA'}
|
||||
{done ? t('twofa.disabled') : busy ? t('twofa.disabling') : t('twofa.disable')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
Reference in New Issue
Block a user