web-next: change-race/faction/level-up/gold por SumUp + rastreador de misiones
- /change-race-character (3€), /change-faction-character (5€, valida condiciones: sin hermandad/correo/subastas/capitán-arena y oro <= cap por nivel), /level-up-character (10€), /gold-character (oro por correo SOAP). Cada uno borra su ruta antigua y actualiza el link del panel. - SumUp: hook precheck en PaidServiceConfig (condiciones cambio de facción); columna home_stripelog.metadata para campos extra (gold_amount). - /quest-character: rastreador de estado de misiones/cadenas (clase, Hijos de Hodir, misión por ID) en personaje offline; cooldowns por categoría, Turnstile, IDs de misión verificados en wotlkdb 3.3.5a. Tabla home_questsearchhistory. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getGameCharacters } from '@/lib/characters'
|
||||
import { getChangeFactionPrice } from '@/lib/prices'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { ServiceBox } from '@/components/ServiceBox'
|
||||
import { PaidServiceForm } from '@/components/PaidServiceForm'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const metadata: Metadata = { title: 'Cambiar facción del personaje' }
|
||||
|
||||
const GOLD_ICON = '/nw-themes/nw-ryu/nw-images/nw-icons/money-gold.gif'
|
||||
|
||||
function GoldCell({ amount }: { amount: number }) {
|
||||
return (
|
||||
<td>
|
||||
{amount}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={GOLD_ICON} width="10" alt="oro" />
|
||||
</td>
|
||||
)
|
||||
}
|
||||
|
||||
export default async function ChangeFactionCharacterPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
|
||||
const session = await getSession()
|
||||
if (!session.bnetId) redirect({ href: '/login', locale })
|
||||
if (!session.username) redirect({ href: '/select-account', locale })
|
||||
|
||||
const [chars, price] = await Promise.all([getGameCharacters(session.accountId!), getChangeFactionPrice()])
|
||||
|
||||
return (
|
||||
<PageShell title="Cambiar facción del personaje">
|
||||
<ServiceBox>
|
||||
<br />
|
||||
<p>
|
||||
La herramienta <span>Cambiar facción del personaje</span> te permite cambiar de la Alianza a la Horda y
|
||||
viceversa.
|
||||
</p>
|
||||
<p>Si también deseas cambiar el nombre del personaje, debes usar la herramienta Renombrar personaje.</p>
|
||||
<br />
|
||||
<p>Al hacer un cambio de Facción:</p>
|
||||
<p>- Los items, hechizos, títulos, reputaciones, monturas y logros se cambiarán a la nueva facción</p>
|
||||
<p>- Las misiones activas en el Registro de misiones serán abandonadas</p>
|
||||
<p>- Los teams de arena serán borrados</p>
|
||||
<p>- Los amigos en la Lista de amigos se borrarán</p>
|
||||
<br />
|
||||
<p><span className="red-info2">Restricciones del personaje:</span></p>
|
||||
<p>- No debe tener subastas activas</p>
|
||||
<p>- No debe tener correos en el buzón</p>
|
||||
<p>- No debe ser capitán de un team de arenas</p>
|
||||
<p>- No debe ser miembro de una hermandad</p>
|
||||
<p>- No debe tener demasiado oro</p>
|
||||
<br />
|
||||
<table className="max-gold-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Nivel</th>
|
||||
<th>Max. Oro</th>
|
||||
</tr>
|
||||
<tr><td>10-30</td><GoldCell amount={300} /></tr>
|
||||
<tr><td>31-50</td><GoldCell amount={1000} /></tr>
|
||||
<tr><td>51-70</td><GoldCell amount={5000} /></tr>
|
||||
<tr><td>71-80</td><GoldCell amount={20000} /></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<br />
|
||||
<p>
|
||||
Al usar el botón CAMBIAR FACCIÓN del personaje que hayas escogido, se enviará una petición de cambio de
|
||||
facción de dicho personaje.
|
||||
</p>
|
||||
<p>Cuando ingreses a la lista de personajes de tu cuenta, verás un ícono a la izquierda de tu personaje escogido.</p>
|
||||
<p>Al darle click a dicho ícono ingresarás al menú de edición de personaje que te permitirá pasar a la facción contraria.</p>
|
||||
<br />
|
||||
<fieldset>
|
||||
<legend>NOTA</legend>
|
||||
<div className="separate2">
|
||||
<p>
|
||||
Por favor, asegúrate de haber escogido al personaje correcto ya que esta acción no es reversible una vez
|
||||
realizada.
|
||||
</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="centered">
|
||||
<br />
|
||||
<br />
|
||||
<p>Requiere <span>{price}</span> <span className="yellow-info">€</span> (SumUp)</p>
|
||||
</div>
|
||||
|
||||
<PaidServiceForm
|
||||
characters={chars.map((c) => ({ name: c.name, classCss: c.classCss }))}
|
||||
checkoutEndpoint="/api/character/change-faction/checkout"
|
||||
payLabel="CAMBIAR FACCIÓN"
|
||||
buttonClass="change-faction-button"
|
||||
provider="sumup"
|
||||
confirmText={`¿Estás seguro de cambiar de facción al personaje seleccionado por ${price} € (SumUp)?`}
|
||||
/>
|
||||
</ServiceBox>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { ServicePageContent } from '@/components/ServicePageContent'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
return <ServicePageContent service="change-faction" locale={locale} />
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getGameCharacters } from '@/lib/characters'
|
||||
import { getChangeRacePrice } from '@/lib/prices'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { ServiceBox } from '@/components/ServiceBox'
|
||||
import { PaidServiceForm } from '@/components/PaidServiceForm'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const metadata: Metadata = { title: 'Cambiar raza del personaje' }
|
||||
|
||||
export default async function ChangeRaceCharacterPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
|
||||
const session = await getSession()
|
||||
if (!session.bnetId) redirect({ href: '/login', locale })
|
||||
if (!session.username) redirect({ href: '/select-account', locale })
|
||||
|
||||
const [chars, price] = await Promise.all([getGameCharacters(session.accountId!), getChangeRacePrice()])
|
||||
|
||||
return (
|
||||
<PageShell title="Cambiar raza del personaje">
|
||||
<ServiceBox>
|
||||
<br />
|
||||
<p className="red-info2 h2">
|
||||
<i className="fas fa-exclamation-triangle"></i> ESTE CAMBIO NO PERMITE CAMBIAR A LA FACCIÓN CONTRARIA{' '}
|
||||
<i className="fas fa-exclamation-triangle"></i>
|
||||
</p>
|
||||
<p>
|
||||
La herramienta <span>Cambiar raza del personaje</span> te permite cambiar la raza del personaje entre otras de
|
||||
la MISMA facción.
|
||||
</p>
|
||||
<p>Si también deseas cambiar el nombre del personaje, debes usar la herramienta Renombrar personaje.</p>
|
||||
<br />
|
||||
<p>
|
||||
Al usar el botón CAMBIAR RAZA del personaje que hayas escogido, se enviará una petición de cambio de raza de
|
||||
dicho personaje.
|
||||
</p>
|
||||
<p>Cuando ingreses a la lista de personajes de tu cuenta, verás un ícono a la izquierda de tu personaje escogido.</p>
|
||||
<p>
|
||||
Al darle click a dicho ícono ingresarás al menú de edición de personaje que te permitirá escoger entre las
|
||||
razas de la misma facción.
|
||||
</p>
|
||||
<br />
|
||||
<fieldset>
|
||||
<legend>NOTA</legend>
|
||||
<div className="separate2">
|
||||
<p>
|
||||
Por favor, asegúrate de haber escogido al personaje correcto ya que esta acción no es reversible una vez
|
||||
realizada.
|
||||
</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="centered">
|
||||
<br />
|
||||
<br />
|
||||
<p>Requiere <span>{price}</span> <span className="yellow-info">€</span> (SumUp)</p>
|
||||
</div>
|
||||
|
||||
<PaidServiceForm
|
||||
characters={chars.map((c) => ({ name: c.name, classCss: c.classCss }))}
|
||||
checkoutEndpoint="/api/character/change-race/checkout"
|
||||
payLabel="CAMBIAR RAZA"
|
||||
buttonClass="change-race-button"
|
||||
provider="sumup"
|
||||
confirmText={`¿Estás seguro de cambiar de raza al personaje seleccionado por ${price} € (SumUp)?`}
|
||||
/>
|
||||
</ServiceBox>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { ServicePageContent } from '@/components/ServicePageContent'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
return <ServicePageContent service="change-race" locale={locale} />
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getGameCharacters } from '@/lib/characters'
|
||||
import { getGoldOptions } from '@/lib/prices'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { ServiceBox } from '@/components/ServiceBox'
|
||||
import { GoldForm } from '@/components/GoldForm'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const metadata: Metadata = { title: 'Adquirir oro' }
|
||||
|
||||
const GOLD_ICON = '/nw-themes/nw-ryu/nw-images/nw-icons/money-gold.gif'
|
||||
|
||||
export default async function GoldCharacterPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
|
||||
const session = await getSession()
|
||||
if (!session.bnetId) redirect({ href: '/login', locale })
|
||||
if (!session.username) redirect({ href: '/select-account', locale })
|
||||
|
||||
const [chars, options] = await Promise.all([getGameCharacters(session.accountId!), getGoldOptions()])
|
||||
|
||||
return (
|
||||
<PageShell title="Adquirir oro">
|
||||
<ServiceBox>
|
||||
<br />
|
||||
<p>
|
||||
La herramienta <span>Adquirir oro</span> te permite enviar oro a un personaje.
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
Al usar el botón ENVIAR ORO del personaje que hayas escogido, se enviará un correo con la cantidad de oro
|
||||
elegida al personaje.
|
||||
</p>
|
||||
<p>Cuando entres al reino con dicho personaje, tendrás el mensaje con la cantidad de oro seleccionada en tu buzón.</p>
|
||||
<br />
|
||||
<fieldset>
|
||||
<legend>NOTA</legend>
|
||||
<div className="separate2">
|
||||
<p>
|
||||
Por favor, asegúrate de haber escogido al personaje correcto ya que esta acción no es reversible una vez
|
||||
realizada.
|
||||
</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="centered">
|
||||
<br />
|
||||
<br />
|
||||
<p>Requiere</p>
|
||||
<table className="gold-table">
|
||||
<tbody>
|
||||
{options.map((o) => (
|
||||
<tr key={o.gold_amount}>
|
||||
<td>
|
||||
<span>
|
||||
{o.gold_amount}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={GOLD_ICON} width="10" alt="oro" /> =
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span>{o.price}</span>
|
||||
<span className="yellow-info"> €</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<GoldForm characters={chars.map((c) => ({ name: c.name, classCss: c.classCss }))} options={options} />
|
||||
</ServiceBox>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { getTranslations, setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getGameCharacters } from '@/lib/characters'
|
||||
import { getGoldOptions } from '@/lib/prices'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { ServiceBox } from '@/components/ServiceBox'
|
||||
import { GoldForm } from '@/components/GoldForm'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function GoldPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
const t = await getTranslations('Paid')
|
||||
const session = await getSession()
|
||||
if (!session.bnetId) redirect({ href: '/login', locale })
|
||||
if (!session.username) redirect({ href: '/select-account', locale })
|
||||
|
||||
const [chars, options] = await Promise.all([getGameCharacters(session.accountId!), getGoldOptions()])
|
||||
|
||||
return (
|
||||
<PageShell title={t('gold.title')}>
|
||||
<ServiceBox>
|
||||
<GoldForm characters={chars.map((c) => ({ name: c.name, classCss: c.classCss }))} options={options} />
|
||||
</ServiceBox>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getGameCharacters } from '@/lib/characters'
|
||||
import { getLevelUpPrice } from '@/lib/prices'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { ServiceBox } from '@/components/ServiceBox'
|
||||
import { PaidServiceForm } from '@/components/PaidServiceForm'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const metadata: Metadata = { title: 'Subir a nivel 80' }
|
||||
|
||||
export default async function LevelUpCharacterPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
|
||||
const session = await getSession()
|
||||
if (!session.bnetId) redirect({ href: '/login', locale })
|
||||
if (!session.username) redirect({ href: '/select-account', locale })
|
||||
|
||||
const [chars, price] = await Promise.all([getGameCharacters(session.accountId!), getLevelUpPrice()])
|
||||
|
||||
return (
|
||||
<PageShell title="Subir a nivel 80">
|
||||
<ServiceBox>
|
||||
<br />
|
||||
<p>
|
||||
La herramienta <span>Subir a nivel 80</span> te permite subir cualquier personaje al nivel 80.
|
||||
</p>
|
||||
<p>Sólo subirá de nivel al personaje. Esta opción no incluye ítems/oro/etc.</p>
|
||||
<br />
|
||||
<p>
|
||||
Al usar el botón SUBIR A NIVEL 80 del personaje que hayas escogido, se enviará el nivel 80 al personaje.
|
||||
</p>
|
||||
<p>Cuando ingreses a la lista de personajes de tu cuenta, verás que tu personaje escogido ya es nivel 80.</p>
|
||||
<br />
|
||||
<fieldset>
|
||||
<legend>NOTA</legend>
|
||||
<div className="separate2">
|
||||
<p>Se requiere que el personaje esté desconectado.</p>
|
||||
<p>
|
||||
Por favor, asegúrate de haber escogido al personaje correcto ya que esta acción no es reversible una vez
|
||||
realizada.
|
||||
</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="centered">
|
||||
<br />
|
||||
<br />
|
||||
<p>Requiere <span>{price}</span> <span className="yellow-info">€</span> (SumUp)</p>
|
||||
</div>
|
||||
|
||||
<PaidServiceForm
|
||||
characters={chars.map((c) => ({ name: c.name, classCss: c.classCss }))}
|
||||
checkoutEndpoint="/api/character/level-up/checkout"
|
||||
payLabel="SUBIR A NIVEL 80"
|
||||
buttonClass="level-up-button"
|
||||
provider="sumup"
|
||||
confirmText={`¿Estás seguro de subir a nivel 80 al personaje seleccionado por ${price} € (SumUp)?`}
|
||||
/>
|
||||
</ServiceBox>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { ServicePageContent } from '@/components/ServicePageContent'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
return <ServicePageContent service="level-up" locale={locale} />
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { setRequestLocale } from 'next-intl/server'
|
||||
import { redirect } from '@/i18n/navigation'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { getGameCharacters } from '@/lib/characters'
|
||||
import { QUEST_OPTIONS } from '@/lib/quest-tracker'
|
||||
import { PageShell } from '@/components/PageShell'
|
||||
import { ServiceBox } from '@/components/ServiceBox'
|
||||
import { QuestTrackerForm } from '@/components/QuestTrackerForm'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const metadata: Metadata = { title: 'Rastreador de misiones' }
|
||||
|
||||
const QUEST_DB = 'https://wotlk.novawow.com'
|
||||
const CLASS_GROUPS: { css: string; name: string }[] = [
|
||||
{ css: 'warlock', name: 'Brujo' },
|
||||
{ css: 'hunter', name: 'Cazador' },
|
||||
{ css: 'shaman', name: 'Chamán' },
|
||||
{ css: 'druid', name: 'Druida' },
|
||||
{ css: 'warrior', name: 'Guerrero' },
|
||||
{ css: 'paladin', name: 'Paladín' },
|
||||
]
|
||||
|
||||
export default async function QuestCharacterPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params
|
||||
setRequestLocale(locale)
|
||||
|
||||
const session = await getSession()
|
||||
if (!session.bnetId) redirect({ href: '/login', locale })
|
||||
if (!session.username) redirect({ href: '/select-account', locale })
|
||||
|
||||
const chars = await getGameCharacters(session.accountId!)
|
||||
const clientOptions = QUEST_OPTIONS.map((o) => ({
|
||||
key: o.key,
|
||||
classCss: o.classCss,
|
||||
label: o.label,
|
||||
minLevel: o.minLevel,
|
||||
hasData: o.quests.length > 0,
|
||||
}))
|
||||
|
||||
return (
|
||||
<PageShell title="Rastreador de misiones">
|
||||
<ServiceBox>
|
||||
<br />
|
||||
<p>
|
||||
La herramienta <span>Rastreador de misiones</span> te permite ver el estado de misiones o cadenas de misiones
|
||||
importantes en tus personajes.
|
||||
</p>
|
||||
<br />
|
||||
<p>Opciones disponibles:</p>
|
||||
{CLASS_GROUPS.map((g) => {
|
||||
const opts = QUEST_OPTIONS.filter((o) => o.classCss === g.css)
|
||||
if (opts.length === 0) return null
|
||||
return (
|
||||
<div key={g.css}>
|
||||
<p className={g.css}>{g.name}</p>
|
||||
<ul>
|
||||
{opts.map((o) => (
|
||||
<li key={o.key}>
|
||||
{o.link ? (
|
||||
<a
|
||||
href={`${QUEST_DB}/?${o.link.type}=${o.link.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="icontinyl"
|
||||
style={
|
||||
o.icon
|
||||
? {
|
||||
paddingLeft: '18px',
|
||||
background: `url("${QUEST_DB}/static/images/wow/icons/tiny/${o.icon}.gif") left center no-repeat`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{o.label}
|
||||
</a>
|
||||
) : (
|
||||
o.label
|
||||
)}{' '}
|
||||
a partir del nivel {o.minLevel}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<br />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<p className="second-brown">Por reputación</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href={`${QUEST_DB}/?faction=1119`} target="_blank" rel="noopener noreferrer">Los Hijos de Hodir</a>: Cadena
|
||||
de misiones principal hasta interactuar con el intendente{' '}
|
||||
<a href={`${QUEST_DB}/?npc=32540`} target="_blank" rel="noopener noreferrer">Lillehoff</a>
|
||||
</li>
|
||||
</ul>
|
||||
<br />
|
||||
<p className="second-brown">Por misión específica</p>
|
||||
<ul>
|
||||
<li>A partir del nivel 10</li>
|
||||
</ul>
|
||||
<br />
|
||||
<p>Cómo buscar por misión específica:</p>
|
||||
<p>1) Visita nuestra <a href={QUEST_DB} target="_blank" rel="noopener noreferrer">base de datos</a>.</p>
|
||||
<p>2) Busca allí el nombre de la misión.</p>
|
||||
<p>3) Una vez que la hayas encontrado, su enlace se verá cómo "<span className="second-brown">{QUEST_DB}/?quest=12843</span>"</p>
|
||||
<p>4) Usa el número final del enlace en el campo ID de misión (Ejemplo del enlace anterior: 12843).</p>
|
||||
<br />
|
||||
<p>Al usar el botón BUSCAR MISIONES, podrás ver el estado de las misiones del personaje.</p>
|
||||
<br />
|
||||
<fieldset>
|
||||
<legend>NOTA</legend>
|
||||
<div className="separate2">
|
||||
<p>Se requiere que el personaje esté desconectado y cumpla con el mínimo de nivel de cada opción.</p>
|
||||
<p>Se puede repetir búsqueda de cadenas de misiones en un mismo personaje cada 1 hora.</p>
|
||||
<p>Se puede repetir búsqueda de cadenas de misiones de clase en un mismo personaje cada 30 minutos.</p>
|
||||
<p>Se puede repetir búsqueda de misión específica en un mismo personaje cada 10 minutos.</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<QuestTrackerForm characters={chars.map((c) => ({ name: c.name, classCss: c.classCss, level: c.level }))} options={clientOptions} />
|
||||
</ServiceBox>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -27,6 +27,12 @@ export async function POST(request: Request, { params }: { params: Promise<{ ser
|
||||
return Response.json({ success: false, error: 'invalidCharacter' })
|
||||
}
|
||||
|
||||
// Comprobación previa al pago (condiciones del servicio, p.ej. cambio de facción).
|
||||
if (cfg.precheck) {
|
||||
const pc = await cfg.precheck(session.accountId, character)
|
||||
if (!pc.ok) return Response.json({ success: false, error: 'ineligible', message: pc.message })
|
||||
}
|
||||
|
||||
// Campos extra del servicio (gold_amount, destination_account...) -> metadata.
|
||||
// `service` va en metadata para que el webhook sepa qué entregar sin la URL.
|
||||
const metadata: Record<string, string> = { service }
|
||||
@@ -60,6 +66,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ ser
|
||||
returnUrl: `${site}/service-success?service=${service}&provider=sumup&ref=${reference}`,
|
||||
service,
|
||||
characterName: character,
|
||||
metadata,
|
||||
})
|
||||
if (!r.success || !r.url) return Response.json({ success: false, error: r.error || 'sumupError' })
|
||||
return Response.json({ success: true, url: r.url })
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { RowDataPacket } from 'mysql2'
|
||||
import { getSession } from '@/lib/session'
|
||||
import { db, DB } from '@/lib/db'
|
||||
import { getClassCss } from '@/lib/character-info'
|
||||
import { verifyTurnstile } from '@/lib/turnstile'
|
||||
import { QUEST_OPTIONS, questStatuses, cooldownRemainingMs, recordSearch, type QuestCategory } from '@/lib/quest-tracker'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const session = await getSession()
|
||||
if (!session.accountId || !session.username) {
|
||||
return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
let body: Record<string, unknown> = {}
|
||||
try {
|
||||
body = await request.json()
|
||||
} catch {
|
||||
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
|
||||
}
|
||||
|
||||
const character = String(body.character ?? '').trim()
|
||||
const optionKey = String(body.option ?? '').trim()
|
||||
if (!character || !optionKey) return Response.json({ success: false, error: 'missingFields' })
|
||||
|
||||
const ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() || undefined
|
||||
if (!(await verifyTurnstile(String(body.turnstile ?? ''), ip))) {
|
||||
return Response.json({ success: false, error: 'captcha' })
|
||||
}
|
||||
|
||||
// Personaje: debe ser de la cuenta y estar desconectado.
|
||||
let guid = 0
|
||||
let level = 0
|
||||
let classCss = ''
|
||||
try {
|
||||
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
|
||||
'SELECT guid, level, class, online FROM characters WHERE name = ? AND account = ? LIMIT 1',
|
||||
[character, session.accountId],
|
||||
)
|
||||
const c = rows[0]
|
||||
if (!c) return Response.json({ success: false, error: 'characterNotOwned' })
|
||||
if (c.online === 1) return Response.json({ success: false, error: 'characterOnline' })
|
||||
guid = Number(c.guid)
|
||||
level = Number(c.level)
|
||||
classCss = getClassCss(Number(c.class))
|
||||
} catch {
|
||||
return Response.json({ success: false, error: 'genericError' })
|
||||
}
|
||||
|
||||
// Resuelve categoría + misiones a comprobar.
|
||||
let category: QuestCategory
|
||||
let quests: number[]
|
||||
let label: string
|
||||
if (optionKey === 'specific') {
|
||||
const qid = Number(body.questId)
|
||||
if (!Number.isInteger(qid) || qid <= 0) return Response.json({ success: false, error: 'invalidQuestId' })
|
||||
if (level < 10) return Response.json({ success: false, error: 'levelTooLow' })
|
||||
category = 'specific'
|
||||
quests = [qid]
|
||||
label = `Misión #${qid}`
|
||||
} else {
|
||||
const opt = QUEST_OPTIONS.find((o) => o.key === optionKey)
|
||||
if (!opt) return Response.json({ success: false, error: 'invalidOption' })
|
||||
if (opt.classCss && opt.classCss !== classCss) return Response.json({ success: false, error: 'wrongClass' })
|
||||
if (level < opt.minLevel) return Response.json({ success: false, error: 'levelTooLow' })
|
||||
if (opt.quests.length === 0) return Response.json({ success: false, error: 'noData' })
|
||||
category = opt.category
|
||||
quests = opt.quests
|
||||
label = opt.label
|
||||
}
|
||||
|
||||
const remaining = await cooldownRemainingMs(character, category)
|
||||
if (remaining > 0) {
|
||||
return Response.json({ success: false, error: 'cooldown', minutes: Math.ceil(remaining / 60000) })
|
||||
}
|
||||
|
||||
const statuses = await questStatuses(guid, quests)
|
||||
await recordSearch(character, category)
|
||||
return Response.json({ success: true, label, statuses })
|
||||
}
|
||||
@@ -33,11 +33,11 @@ export function AccountTools({ isAdmin }: { isAdmin: boolean }) {
|
||||
{ href: '/revive-character', icon: 'revive-icon', label: t('svcRevive'), desc: t('svcReviveDesc') },
|
||||
{ href: '/rename-character', icon: 'rename-icon', label: t('svcRename'), desc: t('svcRenameDesc') },
|
||||
{ href: '/customize-character', icon: 'customize-icon', label: t('svcCustomize'), desc: t('svcCustomizeDesc') },
|
||||
{ href: '/change-race', icon: 'change-race-icon', label: t('svcChangeRace'), desc: t('svcChangeRaceDesc') },
|
||||
{ href: '/change-faction', icon: 'change-faction-icon', label: t('svcChangeFaction'), desc: t('svcChangeFactionDesc') },
|
||||
{ href: '/level-up', icon: 'level-up-icon', label: t('svcLevelUp'), desc: t('svcLevelUpDesc') },
|
||||
{ href: '/gold', icon: 'gold-icon', label: t('svcGold'), desc: t('svcGoldDesc') },
|
||||
{ href: '/quest', icon: 'quest-icon', label: t('svcQuest'), desc: t('svcQuestDesc') },
|
||||
{ href: '/change-race-character', icon: 'change-race-icon', label: t('svcChangeRace'), desc: t('svcChangeRaceDesc') },
|
||||
{ href: '/change-faction-character', icon: 'change-faction-icon', label: t('svcChangeFaction'), desc: t('svcChangeFactionDesc') },
|
||||
{ href: '/level-up-character', icon: 'level-up-icon', label: t('svcLevelUp'), desc: t('svcLevelUpDesc') },
|
||||
{ href: '/gold-character', icon: 'gold-icon', label: t('svcGold'), desc: t('svcGoldDesc') },
|
||||
{ href: '/quest-character', icon: 'quest-icon', label: t('svcQuest'), desc: t('svcQuestDesc') },
|
||||
{ href: '/transfer', icon: 'tranfer-char-icon', label: t('svcTransfer'), desc: t('svcTransferDesc') },
|
||||
{ href: '/restore-character', icon: 'restore-icon', label: t('svcRestoreChar'), desc: t('svcRestoreCharDesc') },
|
||||
{ href: '/restore-items', icon: 'prox-icon', label: t('svcRestoreItems'), desc: t('svcRestoreItemsDesc') },
|
||||
|
||||
@@ -16,6 +16,9 @@ export function GoldForm({ characters, options }: { characters: CharOption[]; op
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (busy || !character || !amount) return
|
||||
const opt = options.find((o) => String(o.gold_amount) === amount)
|
||||
const price = opt?.price ?? 0
|
||||
if (!window.confirm(`¿Estás seguro de enviar ${amount} de oro a ${character} por ${price} € (SumUp)?`)) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
@@ -23,12 +26,12 @@ export function GoldForm({ characters, options }: { characters: CharOption[]; op
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ character, gold_amount: amount }),
|
||||
body: JSON.stringify({ character, gold_amount: amount, provider: 'sumup' }),
|
||||
})
|
||||
const data: { success?: boolean; url?: string } = await res.json()
|
||||
const data: { success?: boolean; url?: string; message?: string } = await res.json()
|
||||
if (data.success && data.url) window.location.href = data.url
|
||||
else {
|
||||
setError(t('genericError'))
|
||||
setError(data.message || t('genericError'))
|
||||
setBusy(false)
|
||||
}
|
||||
} catch {
|
||||
@@ -54,7 +57,7 @@ export function GoldForm({ characters, options }: { characters: CharOption[]; op
|
||||
</select>
|
||||
<br />
|
||||
<button type="submit" className="gold-button" disabled={busy || !character || !amount}>
|
||||
{busy ? t('processing') : tp('gold.buy')}
|
||||
{busy ? t('processing') : tp('gold.send')}
|
||||
</button>
|
||||
</form>
|
||||
<hr />
|
||||
|
||||
@@ -33,11 +33,11 @@ export function PaidServiceForm({ characters, checkoutEndpoint, payLabel, button
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ character, ...(provider ? { provider } : {}) }),
|
||||
})
|
||||
const data: { success?: boolean; url?: string } = await res.json()
|
||||
const data: { success?: boolean; url?: string; message?: string } = await res.json()
|
||||
if (data.success && data.url) {
|
||||
window.location.href = data.url // Checkout de Stripe
|
||||
window.location.href = data.url // Checkout (Stripe o SumUp)
|
||||
} else {
|
||||
setError(t('genericError'))
|
||||
setError(data.message || t('genericError'))
|
||||
setBusy(false)
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { CharacterSelect } from '@/components/CharacterSelect'
|
||||
import { Turnstile } from '@/components/Turnstile'
|
||||
|
||||
const QUEST_DB = 'https://wotlk.novawow.com'
|
||||
|
||||
export interface QuestCharacter {
|
||||
name: string
|
||||
classCss: string
|
||||
level: number
|
||||
}
|
||||
export interface QuestOptionClient {
|
||||
key: string
|
||||
classCss: string | null
|
||||
label: string
|
||||
minLevel: number
|
||||
hasData: boolean
|
||||
}
|
||||
interface QuestStatus {
|
||||
quest: number
|
||||
state: 'rewarded' | 'inProgress' | 'none'
|
||||
}
|
||||
|
||||
function trackerError(error?: string, minutes?: number): string {
|
||||
switch (error) {
|
||||
case 'missingFields':
|
||||
return 'Selecciona un personaje y una opción.'
|
||||
case 'characterNotOwned':
|
||||
return 'El personaje seleccionado no es válido.'
|
||||
case 'characterOnline':
|
||||
return 'El personaje debe estar desconectado.'
|
||||
case 'wrongClass':
|
||||
return 'Esa opción no corresponde a la clase del personaje.'
|
||||
case 'levelTooLow':
|
||||
return 'El personaje no cumple el nivel mínimo de esa opción.'
|
||||
case 'invalidQuestId':
|
||||
return 'Introduce un ID de misión válido.'
|
||||
case 'invalidOption':
|
||||
case 'noData':
|
||||
return 'Esa opción no está disponible todavía.'
|
||||
case 'cooldown':
|
||||
return `Debes esperar ${minutes ?? 0} min para repetir esta búsqueda en este personaje.`
|
||||
case 'captcha':
|
||||
return 'Verifica que no eres un robot.'
|
||||
case 'notAuthenticated':
|
||||
return 'Tu sesión ha expirado. Inicia sesión de nuevo.'
|
||||
default:
|
||||
return 'No se pudo completar la búsqueda. Inténtalo de nuevo.'
|
||||
}
|
||||
}
|
||||
|
||||
export function QuestTrackerForm({ characters, options }: { characters: QuestCharacter[]; options: QuestOptionClient[] }) {
|
||||
const [character, setCharacter] = useState('')
|
||||
const [option, setOption] = useState('')
|
||||
const [questId, setQuestId] = useState('')
|
||||
const [captcha, setCaptcha] = useState('')
|
||||
const [captchaKey, setCaptchaKey] = useState(0)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null)
|
||||
const [result, setResult] = useState<{ label: string; statuses: QuestStatus[] } | null>(null)
|
||||
|
||||
const selected = characters.find((c) => c.name === character)
|
||||
|
||||
// Opciones que el personaje elegido puede rastrear (clase + nivel + con datos),
|
||||
// más la búsqueda por misión específica (nivel >= 10).
|
||||
const available = useMemo(() => {
|
||||
if (!selected) return [] as { key: string; label: string }[]
|
||||
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' })
|
||||
return list
|
||||
}, [selected, options])
|
||||
|
||||
function onCharacter(v: string) {
|
||||
setCharacter(v)
|
||||
setOption('')
|
||||
setQuestId('')
|
||||
setResult(null)
|
||||
setMessage(null)
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
if (busy || !character || !option) return
|
||||
setBusy(true)
|
||||
setMessage(null)
|
||||
setResult(null)
|
||||
try {
|
||||
const res = await fetch('/api/character/quest', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ character, option, questId: option === 'specific' ? Number(questId) : undefined, turnstile: captcha }),
|
||||
})
|
||||
const data: { success?: boolean; error?: string; minutes?: number; label?: string; statuses?: QuestStatus[] } =
|
||||
await res.json()
|
||||
if (data.success && data.statuses) {
|
||||
setResult({ label: data.label ?? '', statuses: data.statuses })
|
||||
} else {
|
||||
setMessage({ ok: false, text: trackerError(data.error, data.minutes) })
|
||||
}
|
||||
} catch {
|
||||
setMessage({ ok: false, text: trackerError() })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
setCaptcha('')
|
||||
setCaptchaKey((k) => k + 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (characters.length === 0) {
|
||||
return (
|
||||
<div className="centered">
|
||||
<br />
|
||||
<span>No hay personajes disponibles para esta herramienta</span>
|
||||
<br />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const stateText = (s: QuestStatus['state']) =>
|
||||
s === 'rewarded'
|
||||
? { text: 'Completada', cls: 'green-info2' }
|
||||
: s === 'inProgress'
|
||||
? { text: 'En curso', cls: 'yellow-info' }
|
||||
: { text: 'Sin empezar', 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" />
|
||||
<br />
|
||||
<select value={option} onChange={(e) => setOption(e.target.value)} required disabled={!selected}>
|
||||
<option value="" disabled>Selecciona una opción</option>
|
||||
{available.map((o) => (
|
||||
<option key={o.key} value={o.key}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<br />
|
||||
{option === 'specific' && (
|
||||
<>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="ID de misión"
|
||||
value={questId}
|
||||
onChange={(e) => setQuestId(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
<Turnstile key={captchaKey} onVerify={setCaptcha} />
|
||||
<button type="submit" className="quest-button" disabled={busy || !character || !option}>
|
||||
{busy ? 'Buscando misiones' : 'BUSCAR MISIONES'}
|
||||
</button>
|
||||
</form>
|
||||
<hr />
|
||||
<div className="alert-message" id="quest-response" style={{ display: message || result ? 'block' : 'none' }}>
|
||||
{message && <span className={message.ok ? 'ok-form-response' : 'red-form-response'}>{message.text}</span>}
|
||||
{result && (
|
||||
<div>
|
||||
<p className="yellow-info">{result.label}</p>
|
||||
<table className="max-center-table2">
|
||||
<tbody>
|
||||
{result.statuses.map((s) => {
|
||||
const st = stateText(s.state)
|
||||
return (
|
||||
<tr key={s.quest}>
|
||||
<td className="lefted">
|
||||
<a href={`${QUEST_DB}/?quest=${s.quest}`} target="_blank" rel="noopener noreferrer">
|
||||
Misión #{s.quest}
|
||||
</a>
|
||||
</td>
|
||||
<td className={`real-info-box ${st.cls}`}>{st.text}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { RowDataPacket } from 'mysql2'
|
||||
import { db, DB } from './db'
|
||||
|
||||
/**
|
||||
* Condiciones para cambiar de facción (AzerothCore). El personaje NO puede:
|
||||
* tener subastas activas, correos en el buzón, ser capitán de un equipo de arena,
|
||||
* ser miembro de una hermandad, ni superar el máximo de oro según su nivel.
|
||||
*/
|
||||
|
||||
/** Máximo de oro (en oro) permitido según el nivel. */
|
||||
export function factionGoldCap(level: number): number {
|
||||
if (level <= 30) return 300
|
||||
if (level <= 50) return 1000
|
||||
if (level <= 70) return 5000
|
||||
return 20000
|
||||
}
|
||||
|
||||
export interface FactionEligibility {
|
||||
ok: boolean
|
||||
reasons: string[]
|
||||
}
|
||||
|
||||
export async function checkFactionChangeEligibility(accountId: number, character: string): Promise<FactionEligibility> {
|
||||
const [chars] = await db(DB.characters).query<RowDataPacket[]>(
|
||||
'SELECT guid, level, money FROM characters WHERE name = ? AND account = ? LIMIT 1',
|
||||
[character, accountId],
|
||||
)
|
||||
const ch = chars[0]
|
||||
if (!ch) return { ok: false, reasons: ['El personaje no pertenece a tu cuenta.'] }
|
||||
|
||||
const guid = Number(ch.guid)
|
||||
const level = Number(ch.level)
|
||||
const money = Number(ch.money)
|
||||
const reasons: string[] = []
|
||||
|
||||
const cap = factionGoldCap(level)
|
||||
if (money > cap * 10000) reasons.push(`tiene demasiado oro (máximo ${cap} para su nivel)`)
|
||||
|
||||
// Cada condición en su try/catch: alguna tabla puede no existir en esta BD.
|
||||
const exists = async (sql: string): Promise<boolean> => {
|
||||
try {
|
||||
const [rows] = await db(DB.characters).query<RowDataPacket[]>(sql, [guid])
|
||||
return Boolean(rows[0])
|
||||
} catch {
|
||||
return false // tabla ausente => no bloquea (el core lo valida al aplicar)
|
||||
}
|
||||
}
|
||||
|
||||
if (await exists('SELECT 1 FROM guild_member WHERE guid = ? LIMIT 1')) reasons.push('es miembro de una hermandad')
|
||||
if (await exists('SELECT 1 FROM mail WHERE receiver = ? LIMIT 1')) reasons.push('tiene correos en el buzón')
|
||||
if (await exists('SELECT 1 FROM auctionhouse WHERE owner = ? LIMIT 1')) reasons.push('tiene subastas activas')
|
||||
if (await exists('SELECT 1 FROM arena_team WHERE captainGuid = ? LIMIT 1')) reasons.push('es capitán de un equipo de arena')
|
||||
|
||||
return { ok: reasons.length === 0, reasons }
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { getClassCss } from './character-info'
|
||||
export interface GameCharacter {
|
||||
name: string
|
||||
online: boolean
|
||||
level: number
|
||||
classCss: string // clase CSS de color por clase (priest, warrior…), para el <select>
|
||||
}
|
||||
|
||||
@@ -12,10 +13,10 @@ export interface GameCharacter {
|
||||
export async function getGameCharacters(accountId: number): Promise<GameCharacter[]> {
|
||||
try {
|
||||
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
|
||||
'SELECT name, online, class FROM characters WHERE account = ?',
|
||||
'SELECT name, online, class, level FROM characters WHERE account = ?',
|
||||
[accountId],
|
||||
)
|
||||
return rows.map((r) => ({ name: r.name, online: r.online === 1, classCss: getClassCss(r.class) }))
|
||||
return rows.map((r) => ({ name: r.name, online: r.online === 1, level: Number(r.level), classCss: getClassCss(r.class) }))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { executeSoapCommand } from './soap'
|
||||
import { db, DB } from './db'
|
||||
import { creditDPoints } from './dpoints'
|
||||
import { checkFactionChangeEligibility } from './change-faction'
|
||||
import {
|
||||
getRenamePrice,
|
||||
getCustomizePrice,
|
||||
@@ -18,22 +18,23 @@ export interface PaidServiceConfig {
|
||||
productName: (character: string, meta: Meta) => string
|
||||
fulfill: (character: string, meta: Meta) => Promise<boolean>
|
||||
extraFields: string[] // campos (además de character) que el form envía y se guardan en metadata
|
||||
// Comprobación previa al pago (p.ej. condiciones de cambio de facción). Si `ok`
|
||||
// es false, se bloquea el checkout y se muestra `message`.
|
||||
precheck?: (accountId: number, character: string) => Promise<{ ok: boolean; message?: string }>
|
||||
}
|
||||
|
||||
async function soapFulfill(command: string): Promise<boolean> {
|
||||
return (await executeSoapCommand(command)) !== null
|
||||
}
|
||||
|
||||
async function addGold(character: string, goldAmount: number): Promise<boolean> {
|
||||
try {
|
||||
await db(DB.characters).query('UPDATE characters SET money = money + ? WHERE name = ?', [
|
||||
goldAmount * 10000,
|
||||
character,
|
||||
])
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
/** Nombre de personaje válido (evita inyección en el comando SOAP). */
|
||||
const SAFE_NAME = /^[A-Za-z]{1,12}$/
|
||||
|
||||
/** Envía el oro por correo al personaje (como el diseño). Requiere el worldserver. */
|
||||
async function sendGoldByMail(character: string, goldAmount: number): Promise<boolean> {
|
||||
if (!SAFE_NAME.test(character) || !(goldAmount > 0)) return false
|
||||
const copper = goldAmount * 10000
|
||||
return soapFulfill(`.send money "${character}" "Adquirir oro" "Has recibido ${goldAmount} de oro." ${copper}`)
|
||||
}
|
||||
|
||||
export const PAID_SERVICES: Record<string, PaidServiceConfig> = {
|
||||
@@ -59,6 +60,10 @@ export const PAID_SERVICES: Record<string, PaidServiceConfig> = {
|
||||
price: () => getChangeFactionPrice(),
|
||||
productName: (c) => `Cambiar facción: ${c}`,
|
||||
fulfill: (c) => soapFulfill(`.char changef ${c}`),
|
||||
precheck: async (accountId, character) => {
|
||||
const r = await checkFactionChangeEligibility(accountId, character)
|
||||
return { ok: r.ok, message: r.ok ? undefined : `No se puede cambiar la facción: el personaje ${r.reasons.join('; ')}.` }
|
||||
},
|
||||
extraFields: [],
|
||||
},
|
||||
'level-up': {
|
||||
@@ -69,8 +74,8 @@ export const PAID_SERVICES: Record<string, PaidServiceConfig> = {
|
||||
},
|
||||
gold: {
|
||||
price: (m) => goldPriceFor(Number(m.gold_amount)),
|
||||
productName: (c, m) => `Aumentar el Oro: ${m.gold_amount} al Personaje: ${c}`,
|
||||
fulfill: (c, m) => addGold(c, Number(m.gold_amount)),
|
||||
productName: (c, m) => `Adquirir oro: ${m.gold_amount} al personaje ${c}`,
|
||||
fulfill: (c, m) => sendGoldByMail(c, Number(m.gold_amount)),
|
||||
extraFields: ['gold_amount'],
|
||||
},
|
||||
transfer: {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { RowDataPacket } from 'mysql2'
|
||||
import { db, DB } from './db'
|
||||
|
||||
/**
|
||||
* Rastreador de misiones (/quest-character): comprueba el estado (completada /
|
||||
* en curso / sin empezar) de misiones o cadenas de misiones en un personaje.
|
||||
*
|
||||
* Categorías (cooldown por personaje):
|
||||
* - 'class' cadenas de misiones de clase — cada 30 min
|
||||
* - 'reputation' cadenas de misiones (reputación) — cada 1 h
|
||||
* - 'specific' una misión concreta por ID — cada 10 min
|
||||
*/
|
||||
|
||||
export type QuestCategory = 'class' | 'reputation' | 'specific'
|
||||
|
||||
export const COOLDOWN_MS: Record<QuestCategory, number> = {
|
||||
reputation: 60 * 60 * 1000,
|
||||
class: 30 * 60 * 1000,
|
||||
specific: 10 * 60 * 1000,
|
||||
}
|
||||
|
||||
export interface QuestOption {
|
||||
key: string
|
||||
category: Exclude<QuestCategory, 'specific'>
|
||||
classCss: string | null // clase a la que aplica ('warlock'…) o null (cualquier clase)
|
||||
label: string
|
||||
link?: { type: 'spell' | 'item' | 'faction'; id: number }
|
||||
icon?: string
|
||||
minLevel: number
|
||||
quests: number[] // IDs de misión de la cadena (variantes de facción combinadas)
|
||||
}
|
||||
|
||||
// IDs de misión verificados en la BD WotLK 3.3.5a (wotlkdb.com): la misión FINAL
|
||||
// que otorga cada habilidad, combinando variantes de facción/raza (el personaje
|
||||
// solo tendrá en su registro las de su facción/raza). Editables aquí si hiciera falta.
|
||||
export const QUEST_OPTIONS: QuestOption[] = [
|
||||
// Brujo
|
||||
{ key: 'wl_voidwalker', category: 'class', classCss: 'warlock', label: 'Invocar abisario', link: { type: 'spell', id: 697 }, minLevel: 10, icon: 'spell_shadow_summonvoidwalker', quests: [1689, 1504, 1471] },
|
||||
{ key: 'wl_succubus', category: 'class', classCss: 'warlock', label: 'Invocar súcubo', link: { type: 'spell', id: 712 }, minLevel: 20, icon: 'spell_shadow_summonsuccubus', quests: [1739, 1513, 1474] },
|
||||
{ key: 'wl_felhunter', category: 'class', classCss: 'warlock', label: 'Invocar manáfago', link: { type: 'spell', id: 691 }, minLevel: 30, icon: 'spell_shadow_summonfelhunter', quests: [1795] },
|
||||
{ key: 'wl_inferno', category: 'class', classCss: 'warlock', label: 'Inferno', link: { type: 'spell', id: 1122 }, minLevel: 50, icon: 'spell_shadow_summoninfernal', quests: [7603] },
|
||||
{ key: 'wl_doom', category: 'class', classCss: 'warlock', label: 'Ritual de fatalidad', link: { type: 'spell', id: 18540 }, minLevel: 60, icon: 'spell_shadow_antimagicshell', quests: [7583] },
|
||||
// Cazador
|
||||
{ key: 'hunter_pet', category: 'class', classCss: 'hunter', label: 'Habilidades de mascota', minLevel: 10, quests: [6103, 6086, 9675, 6081, 6089, 9673] },
|
||||
// Chamán
|
||||
{ key: 'sh_earth', category: 'class', classCss: 'shaman', label: 'Tótem de tierra', link: { type: 'item', id: 5175 }, minLevel: 4, icon: 'spell_totem_wardofdraining', quests: [9451, 1518, 1521] },
|
||||
{ key: 'sh_fire', category: 'class', classCss: 'shaman', label: 'Tótem de fuego', link: { type: 'item', id: 5176 }, minLevel: 10, icon: 'spell_totem_wardofdraining', quests: [9555, 1527] },
|
||||
{ key: 'sh_water', category: 'class', classCss: 'shaman', label: 'Tótem de agua', link: { type: 'item', id: 5177 }, minLevel: 20, icon: 'spell_totem_wardofdraining', quests: [9509, 96] },
|
||||
{ key: 'sh_air', category: 'class', classCss: 'shaman', label: 'Tótem de aire', link: { type: 'item', id: 5178 }, minLevel: 30, icon: 'spell_totem_wardofdraining', quests: [9554, 1531, 1532] },
|
||||
// Druida
|
||||
{ key: 'dr_bear', category: 'class', classCss: 'druid', label: 'Forma de oso', link: { type: 'spell', id: 5487 }, minLevel: 10, icon: 'ability_racial_bearform', quests: [6001, 6002] },
|
||||
{ key: 'dr_curepoison', category: 'class', classCss: 'druid', label: 'Curar envenenamiento', link: { type: 'spell', id: 8946 }, minLevel: 14, icon: 'spell_nature_nullifypoison', quests: [6125, 6130] },
|
||||
{ key: 'dr_aquatic', category: 'class', classCss: 'druid', label: 'Forma acuática', link: { type: 'spell', id: 1066 }, minLevel: 16, icon: 'ability_druid_aquaticform', quests: [5061, 31] },
|
||||
{ key: 'dr_flight', category: 'class', classCss: 'druid', label: 'Forma de vuelo presto', link: { type: 'spell', id: 40120 }, minLevel: 70, icon: 'ability_druid_flightform', quests: [11001] },
|
||||
// Guerrero
|
||||
{ key: 'wr_defensive', category: 'class', classCss: 'warrior', label: 'Actitud defensiva', link: { type: 'spell', id: 71 }, minLevel: 10, icon: 'ability_warrior_defensivestance', quests: [1665, 1678, 1683, 9582, 10350, 1498, 1819] },
|
||||
{ key: 'wr_berserker', category: 'class', classCss: 'warrior', label: 'Actitud rabiosa', link: { type: 'spell', id: 2458 }, minLevel: 30, icon: 'ability_racial_avatar', quests: [1719] },
|
||||
// Paladín
|
||||
{ key: 'pal_redemption', category: 'class', classCss: 'paladin', label: 'Redención', link: { type: 'spell', id: 7328 }, minLevel: 12, icon: 'spell_holy_resurrection', quests: [1788, 9598, 9685] },
|
||||
// Reputación
|
||||
{ key: 'rep_hodir', category: 'reputation', classCss: null, label: 'Los Hijos de Hodir', link: { type: 'faction', id: 1119 }, minLevel: 77, quests: [12843, 13064, 12915, 12956] },
|
||||
]
|
||||
|
||||
/** Opciones que un personaje (clase + nivel) puede rastrear. */
|
||||
export function optionsFor(classCss: string, level: number): QuestOption[] {
|
||||
return QUEST_OPTIONS.filter(
|
||||
(o) => (o.classCss === null || o.classCss === classCss) && level >= o.minLevel && o.quests.length > 0,
|
||||
)
|
||||
}
|
||||
|
||||
export type QuestState = 'rewarded' | 'inProgress' | 'none'
|
||||
export interface QuestStatus {
|
||||
quest: number
|
||||
state: QuestState
|
||||
}
|
||||
|
||||
/** Estado de cada misión (completada/en curso/sin empezar) para un personaje. */
|
||||
export async function questStatuses(guid: number, quests: number[]): Promise<QuestStatus[]> {
|
||||
if (quests.length === 0) return []
|
||||
const ph = quests.map(() => '?').join(',')
|
||||
const rewarded = new Set<number>()
|
||||
const inProgress = new Set<number>()
|
||||
try {
|
||||
const [r] = await db(DB.characters).query<RowDataPacket[]>(
|
||||
`SELECT quest FROM character_queststatus_rewarded WHERE guid = ? AND quest IN (${ph})`,
|
||||
[guid, ...quests],
|
||||
)
|
||||
for (const row of r) rewarded.add(Number(row.quest))
|
||||
const [p] = await db(DB.characters).query<RowDataPacket[]>(
|
||||
`SELECT quest FROM character_queststatus WHERE guid = ? AND quest IN (${ph})`,
|
||||
[guid, ...quests],
|
||||
)
|
||||
for (const row of p) inProgress.add(Number(row.quest))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return quests.map((q) => ({
|
||||
quest: q,
|
||||
state: rewarded.has(q) ? 'rewarded' : inProgress.has(q) ? 'inProgress' : 'none',
|
||||
}))
|
||||
}
|
||||
|
||||
/** Minutos restantes de cooldown para (personaje, categoría), o 0 si disponible. */
|
||||
export async function cooldownRemainingMs(character: string, category: QuestCategory): Promise<number> {
|
||||
try {
|
||||
const [rows] = await db(DB.default).query<RowDataPacket[]>(
|
||||
'SELECT searched_at FROM home_questsearchhistory WHERE character_name = ? AND category = ? ORDER BY searched_at DESC LIMIT 1',
|
||||
[character, category],
|
||||
)
|
||||
if (!rows[0]) return 0
|
||||
const diff = Date.now() - new Date(rows[0].searched_at).getTime()
|
||||
return diff < COOLDOWN_MS[category] ? COOLDOWN_MS[category] - diff : 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export async function recordSearch(character: string, category: QuestCategory): Promise<void> {
|
||||
try {
|
||||
await db(DB.default).query(
|
||||
'INSERT INTO home_questsearchhistory (character_name, category, searched_at) VALUES (?, ?, NOW())',
|
||||
[character, category],
|
||||
)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
+26
-6
@@ -31,6 +31,8 @@ interface CreateParams {
|
||||
// servicio sobre `characterName` en vez de acreditar PD. Sin `service` = compra de PD.
|
||||
service?: string
|
||||
characterName?: string
|
||||
// Campos extra del servicio (p.ej. { gold_amount }) que la entrega necesita.
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,9 +62,21 @@ export async function createSumUpCheckout(p: CreateParams): Promise<{ success: b
|
||||
if (!url) return { success: false, error: 'noHostedUrl' }
|
||||
|
||||
await db(DB.default).query(
|
||||
'INSERT INTO home_stripelog (account_id, username, email, acore_ip, stripe_ip, product_name, amount, session_id, mode, character_name, service, timestamp, fulfilled) ' +
|
||||
'VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, NOW(), 0)',
|
||||
[p.accountId, p.username, p.email, p.acoreIp, p.description, p.amount, p.reference, 'sumup', p.characterName ?? p.username, p.service ?? null],
|
||||
'INSERT INTO home_stripelog (account_id, username, email, acore_ip, stripe_ip, product_name, amount, session_id, mode, character_name, service, metadata, timestamp, fulfilled) ' +
|
||||
'VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, NOW(), 0)',
|
||||
[
|
||||
p.accountId,
|
||||
p.username,
|
||||
p.email,
|
||||
p.acoreIp,
|
||||
p.description,
|
||||
p.amount,
|
||||
p.reference,
|
||||
'sumup',
|
||||
p.characterName ?? p.username,
|
||||
p.service ?? null,
|
||||
p.metadata ? JSON.stringify(p.metadata) : null,
|
||||
],
|
||||
)
|
||||
return { success: true, url }
|
||||
} catch (e) {
|
||||
@@ -96,7 +110,7 @@ export async function fulfillSumUpCheckout(
|
||||
if (!paid) return { ok: false, paid: false }
|
||||
|
||||
const [rows] = await db(DB.default).query<RowDataPacket[]>(
|
||||
'SELECT id, account_id, amount, service, character_name FROM home_stripelog WHERE session_id = ? AND mode = ?',
|
||||
'SELECT id, account_id, amount, service, character_name, metadata FROM home_stripelog WHERE session_id = ? AND mode = ?',
|
||||
[reference, 'sumup'],
|
||||
)
|
||||
const log = rows[0]
|
||||
@@ -109,11 +123,17 @@ export async function fulfillSumUpCheckout(
|
||||
)
|
||||
if (res.affectedRows === 0) return { ok: false, paid: true }
|
||||
|
||||
// Pago de servicio (renombrar…): ejecuta la acción sobre el personaje.
|
||||
// Pago de servicio (renombrar, oro…): ejecuta la acción sobre el personaje.
|
||||
const service = log.service ? String(log.service) : ''
|
||||
const cfg = service ? getPaidService(service) : null
|
||||
if (cfg) {
|
||||
const ok = await cfg.fulfill(String(log.character_name), { service })
|
||||
let meta: Record<string, string> = { service }
|
||||
try {
|
||||
if (log.metadata) meta = { ...JSON.parse(String(log.metadata)), service }
|
||||
} catch {
|
||||
/* metadata corrupto: seguimos con lo básico */
|
||||
}
|
||||
const ok = await cfg.fulfill(String(log.character_name), meta)
|
||||
return { ok, paid: true, service, character: String(log.character_name) }
|
||||
}
|
||||
|
||||
|
||||
+82
-13
@@ -218,10 +218,38 @@
|
||||
"genericError": "Something went wrong. Please try again later.",
|
||||
"captchaFailed": "Anti-bot verification failed. Please retry.",
|
||||
"infoSections": [
|
||||
{ "h": "If you forgot the account password:", "lines": ["- Select the 'Password' option.", "- Enter your Username.", "- An email will be sent with a link to generate a new password."] },
|
||||
{ "h": "If you forgot the account name:", "lines": ["- Select the 'Account name' option.", "- Enter your email.", "- An email will be sent with all accounts linked to that email."] },
|
||||
{ "h": "If you forgot the Security token:", "lines": ["- Select the 'Security token' option.", "- Enter your email.", "- An email will be sent with the Security token."] },
|
||||
{ "h": "If you didn't receive the activation link:", "lines": ["- Select the 'Activation link' option.", "- Enter your email.", "- An email will be sent with the activation link."] }
|
||||
{
|
||||
"h": "If you forgot the account password:",
|
||||
"lines": [
|
||||
"- Select the 'Password' option.",
|
||||
"- Enter your Username.",
|
||||
"- An email will be sent with a link to generate a new password."
|
||||
]
|
||||
},
|
||||
{
|
||||
"h": "If you forgot the account name:",
|
||||
"lines": [
|
||||
"- Select the 'Account name' option.",
|
||||
"- Enter your email.",
|
||||
"- An email will be sent with all accounts linked to that email."
|
||||
]
|
||||
},
|
||||
{
|
||||
"h": "If you forgot the Security token:",
|
||||
"lines": [
|
||||
"- Select the 'Security token' option.",
|
||||
"- Enter your email.",
|
||||
"- An email will be sent with the Security token."
|
||||
]
|
||||
},
|
||||
{
|
||||
"h": "If you didn't receive the activation link:",
|
||||
"lines": [
|
||||
"- Select the 'Activation link' option.",
|
||||
"- Enter your email.",
|
||||
"- An email will be sent with the activation link."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Reset": {
|
||||
@@ -304,7 +332,8 @@
|
||||
"buy": "Buy gold",
|
||||
"selectAmount": "Select the amount",
|
||||
"option": "{amount} gold — {price} €",
|
||||
"success": "Gold has been added to character {name}."
|
||||
"success": "Gold has been added to character {name}.",
|
||||
"send": "SEND GOLD"
|
||||
},
|
||||
"transfer": {
|
||||
"title": "Transfer character",
|
||||
@@ -552,13 +581,53 @@
|
||||
"noSites": "No vote sites configured yet.",
|
||||
"loginToVote": "Sign in to vote.",
|
||||
"infoSections": [
|
||||
{ "h": "What is voting?", "lines": ["Voting is the act of giving the server a vote on a website that offers top lists.", "As a reward for voting for {server}, you receive VP."] },
|
||||
{ "h": "What are VP?", "lines": ["We give our users the chance to earn levels with exclusive benefits in our online community. These benefits include access to special tools, exclusive content and significant improvements to the website experience. When voting, users receive VP, an internal recognition unit reflecting their commitment and progress on our platform. VP let you reach new levels and unlock exclusive rewards. VP are a unique way to value and reward the active participation of our users in the community."] },
|
||||
{ "h": "How can I vote?", "lines": ["To vote for {server}, click the buttons below; each one redirects to the corresponding top-list website.", "Once there, give the server a vote and you'll receive your VP."] },
|
||||
{ "h": "How many VP do I get per vote?", "lines": ["In the list below, each site shows how many VP it grants for a successful vote."] },
|
||||
{ "h": "How often can I vote?", "lines": ["You can only vote on each site every 12 hours.", "The box shows the last time you voted on each site."] },
|
||||
{ "h": "Why doesn't my vote count on the site?", "lines": ["We use a postback system, so each vote site sends us a response whenever a user votes for our server.", "Once the response is analyzed on our site, the corresponding VP are added as long as the user voted correctly.", "Each vote site also has its own restrictions, such as only allowing one vote per IP, so a vote may not count if several accounts vote from the same IP on the same day."] },
|
||||
{ "h": "Questions or problems voting?", "lines": ["If after reading this guide you still have questions, the {server} team can assist you at any time. You can open a ticket asking for help to vote."] }
|
||||
{
|
||||
"h": "What is voting?",
|
||||
"lines": [
|
||||
"Voting is the act of giving the server a vote on a website that offers top lists.",
|
||||
"As a reward for voting for {server}, you receive VP."
|
||||
]
|
||||
},
|
||||
{
|
||||
"h": "What are VP?",
|
||||
"lines": [
|
||||
"We give our users the chance to earn levels with exclusive benefits in our online community. These benefits include access to special tools, exclusive content and significant improvements to the website experience. When voting, users receive VP, an internal recognition unit reflecting their commitment and progress on our platform. VP let you reach new levels and unlock exclusive rewards. VP are a unique way to value and reward the active participation of our users in the community."
|
||||
]
|
||||
},
|
||||
{
|
||||
"h": "How can I vote?",
|
||||
"lines": [
|
||||
"To vote for {server}, click the buttons below; each one redirects to the corresponding top-list website.",
|
||||
"Once there, give the server a vote and you'll receive your VP."
|
||||
]
|
||||
},
|
||||
{
|
||||
"h": "How many VP do I get per vote?",
|
||||
"lines": [
|
||||
"In the list below, each site shows how many VP it grants for a successful vote."
|
||||
]
|
||||
},
|
||||
{
|
||||
"h": "How often can I vote?",
|
||||
"lines": [
|
||||
"You can only vote on each site every 12 hours.",
|
||||
"The box shows the last time you voted on each site."
|
||||
]
|
||||
},
|
||||
{
|
||||
"h": "Why doesn't my vote count on the site?",
|
||||
"lines": [
|
||||
"We use a postback system, so each vote site sends us a response whenever a user votes for our server.",
|
||||
"Once the response is analyzed on our site, the corresponding VP are added as long as the user voted correctly.",
|
||||
"Each vote site also has its own restrictions, such as only allowing one vote per IP, so a vote may not count if several accounts vote from the same IP on the same day."
|
||||
]
|
||||
},
|
||||
{
|
||||
"h": "Questions or problems voting?",
|
||||
"lines": [
|
||||
"If after reading this guide you still have questions, the {server} team can assist you at any time. You can open a ticket asking for help to vote."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Common": {
|
||||
@@ -621,4 +690,4 @@
|
||||
"title": "Page not found",
|
||||
"message": "It looks like the page you are looking for doesn't exist or isn't available."
|
||||
}
|
||||
}
|
||||
}
|
||||
+82
-13
@@ -218,10 +218,38 @@
|
||||
"genericError": "Algo ha salido mal. Inténtalo más tarde.",
|
||||
"captchaFailed": "Verificación anti-bots fallida. Reintenta.",
|
||||
"infoSections": [
|
||||
{ "h": "Si has olvidado la contraseña de la cuenta:", "lines": ["- Selecciona la opción 'Contraseña'.", "- Escribe Nombre de usuario.", "- Un correo será enviado con un enlace para generar una contraseña nueva."] },
|
||||
{ "h": "Si has olvidado el nombre de la cuenta:", "lines": ["- Selecciona la opción 'Nombre de cuenta'.", "- Escribe el correo electrónico.", "- Un correo será enviado con todas las cuentas ligadas al correo."] },
|
||||
{ "h": "Si has olvidado el Token de seguridad:", "lines": ["- Selecciona la opción 'Token de seguridad'.", "- Escribe el correo electrónico.", "- Un correo será enviado con el Token de seguridad."] },
|
||||
{ "h": "Si no has recibido el enlace de activación:", "lines": ["- Selecciona la opción 'Enlace de activación'.", "- Escribe el correo electrónico.", "- Un correo será enviado con el enlace de activación."] }
|
||||
{
|
||||
"h": "Si has olvidado la contraseña de la cuenta:",
|
||||
"lines": [
|
||||
"- Selecciona la opción 'Contraseña'.",
|
||||
"- Escribe Nombre de usuario.",
|
||||
"- Un correo será enviado con un enlace para generar una contraseña nueva."
|
||||
]
|
||||
},
|
||||
{
|
||||
"h": "Si has olvidado el nombre de la cuenta:",
|
||||
"lines": [
|
||||
"- Selecciona la opción 'Nombre de cuenta'.",
|
||||
"- Escribe el correo electrónico.",
|
||||
"- Un correo será enviado con todas las cuentas ligadas al correo."
|
||||
]
|
||||
},
|
||||
{
|
||||
"h": "Si has olvidado el Token de seguridad:",
|
||||
"lines": [
|
||||
"- Selecciona la opción 'Token de seguridad'.",
|
||||
"- Escribe el correo electrónico.",
|
||||
"- Un correo será enviado con el Token de seguridad."
|
||||
]
|
||||
},
|
||||
{
|
||||
"h": "Si no has recibido el enlace de activación:",
|
||||
"lines": [
|
||||
"- Selecciona la opción 'Enlace de activación'.",
|
||||
"- Escribe el correo electrónico.",
|
||||
"- Un correo será enviado con el enlace de activación."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Reset": {
|
||||
@@ -304,7 +332,8 @@
|
||||
"buy": "Comprar oro",
|
||||
"selectAmount": "Selecciona la cantidad",
|
||||
"option": "{amount} oro — {price} €",
|
||||
"success": "El oro ha sido añadido al personaje {name}."
|
||||
"success": "El oro ha sido añadido al personaje {name}.",
|
||||
"send": "ENVIAR ORO"
|
||||
},
|
||||
"transfer": {
|
||||
"title": "Transferir personaje",
|
||||
@@ -552,13 +581,53 @@
|
||||
"noSites": "No hay sitios de votación configurados todavía.",
|
||||
"loginToVote": "Inicia sesión para votar.",
|
||||
"infoSections": [
|
||||
{ "h": "¿Qué son las votaciones?", "lines": ["Una votación es la acción de darle un voto al servidor en una página web que ofrezca listas de tops.", "Como recompensa a realizar una votación por {server}, se otorgan PV."] },
|
||||
{ "h": "¿Qué son los PV?", "lines": ["Brindamos a nuestros usuarios la oportunidad de obtener niveles con beneficios exclusivos en nuestra comunidad en línea. Estos beneficios incluyen acceso a herramientas especiales, contenido exclusivo y mejoras significativas en la experiencia de nuestro sitio web. Al realizar una votación, los usuarios reciben PV, una unidad de reconocimiento interna que refleja su compromiso y progreso en nuestra plataforma. Los PV permiten alcanzar nuevos niveles y desbloquear recompensas exclusivas. Destacamos que los PV son una forma única de valorar y premiar la participación activa de nuestros usuarios en la comunidad."] },
|
||||
{ "h": "¿Cómo se puede votar?", "lines": ["Para votar por {server}, se debe hacer click en los botones que aparecen debajo, y cada uno de ellos redirigirá a la página web del top correspondiente.", "Una vez allí, deberás darle un voto al servidor y así recibirás tus PV."] },
|
||||
{ "h": "¿Cuántos PV se reciben por cada voto?", "lines": ["En la lista inferior, cada sitio indica cuántos PV otorgará si se realiza una votación exitosa."] },
|
||||
{ "h": "¿Cada cuánto se puede votar?", "lines": ["Sólo se puede votar por cada sitio cada 12 horas.", "El recuadro informa sobre la última vez que has votado en cada sitio."] },
|
||||
{ "h": "¿Por qué el voto no cuenta en la web?", "lines": ["Usamos el sistema de postback por lo que cada sitio web de votos nos envía una respuesta cada vez que un usuario vota por nuestro servidor.", "Una vez que la respuesta es analizada en nuestra web, se sumarán los PV correspondientes siempre que el usuario haya votado correctamente.", "Cada sitio de votación tiene, además, sus propias restricciones como sólo permitir un voto por IP por lo cual puede suceder que el voto no cuente si se usan varias cuentas para votar en el mismo día."] },
|
||||
{ "h": "¿Tienes dudas o problemas para votar?", "lines": ["Si a pesar de leer este instructivo aún tienes dudas, el equipo de {server} podrá asesorarte en cualquier momento. Puedes colocar un ticket solicitando ayuda para votar."] }
|
||||
{
|
||||
"h": "¿Qué son las votaciones?",
|
||||
"lines": [
|
||||
"Una votación es la acción de darle un voto al servidor en una página web que ofrezca listas de tops.",
|
||||
"Como recompensa a realizar una votación por {server}, se otorgan PV."
|
||||
]
|
||||
},
|
||||
{
|
||||
"h": "¿Qué son los PV?",
|
||||
"lines": [
|
||||
"Brindamos a nuestros usuarios la oportunidad de obtener niveles con beneficios exclusivos en nuestra comunidad en línea. Estos beneficios incluyen acceso a herramientas especiales, contenido exclusivo y mejoras significativas en la experiencia de nuestro sitio web. Al realizar una votación, los usuarios reciben PV, una unidad de reconocimiento interna que refleja su compromiso y progreso en nuestra plataforma. Los PV permiten alcanzar nuevos niveles y desbloquear recompensas exclusivas. Destacamos que los PV son una forma única de valorar y premiar la participación activa de nuestros usuarios en la comunidad."
|
||||
]
|
||||
},
|
||||
{
|
||||
"h": "¿Cómo se puede votar?",
|
||||
"lines": [
|
||||
"Para votar por {server}, se debe hacer click en los botones que aparecen debajo, y cada uno de ellos redirigirá a la página web del top correspondiente.",
|
||||
"Una vez allí, deberás darle un voto al servidor y así recibirás tus PV."
|
||||
]
|
||||
},
|
||||
{
|
||||
"h": "¿Cuántos PV se reciben por cada voto?",
|
||||
"lines": [
|
||||
"En la lista inferior, cada sitio indica cuántos PV otorgará si se realiza una votación exitosa."
|
||||
]
|
||||
},
|
||||
{
|
||||
"h": "¿Cada cuánto se puede votar?",
|
||||
"lines": [
|
||||
"Sólo se puede votar por cada sitio cada 12 horas.",
|
||||
"El recuadro informa sobre la última vez que has votado en cada sitio."
|
||||
]
|
||||
},
|
||||
{
|
||||
"h": "¿Por qué el voto no cuenta en la web?",
|
||||
"lines": [
|
||||
"Usamos el sistema de postback por lo que cada sitio web de votos nos envía una respuesta cada vez que un usuario vota por nuestro servidor.",
|
||||
"Una vez que la respuesta es analizada en nuestra web, se sumarán los PV correspondientes siempre que el usuario haya votado correctamente.",
|
||||
"Cada sitio de votación tiene, además, sus propias restricciones como sólo permitir un voto por IP por lo cual puede suceder que el voto no cuente si se usan varias cuentas para votar en el mismo día."
|
||||
]
|
||||
},
|
||||
{
|
||||
"h": "¿Tienes dudas o problemas para votar?",
|
||||
"lines": [
|
||||
"Si a pesar de leer este instructivo aún tienes dudas, el equipo de {server} podrá asesorarte en cualquier momento. Puedes colocar un ticket solicitando ayuda para votar."
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Common": {
|
||||
@@ -621,4 +690,4 @@
|
||||
"title": "Página no encontrada",
|
||||
"message": "Parece que la página que estás buscando no existe o no se encuentra disponible."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Metadata de servicio para pagos SumUp con campos extra (p.ej. gold_amount en
|
||||
-- /gold-character). La entrega (fulfillSumUpCheckout) parsea este JSON y lo pasa
|
||||
-- a la acción del servicio. NULL para servicios sin campos extra o compras de PD.
|
||||
-- Ver lib/sumup.ts. (MySQL 9.x no soporta ADD COLUMN IF NOT EXISTS.)
|
||||
ALTER TABLE home_stripelog ADD COLUMN metadata TEXT NULL DEFAULT NULL;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Cooldowns del Rastreador de misiones (/quest-character), por personaje y categoría.
|
||||
-- category: 'class' (30 min), 'reputation' (1 h), 'specific' (10 min).
|
||||
CREATE TABLE IF NOT EXISTS home_questsearchhistory (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
character_name VARCHAR(12) NOT NULL,
|
||||
category VARCHAR(16) NOT NULL,
|
||||
searched_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_char_cat (character_name, category, searched_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
Reference in New Issue
Block a user