From eaae16e33a138ab01aab8affc4c605e49ae5caed Mon Sep 17 00:00:00 2001 From: adevopg Date: Tue, 14 Jul 2026 12:39:28 +0000 Subject: [PATCH] web-next: change-race/faction/level-up/gold por SumUp + rastreador de misiones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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) --- .../change-faction-character/page.tsx | 107 ++++++++++ web-next/app/[locale]/change-faction/page.tsx | 8 - .../[locale]/change-race-character/page.tsx | 76 +++++++ web-next/app/[locale]/change-race/page.tsx | 8 - web-next/app/[locale]/gold-character/page.tsx | 81 ++++++++ web-next/app/[locale]/gold/page.tsx | 29 --- .../app/[locale]/level-up-character/page.tsx | 67 +++++++ web-next/app/[locale]/level-up/page.tsx | 8 - .../app/[locale]/quest-character/page.tsx | 125 ++++++++++++ .../api/character/[service]/checkout/route.ts | 7 + web-next/app/api/character/quest/route.ts | 79 ++++++++ web-next/components/AccountTools.tsx | 10 +- web-next/components/GoldForm.tsx | 11 +- web-next/components/PaidServiceForm.tsx | 6 +- web-next/components/QuestTrackerForm.tsx | 189 ++++++++++++++++++ web-next/lib/change-faction.ts | 55 +++++ web-next/lib/characters.ts | 5 +- web-next/lib/paid-services.ts | 31 +-- web-next/lib/quest-tracker.ts | 127 ++++++++++++ web-next/lib/sumup.ts | 32 ++- web-next/messages/en.json | 95 +++++++-- web-next/messages/es.json | 95 +++++++-- web-next/sql/add_stripelog_metadata.sql | 5 + web-next/sql/home_questsearchhistory.sql | 10 + 24 files changed, 1154 insertions(+), 112 deletions(-) create mode 100644 web-next/app/[locale]/change-faction-character/page.tsx delete mode 100644 web-next/app/[locale]/change-faction/page.tsx create mode 100644 web-next/app/[locale]/change-race-character/page.tsx delete mode 100644 web-next/app/[locale]/change-race/page.tsx create mode 100644 web-next/app/[locale]/gold-character/page.tsx delete mode 100644 web-next/app/[locale]/gold/page.tsx create mode 100644 web-next/app/[locale]/level-up-character/page.tsx delete mode 100644 web-next/app/[locale]/level-up/page.tsx create mode 100644 web-next/app/[locale]/quest-character/page.tsx create mode 100644 web-next/app/api/character/quest/route.ts create mode 100644 web-next/components/QuestTrackerForm.tsx create mode 100644 web-next/lib/change-faction.ts create mode 100644 web-next/lib/quest-tracker.ts create mode 100644 web-next/sql/add_stripelog_metadata.sql create mode 100644 web-next/sql/home_questsearchhistory.sql diff --git a/web-next/app/[locale]/change-faction-character/page.tsx b/web-next/app/[locale]/change-faction-character/page.tsx new file mode 100644 index 0000000..fdc6afe --- /dev/null +++ b/web-next/app/[locale]/change-faction-character/page.tsx @@ -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 ( + + {amount} + {/* eslint-disable-next-line @next/next/no-img-element */} + oro + + ) +} + +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 ( + + +
+

+ La herramienta Cambiar facción del personaje te permite cambiar de la Alianza a la Horda y + viceversa. +

+

Si también deseas cambiar el nombre del personaje, debes usar la herramienta Renombrar personaje.

+
+

Al hacer un cambio de Facción:

+

- Los items, hechizos, títulos, reputaciones, monturas y logros se cambiarán a la nueva facción

+

- Las misiones activas en el Registro de misiones serán abandonadas

+

- Los teams de arena serán borrados

+

- Los amigos en la Lista de amigos se borrarán

+
+

Restricciones del personaje:

+

- No debe tener subastas activas

+

- No debe tener correos en el buzón

+

- No debe ser capitán de un team de arenas

+

- No debe ser miembro de una hermandad

+

- No debe tener demasiado oro

+
+ + + + + + + + + + + +
NivelMax. Oro
10-30
31-50
51-70
71-80
+
+

+ 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. +

+

Cuando ingreses a la lista de personajes de tu cuenta, verás un ícono a la izquierda de tu personaje escogido.

+

Al darle click a dicho ícono ingresarás al menú de edición de personaje que te permitirá pasar a la facción contraria.

+
+
+ NOTA +
+

+ Por favor, asegúrate de haber escogido al personaje correcto ya que esta acción no es reversible una vez + realizada. +

+
+
+ +
+
+
+

Requiere {price} (SumUp)

+
+ + ({ 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)?`} + /> +
+
+ ) +} diff --git a/web-next/app/[locale]/change-faction/page.tsx b/web-next/app/[locale]/change-faction/page.tsx deleted file mode 100644 index c07ace2..0000000 --- a/web-next/app/[locale]/change-faction/page.tsx +++ /dev/null @@ -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 -} diff --git a/web-next/app/[locale]/change-race-character/page.tsx b/web-next/app/[locale]/change-race-character/page.tsx new file mode 100644 index 0000000..fe07e5e --- /dev/null +++ b/web-next/app/[locale]/change-race-character/page.tsx @@ -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 ( + + +
+

+ ESTE CAMBIO NO PERMITE CAMBIAR A LA FACCIÓN CONTRARIA{' '} + +

+

+ La herramienta Cambiar raza del personaje te permite cambiar la raza del personaje entre otras de + la MISMA facción. +

+

Si también deseas cambiar el nombre del personaje, debes usar la herramienta Renombrar personaje.

+
+

+ Al usar el botón CAMBIAR RAZA del personaje que hayas escogido, se enviará una petición de cambio de raza de + dicho personaje. +

+

Cuando ingreses a la lista de personajes de tu cuenta, verás un ícono a la izquierda de tu personaje escogido.

+

+ 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. +

+
+
+ NOTA +
+

+ Por favor, asegúrate de haber escogido al personaje correcto ya que esta acción no es reversible una vez + realizada. +

+
+
+ +
+
+
+

Requiere {price} (SumUp)

+
+ + ({ 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)?`} + /> +
+
+ ) +} diff --git a/web-next/app/[locale]/change-race/page.tsx b/web-next/app/[locale]/change-race/page.tsx deleted file mode 100644 index 04b9b04..0000000 --- a/web-next/app/[locale]/change-race/page.tsx +++ /dev/null @@ -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 -} diff --git a/web-next/app/[locale]/gold-character/page.tsx b/web-next/app/[locale]/gold-character/page.tsx new file mode 100644 index 0000000..0a8f349 --- /dev/null +++ b/web-next/app/[locale]/gold-character/page.tsx @@ -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 ( + + +
+

+ La herramienta Adquirir oro te permite enviar oro a un personaje. +

+
+

+ Al usar el botón ENVIAR ORO del personaje que hayas escogido, se enviará un correo con la cantidad de oro + elegida al personaje. +

+

Cuando entres al reino con dicho personaje, tendrás el mensaje con la cantidad de oro seleccionada en tu buzón.

+
+
+ NOTA +
+

+ Por favor, asegúrate de haber escogido al personaje correcto ya que esta acción no es reversible una vez + realizada. +

+
+
+ +
+
+
+

Requiere

+ + + {options.map((o) => ( + + + + + ))} + +
+ + {o.gold_amount} + {/* eslint-disable-next-line @next/next/no-img-element */} + oro = + + + {o.price} + +
+
+
+ + ({ name: c.name, classCss: c.classCss }))} options={options} /> +
+
+ ) +} diff --git a/web-next/app/[locale]/gold/page.tsx b/web-next/app/[locale]/gold/page.tsx deleted file mode 100644 index 49bcab7..0000000 --- a/web-next/app/[locale]/gold/page.tsx +++ /dev/null @@ -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 ( - - - ({ name: c.name, classCss: c.classCss }))} options={options} /> - - - ) -} diff --git a/web-next/app/[locale]/level-up-character/page.tsx b/web-next/app/[locale]/level-up-character/page.tsx new file mode 100644 index 0000000..04f1156 --- /dev/null +++ b/web-next/app/[locale]/level-up-character/page.tsx @@ -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 ( + + +
+

+ La herramienta Subir a nivel 80 te permite subir cualquier personaje al nivel 80. +

+

Sólo subirá de nivel al personaje. Esta opción no incluye ítems/oro/etc.

+
+

+ Al usar el botón SUBIR A NIVEL 80 del personaje que hayas escogido, se enviará el nivel 80 al personaje. +

+

Cuando ingreses a la lista de personajes de tu cuenta, verás que tu personaje escogido ya es nivel 80.

+
+
+ NOTA +
+

Se requiere que el personaje esté desconectado.

+

+ Por favor, asegúrate de haber escogido al personaje correcto ya que esta acción no es reversible una vez + realizada. +

+
+
+ +
+
+
+

Requiere {price} (SumUp)

+
+ + ({ 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)?`} + /> +
+
+ ) +} diff --git a/web-next/app/[locale]/level-up/page.tsx b/web-next/app/[locale]/level-up/page.tsx deleted file mode 100644 index 0da8e19..0000000 --- a/web-next/app/[locale]/level-up/page.tsx +++ /dev/null @@ -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 -} diff --git a/web-next/app/[locale]/quest-character/page.tsx b/web-next/app/[locale]/quest-character/page.tsx new file mode 100644 index 0000000..6094579 --- /dev/null +++ b/web-next/app/[locale]/quest-character/page.tsx @@ -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 ( + + +
+

+ La herramienta Rastreador de misiones te permite ver el estado de misiones o cadenas de misiones + importantes en tus personajes. +

+
+

Opciones disponibles:

+ {CLASS_GROUPS.map((g) => { + const opts = QUEST_OPTIONS.filter((o) => o.classCss === g.css) + if (opts.length === 0) return null + return ( +
+

{g.name}

+
    + {opts.map((o) => ( +
  • + {o.link ? ( + + {o.label} + + ) : ( + o.label + )}{' '} + a partir del nivel {o.minLevel} +
  • + ))} +
+
+
+ ) + })} +

Por reputación

+ +
+

Por misión específica

+
    +
  • A partir del nivel 10
  • +
+
+

Cómo buscar por misión específica:

+

1) Visita nuestra base de datos.

+

2) Busca allí el nombre de la misión.

+

3) Una vez que la hayas encontrado, su enlace se verá cómo "{QUEST_DB}/?quest=12843"

+

4) Usa el número final del enlace en el campo ID de misión (Ejemplo del enlace anterior: 12843).

+
+

Al usar el botón BUSCAR MISIONES, podrás ver el estado de las misiones del personaje.

+
+
+ NOTA +
+

Se requiere que el personaje esté desconectado y cumpla con el mínimo de nivel de cada opción.

+

Se puede repetir búsqueda de cadenas de misiones en un mismo personaje cada 1 hora.

+

Se puede repetir búsqueda de cadenas de misiones de clase en un mismo personaje cada 30 minutos.

+

Se puede repetir búsqueda de misión específica en un mismo personaje cada 10 minutos.

+
+
+ + ({ name: c.name, classCss: c.classCss, level: c.level }))} options={clientOptions} /> +
+
+ ) +} diff --git a/web-next/app/api/character/[service]/checkout/route.ts b/web-next/app/api/character/[service]/checkout/route.ts index 036f978..73473d9 100644 --- a/web-next/app/api/character/[service]/checkout/route.ts +++ b/web-next/app/api/character/[service]/checkout/route.ts @@ -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 = { 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 }) diff --git a/web-next/app/api/character/quest/route.ts b/web-next/app/api/character/quest/route.ts new file mode 100644 index 0000000..a31e8e2 --- /dev/null +++ b/web-next/app/api/character/quest/route.ts @@ -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 = {} + 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( + '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 }) +} diff --git a/web-next/components/AccountTools.tsx b/web-next/components/AccountTools.tsx index 189cd59..e704978 100644 --- a/web-next/components/AccountTools.tsx +++ b/web-next/components/AccountTools.tsx @@ -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') }, diff --git a/web-next/components/GoldForm.tsx b/web-next/components/GoldForm.tsx index 55eb773..5edf53f 100644 --- a/web-next/components/GoldForm.tsx +++ b/web-next/components/GoldForm.tsx @@ -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

diff --git a/web-next/components/PaidServiceForm.tsx b/web-next/components/PaidServiceForm.tsx index e12b0bd..0816454 100644 --- a/web-next/components/PaidServiceForm.tsx +++ b/web-next/components/PaidServiceForm.tsx @@ -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 { diff --git a/web-next/components/QuestTrackerForm.tsx b/web-next/components/QuestTrackerForm.tsx new file mode 100644 index 0000000..dd441de --- /dev/null +++ b/web-next/components/QuestTrackerForm.tsx @@ -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) { + 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 ( +
+
+ No hay personajes disponibles para esta herramienta +
+
+ ) + } + + 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 ( +
+
+ +
+ +
+ {option === 'specific' && ( + <> + setQuestId(e.target.value)} + required + /> +
+ + )} + + + +
+
+ {message && {message.text}} + {result && ( +
+

{result.label}

+ + + {result.statuses.map((s) => { + const st = stateText(s.state) + return ( + + + + + ) + })} + +
+ + Misión #{s.quest} + + {st.text}
+
+ )} +
+
+ ) +} diff --git a/web-next/lib/change-faction.ts b/web-next/lib/change-faction.ts new file mode 100644 index 0000000..6b91539 --- /dev/null +++ b/web-next/lib/change-faction.ts @@ -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 { + const [chars] = await db(DB.characters).query( + '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 => { + try { + const [rows] = await db(DB.characters).query(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 } +} diff --git a/web-next/lib/characters.ts b/web-next/lib/characters.ts index 081b454..c71a67f 100644 --- a/web-next/lib/characters.ts +++ b/web-next/lib/characters.ts @@ -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