diff --git a/web-next/app/[locale]/revive/page.tsx b/web-next/app/[locale]/revive/page.tsx
new file mode 100644
index 0000000..6629589
--- /dev/null
+++ b/web-next/app/[locale]/revive/page.tsx
@@ -0,0 +1,24 @@
+import { getTranslations, setRequestLocale } from 'next-intl/server'
+import { redirect } from '@/i18n/navigation'
+import { getSession } from '@/lib/session'
+import { getGameCharacters } from '@/lib/characters'
+import { CharacterActionForm } from '@/components/CharacterActionForm'
+
+export const dynamic = 'force-dynamic'
+
+export default async function RevivePage({ params }: { params: Promise<{ locale: string }> }) {
+ const { locale } = await params
+ setRequestLocale(locale)
+ const t = await getTranslations('Services')
+ 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!)
+
+ return (
+
+ {t('reviveTitle')}
+ c.name)} endpoint="/api/character/revive" actionLabel={t('reviveAction')} />
+
+ )
+}
diff --git a/web-next/app/[locale]/unstuck/page.tsx b/web-next/app/[locale]/unstuck/page.tsx
new file mode 100644
index 0000000..72f1085
--- /dev/null
+++ b/web-next/app/[locale]/unstuck/page.tsx
@@ -0,0 +1,24 @@
+import { getTranslations, setRequestLocale } from 'next-intl/server'
+import { redirect } from '@/i18n/navigation'
+import { getSession } from '@/lib/session'
+import { getGameCharacters } from '@/lib/characters'
+import { CharacterActionForm } from '@/components/CharacterActionForm'
+
+export const dynamic = 'force-dynamic'
+
+export default async function UnstuckPage({ params }: { params: Promise<{ locale: string }> }) {
+ const { locale } = await params
+ setRequestLocale(locale)
+ const t = await getTranslations('Services')
+ 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!)
+
+ return (
+
+ {t('unstuckTitle')}
+ c.name)} endpoint="/api/character/unstuck" actionLabel={t('unstuckAction')} />
+
+ )
+}
diff --git a/web-next/app/api/character/revive/route.ts b/web-next/app/api/character/revive/route.ts
new file mode 100644
index 0000000..fd281f0
--- /dev/null
+++ b/web-next/app/api/character/revive/route.ts
@@ -0,0 +1,15 @@
+import { getSession } from '@/lib/session'
+import { reviveCharacter } from '@/lib/character-services'
+
+export async function POST(request: Request) {
+ const session = await getSession()
+ if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
+ let character = ''
+ try {
+ const body = await request.json()
+ character = String(body.character ?? '')
+ } catch {
+ return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
+ }
+ return Response.json(await reviveCharacter(session.accountId, character))
+}
diff --git a/web-next/app/api/character/unstuck/route.ts b/web-next/app/api/character/unstuck/route.ts
new file mode 100644
index 0000000..9f119e9
--- /dev/null
+++ b/web-next/app/api/character/unstuck/route.ts
@@ -0,0 +1,15 @@
+import { getSession } from '@/lib/session'
+import { unstuckCharacter } from '@/lib/character-services'
+
+export async function POST(request: Request) {
+ const session = await getSession()
+ if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
+ let character = ''
+ try {
+ const body = await request.json()
+ character = String(body.character ?? '')
+ } catch {
+ return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
+ }
+ return Response.json(await unstuckCharacter(session.accountId, character))
+}
diff --git a/web-next/components/CharacterActionForm.tsx b/web-next/components/CharacterActionForm.tsx
new file mode 100644
index 0000000..c551100
--- /dev/null
+++ b/web-next/components/CharacterActionForm.tsx
@@ -0,0 +1,79 @@
+'use client'
+
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+
+const ERROR_KEYS = ['noCharacter', 'notOfflineOrOwned', 'cooldown', 'soapError'] as const
+
+interface Props {
+ characters: string[]
+ endpoint: string
+ actionLabel: string
+}
+
+/** Formulario reutilizable para acciones de personaje por SOAP (revive, unstuck...). */
+export function CharacterActionForm({ characters, endpoint, actionLabel }: Props) {
+ const t = useTranslations('Services')
+ const [character, setCharacter] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null)
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ if (busy || !character) return
+ setBusy(true)
+ setMessage(null)
+ try {
+ const res = await fetch(endpoint, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'same-origin',
+ body: JSON.stringify({ character }),
+ })
+ const data: { success?: boolean; error?: string } = await res.json()
+ if (data.success) setMessage({ ok: true, text: t('success') })
+ else {
+ const key = (ERROR_KEYS as readonly string[]).includes(data.error ?? '') ? data.error! : 'genericError'
+ setMessage({ ok: false, text: t(key) })
+ }
+ } catch {
+ setMessage({ ok: false, text: t('genericError') })
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ if (characters.length === 0) {
+ return
{t('noCharactersYet')}
+ }
+
+ return (
+
+
+ {message &&
{message.text}
}
+
+ )
+}
diff --git a/web-next/lib/character-services.ts b/web-next/lib/character-services.ts
new file mode 100644
index 0000000..c75cd2d
--- /dev/null
+++ b/web-next/lib/character-services.ts
@@ -0,0 +1,57 @@
+import type { RowDataPacket } from 'mysql2'
+import { db, DB } from './db'
+import { executeSoapCommand } from './soap'
+import { characterIsOfflineAndOwned } from './characters'
+
+export interface Result {
+ success: boolean
+ error?: string
+}
+
+interface SoapActionConfig {
+ command: (character: string) => string
+ historyTable: string // tabla django_wow con columnas (character_name, used_at)
+ cooldownHours: number
+}
+
+/** Acción de personaje por SOAP con cooldown (revive, unstuck...). */
+async function soapCharacterAction(accountId: number, character: string, cfg: SoapActionConfig): Promise {
+ if (!character) return { success: false, error: 'noCharacter' }
+ if (!(await characterIsOfflineAndOwned(accountId, character))) {
+ return { success: false, error: 'notOfflineOrOwned' }
+ }
+ try {
+ const [rows] = await db(DB.default).query(
+ `SELECT used_at FROM ${cfg.historyTable} WHERE character_name = ? ORDER BY used_at DESC LIMIT 1`,
+ [character],
+ )
+ if (rows[0] && Date.now() - new Date(rows[0].used_at).getTime() < cfg.cooldownHours * 3600_000) {
+ return { success: false, error: 'cooldown' }
+ }
+ } catch {
+ /* tabla de historial ausente: seguimos */
+ }
+ const resp = await executeSoapCommand(cfg.command(character))
+ if (resp === null) return { success: false, error: 'soapError' }
+ await db(DB.default).query(
+ `INSERT INTO ${cfg.historyTable} (character_name, used_at) VALUES (?, NOW())`,
+ [character],
+ )
+ return { success: true }
+}
+
+export function reviveCharacter(accountId: number, character: string): Promise {
+ return soapCharacterAction(accountId, character, {
+ command: (c) => `.revive ${c}`,
+ historyTable: 'home_revivehistory',
+ cooldownHours: 12,
+ })
+}
+
+export function unstuckCharacter(accountId: number, character: string): Promise {
+ return soapCharacterAction(accountId, character, {
+ command: (c) => `.unstuck ${c}`,
+ historyTable: 'home_unstuckhistory',
+ cooldownHours: 12,
+ })
+}
diff --git a/web-next/lib/characters.ts b/web-next/lib/characters.ts
new file mode 100644
index 0000000..c565505
--- /dev/null
+++ b/web-next/lib/characters.ts
@@ -0,0 +1,27 @@
+import type { RowDataPacket } from 'mysql2'
+import { db, DB } from './db'
+
+export interface GameCharacter {
+ name: string
+ online: boolean
+}
+
+/** Personajes de una cuenta de juego (acore_characters). Vacío si no está poblada. */
+export async function getGameCharacters(accountId: number): Promise {
+ try {
+ const [rows] = await db(DB.characters).query(
+ 'SELECT name, online FROM characters WHERE account = ?',
+ [accountId],
+ )
+ return rows.map((r) => ({ name: r.name, online: r.online === 1 }))
+ } catch {
+ return []
+ }
+}
+
+/** True si el personaje pertenece a la cuenta y está desconectado. */
+export async function characterIsOfflineAndOwned(accountId: number, name: string): Promise {
+ const chars = await getGameCharacters(accountId)
+ const c = chars.find((ch) => ch.name === name)
+ return Boolean(c && !c.online)
+}
diff --git a/web-next/lib/soap.ts b/web-next/lib/soap.ts
new file mode 100644
index 0000000..9becdb5
--- /dev/null
+++ b/web-next/lib/soap.ts
@@ -0,0 +1,34 @@
+// Cliente SOAP al worldserver de AzerothCore (port de home/ac_soap.py).
+function escapeXml(s: string): string {
+ return s.replace(/&/g, '&').replace(//g, '>')
+}
+
+/** Ejecuta un comando en el worldserver por SOAP. Devuelve el texto de respuesta o null. */
+export async function executeSoapCommand(command: string): Promise {
+ const url = process.env.AC_SOAP_URL
+ const user = process.env.AC_SOAP_USER ?? ''
+ const pass = process.env.AC_SOAP_PASSWORD ?? ''
+ const urn = process.env.AC_SOAP_URN ?? 'urn:AC'
+ if (!url) return null
+
+ const soap = `
+
+ ${escapeXml(command)}
+`
+
+ try {
+ const res = await fetch(url, {
+ method: 'POST',
+ headers: {
+ Authorization: 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64'),
+ 'Content-Type': 'application/xml',
+ },
+ body: soap,
+ signal: AbortSignal.timeout(5000),
+ })
+ if (!res.ok) return null
+ return await res.text()
+ } catch {
+ return null // worldserver inaccesible
+ }
+}
diff --git a/web-next/messages/en.json b/web-next/messages/en.json
index 1614905..b19c786 100644
--- a/web-next/messages/en.json
+++ b/web-next/messages/en.json
@@ -120,5 +120,20 @@
"passwordTooLong": "The password must not exceed 16 characters.",
"accountNotFound": "Account not found.",
"genericError": "Something went wrong. Please try again later."
+ },
+ "Services": {
+ "reviveTitle": "Revive character",
+ "unstuckTitle": "Unstuck character",
+ "reviveAction": "Revive",
+ "unstuckAction": "Unstuck",
+ "selectCharacter": "Select a character",
+ "noCharactersYet": "You have no available characters.",
+ "processing": "Processing…",
+ "success": "Action completed successfully.",
+ "noCharacter": "Select a character.",
+ "notOfflineOrOwned": "The character must be offline and yours.",
+ "cooldown": "You can only do this once every 12 hours.",
+ "soapError": "Server communication error. Please try again later.",
+ "genericError": "Something went wrong. Please try again later."
}
}
diff --git a/web-next/messages/es.json b/web-next/messages/es.json
index de6b9ab..ed0ace3 100644
--- a/web-next/messages/es.json
+++ b/web-next/messages/es.json
@@ -120,5 +120,20 @@
"passwordTooLong": "La contraseña no debe exceder los 16 caracteres.",
"accountNotFound": "No se ha encontrado la cuenta.",
"genericError": "Algo ha salido mal. Inténtalo más tarde."
+ },
+ "Services": {
+ "reviveTitle": "Revivir personaje",
+ "unstuckTitle": "Desbloquear personaje",
+ "reviveAction": "Revivir",
+ "unstuckAction": "Desbloquear",
+ "selectCharacter": "Selecciona un personaje",
+ "noCharactersYet": "No tienes personajes disponibles.",
+ "processing": "Procesando…",
+ "success": "Acción realizada con éxito.",
+ "noCharacter": "Selecciona un personaje.",
+ "notOfflineOrOwned": "El personaje debe estar desconectado y ser tuyo.",
+ "cooldown": "Solo puedes hacerlo una vez cada 12 horas.",
+ "soapError": "Error de comunicación con el servidor. Inténtalo más tarde.",
+ "genericError": "Algo ha salido mal. Inténtalo más tarde."
}
}