web-next: herramientas de PD (transferir, comercio, códigos de promoción)

- /transfer-d-points: transferir PD a la cuenta dueña de un personaje
  (requiere token de seguridad; movimiento atómico con FOR UPDATE).
- /trade-points: comercio PD<->oro mediante códigos de 1 solo uso
  (contraseña + token + Turnstile; oro al creador por SOAP; expira 1h;
  lock de 5s tras canjear; rollback total si falla la entrega).
- /promo-code: canje de códigos por PD/PV (sensibles a mayúsculas/minúsculas,
  usos limitados, 1 canje por cuenta).
- /admin/promo: panel para crear / editar / activar / desactivar / borrar
  códigos de promoción.
- Tablas nuevas (sql/): home_tradecode, home_promocode, home_promoredemption.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-14 09:27:11 +00:00
parent 22ae0ee278
commit 66d056c04b
25 changed files with 1711 additions and 2 deletions
+1
View File
@@ -22,6 +22,7 @@ export default async function AdminPage({ params }: { params: Promise<{ locale:
{ href: '/admin/users', label: t('manageUsers'), icon: '👤' },
{ href: '/admin/recruit', label: t('manageRecruit'), icon: '🎁' },
{ href: '/admin/forum', label: t('manageForum'), icon: '💬' },
{ href: '/admin/promo', label: t('managePromo'), icon: '🎟️' },
]
const rows: typeof items[] = []
@@ -0,0 +1,29 @@
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { redirect, Link } from '@/i18n/navigation'
import { getSession } from '@/lib/session'
import { isAdmin } from '@/lib/admin'
import { listPromoCodes } from '@/lib/admin-promo'
import { PageShell } from '@/components/PageShell'
import { AdminPromoManager } from '@/components/AdminPromoManager'
export const dynamic = 'force-dynamic'
export default async function AdminPromoPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params
setRequestLocale(locale)
const t = await getTranslations('Admin')
const session = await getSession()
if (!session.bnetId) redirect({ href: '/login', locale })
if (!(await isAdmin(session))) redirect({ href: '/', locale })
const codes = await listPromoCodes()
return (
<PageShell title={t('promo')}>
<div className="box-content">
<div className="body-box-content">
<Link className="back-to-account" href="/admin">{t('backToPanel')}</Link>
<AdminPromoManager initial={codes} />
</div>
</div>
</PageShell>
)
}
+52
View File
@@ -0,0 +1,52 @@
import type { Metadata } from 'next'
import { setRequestLocale } from 'next-intl/server'
import { redirect } from '@/i18n/navigation'
import { getSession } from '@/lib/session'
import { getRealmName } from '@/lib/realm'
import { PageShell } from '@/components/PageShell'
import { ServiceBox } from '@/components/ServiceBox'
import { PromoCodeForm } from '@/components/PromoCodeForm'
export const dynamic = 'force-dynamic'
export const metadata: Metadata = { title: 'Código de promoción' }
export default async function PromoCodePage({ 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 realm = await getRealmName()
return (
<PageShell title="Código de promoción">
<ServiceBox>
<br />
<p>
Los <span>Códigos de promoción</span> son palabras con letras y números que se canjean en el formulario de
abajo por PD o PV y tienen usos limitados. Cada uno puede dar diferente cantidad de PD/PV.
</p>
<p>
Estos códigos son generados por el Equipo de {realm} y pueden aparecer en la página web, Discord, las redes
sociales o el servidor.
</p>
<p>Aconsejamos poner atención a las redes sociales y los anuncios del servidor para saber cuándo hay un código disponible.</p>
<br />
<fieldset>
<legend>NOTA</legend>
<div className="separate2">
<p>
Los códigos son sensibles a mayúsculas y minúsculas por lo que siempre debes escribirlos igual a como
aparecen publicados.
</p>
</div>
</fieldset>
<PromoCodeForm />
</ServiceBox>
</PageShell>
)
}
@@ -0,0 +1,86 @@
import type { Metadata } from 'next'
import { setRequestLocale } from 'next-intl/server'
import { redirect, Link } from '@/i18n/navigation'
import { getSession } from '@/lib/session'
import { getGameCharacters } from '@/lib/characters'
import { PageShell } from '@/components/PageShell'
import { ServiceBox } from '@/components/ServiceBox'
import { TradePointsForm } from '@/components/TradePointsForm'
export const dynamic = 'force-dynamic'
export const metadata: Metadata = { title: 'Comercio de PD' }
export default async function TradePointsPage({ 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!)
return (
<PageShell title="Comercio de PD">
<ServiceBox>
<br />
<p>
La herramienta <span>Comercio de PD</span> permite comprar o vender PD a cambio de oro de una forma segura.
</p>
<br />
<p>El sistema permite crear códigos de 1 solo uso que valdrán X cantidad de PD a cambio de X cantidad de oro.</p>
<p>
Los vendedores disponen de la sección VENTA para crear el código y los compradores disponen de la sección
COMPRA para canjear dicho código.
</p>
<br />
<div className="trade-information">
<h2>Venta</h2>
<p>1) Decide cuánto valdrá el código en PD.</p>
<p>2) Decide por cuánto oro se podrá canjear.</p>
<p>3) Escoge a qué personaje de la cuenta se enviará el oro cuando el código sea canjeado.</p>
<p>4) Completa los datos con la contraseña y Token de Seguridad.</p>
<p>5) Usa el botón CREAR CÓDIGO.</p>
<p>6) Un recuadro aparecerá con los datos del Código creado con el ID que se debe usar para enviar a otro jugador.</p>
<br />
</div>
<div className="trade-information">
<h2>Compra</h2>
<p>1) Ingresa ID del código que te ha dado otro jugador.</p>
<p>2) Chequea el valor en PD y costo en oro con el botón CHEQUEAR CÓDIGO.</p>
<p>3) Escoge de qué personaje de la cuenta se descontará el oro cuando el código sea canjeado.</p>
<p>4) Completa los datos con la contraseña y Token de Seguridad.</p>
<p>5) Usa el botón CANJEAR CÓDIGO.</p>
<p>6) Un recuadro aparecerá con los datos del Código canjeado, PD recibidos y oro retirado del personaje seleccionado.</p>
</div>
<br />
<br />
<p>Cada código tiene una duración de 1 hora.</p>
<p>Una vez canjeado el código:</p>
<p>- Los PD serán retirados de la cuenta del creador del código y enviados a quien lo haya canjeado.</p>
<p>- El oro será retirado y enviado por correo al creador.</p>
<p>- La transacción quedará registrada en el Historial de PD y PV.</p>
<br />
<p>La cuenta de quien canjee el código será bloqueada por 5s como medida de seguridad si el trámite es exitoso.</p>
<br />
<p>
Si aún no tienes Token de seguridad, puedes solicitarlo{' '}
<Link href="/security-token" target="_blank">aquí</Link>.
</p>
<br />
<fieldset>
<legend>NOTA</legend>
<div className="separate2">
<p>
Por favor, asegúrate de haber escrito todos los datos correctos ya que esta acción no es reversible una
vez realizada.
</p>
</div>
</fieldset>
<TradePointsForm characters={chars.map((c) => c.name)} />
</ServiceBox>
</PageShell>
)
}
@@ -0,0 +1,56 @@
import type { Metadata } from 'next'
import { setRequestLocale } from 'next-intl/server'
import { redirect, Link } from '@/i18n/navigation'
import { getSession } from '@/lib/session'
import { getDPointsBalance } from '@/lib/dpoints'
import { PageShell } from '@/components/PageShell'
import { ServiceBox } from '@/components/ServiceBox'
import { TransferDPointsForm } from '@/components/TransferDPointsForm'
export const dynamic = 'force-dynamic'
export const metadata: Metadata = { title: 'Transferir PD' }
export default async function TransferDPointsPage({ 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 balance = await getDPointsBalance(session.accountId!)
return (
<PageShell title="Transferir PD">
<ServiceBox>
<br />
<p>
La herramienta <span>Transferir PD</span> permite transferir PD desde tu cuenta a cualquier otra cuenta.
</p>
<br />
<p>
Al usar el botón TRANSFERIR PD se transferirá la cantidad que hayas escogido desde tu cuenta a la cuenta del
personaje que hayas indicado.
</p>
<br />
<p>
Si aún no tienes Token de seguridad, puedes solicitarlo{' '}
<Link href="/security-token" target="_blank">aquí</Link>.
</p>
<br />
<fieldset>
<legend>NOTA</legend>
<div className="separate2">
<p>
Por favor, asegúrate de haber escrito el nombre del personaje correcto ya que esta acción no es reversible
una vez realizada.
</p>
</div>
</fieldset>
<TransferDPointsForm balance={balance} />
</ServiceBox>
</PageShell>
)
}
@@ -0,0 +1,31 @@
import { getSession } from '@/lib/session'
import { isAdmin } from '@/lib/admin'
import { updatePromoCode, deletePromoCode } from '@/lib/admin-promo'
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
const { id } = await params
let b: Record<string, unknown> = {}
try {
b = await request.json()
} catch {
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
}
const pd = Number(b.pd)
const pv = Number(b.pv)
const maxUses = Number(b.maxUses)
if (!Number.isInteger(pd) || pd < 0 || !Number.isInteger(pv) || pv < 0 || !Number.isInteger(maxUses) || maxUses < 1) {
return Response.json({ success: false, error: 'emptyError' })
}
await updatePromoCode(Number(id), { pd, pv, maxUses, active: Boolean(b.active) })
return Response.json({ success: true })
}
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getSession()
if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
const { id } = await params
await deletePromoCode(Number(id))
return Response.json({ success: true })
}
+28
View File
@@ -0,0 +1,28 @@
import { getSession } from '@/lib/session'
import { isAdmin } from '@/lib/admin'
import { createPromoCode } from '@/lib/admin-promo'
export async function POST(request: Request) {
const session = await getSession()
if (!(await isAdmin(session))) return Response.json({ success: false, error: 'forbidden' }, { status: 403 })
let b: Record<string, unknown> = {}
try {
b = await request.json()
} catch {
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
}
const code = String(b.code ?? '').trim()
const pd = Number(b.pd)
const pv = Number(b.pv)
const maxUses = Number(b.maxUses)
const expiresAt = String(b.expiresAt ?? '')
if (!code || !Number.isInteger(pd) || pd < 0 || !Number.isInteger(pv) || pv < 0) {
return Response.json({ success: false, error: 'emptyError' })
}
if (!Number.isInteger(maxUses) || maxUses < 1) return Response.json({ success: false, error: 'emptyError' })
if (pd === 0 && pv === 0) return Response.json({ success: false, error: 'emptyError' })
const r = await createPromoCode(code, pd, pv, maxUses, expiresAt)
if (r.error) return Response.json({ success: false, error: r.error })
return Response.json({ success: true, id: r.id })
}
@@ -0,0 +1,19 @@
import { getSession } from '@/lib/session'
import { checkTradeCode } from '@/lib/trade'
/** COMPRA: consulta el valor en PD y el coste en oro de un código. */
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 })
}
return Response.json(await checkTradeCode(String(body.code ?? '')))
}
@@ -0,0 +1,33 @@
import { getSession } from '@/lib/session'
import { verifyTurnstile } from '@/lib/turnstile'
import { createTradeCode } from '@/lib/trade'
/** VENTA: crea un código de Comercio de PD. */
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 ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() || undefined
const turnstileOk = await verifyTurnstile(String(body.turnstile ?? ''), ip)
const result = await createTradeCode({
creatorAccount: session.accountId,
creatorEmail: session.bnetEmail || '',
creatorCharacter: String(body.character ?? '').trim(),
points: Number(body.points),
gold: Number(body.gold),
password: String(body.password ?? ''),
token: String(body.token ?? '').trim(),
turnstileOk,
})
return Response.json(result)
}
@@ -0,0 +1,32 @@
import { getSession } from '@/lib/session'
import { verifyTurnstile } from '@/lib/turnstile'
import { redeemTradeCode } from '@/lib/trade'
/** COMPRA: canjea un código de Comercio de PD. */
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 ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() || undefined
const turnstileOk = await verifyTurnstile(String(body.turnstile ?? ''), ip)
const result = await redeemTradeCode({
buyerAccount: session.accountId,
buyerEmail: session.bnetEmail || '',
buyerCharacter: String(body.character ?? '').trim(),
code: String(body.code ?? ''),
password: String(body.password ?? ''),
token: String(body.token ?? '').trim(),
turnstileOk,
})
return Response.json(result)
}
@@ -0,0 +1,40 @@
import { getSession } from '@/lib/session'
import { checkSecurityToken } from '@/lib/security-token'
import { getAccountIdByCharacterName } from '@/lib/characters'
import { transferDPoints } from '@/lib/dpoints'
/**
* Transfiere PD desde la cuenta en sesión a la cuenta dueña del personaje
* indicado. Requiere el token de seguridad de la cuenta. Acción irreversible.
*/
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 token = String(body.token ?? '').trim()
const amount = Number(body.amount)
if (!character || !token) return Response.json({ success: false, error: 'missingFields' })
if (!Number.isInteger(amount) || amount <= 0) return Response.json({ success: false, error: 'invalidAmount' })
if (!(await checkSecurityToken(session.accountId, token))) {
return Response.json({ success: false, error: 'invalidToken' })
}
const destAccountId = await getAccountIdByCharacterName(character)
if (!destAccountId) return Response.json({ success: false, error: 'characterNotFound' })
if (destAccountId === session.accountId) return Response.json({ success: false, error: 'sameAccount' })
const result = await transferDPoints(session.accountId, destAccountId, amount)
return Response.json(result)
}
+19
View File
@@ -0,0 +1,19 @@
import { getSession } from '@/lib/session'
import { redeemPromoCode } from '@/lib/promo'
/** Canjea un código de promoción por PD/PV en la cuenta en sesión. */
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 })
}
return Response.json(await redeemPromoCode(session.accountId, String(body.code ?? '')))
}
+143
View File
@@ -0,0 +1,143 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import type { PromoCode } from '@/lib/admin-promo'
export function AdminPromoManager({ initial }: { initial: PromoCode[] }) {
const t = useTranslations('Admin')
const [codes, setCodes] = useState(initial)
const [form, setForm] = useState({ code: '', pd: '0', pv: '0', maxUses: '1', expiresAt: '' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
function upd(k: string, v: string) {
setForm((f) => ({ ...f, [k]: v }))
}
async function create(e: React.FormEvent) {
e.preventDefault()
if (busy || !form.code.trim()) return
setBusy(true)
setError(null)
try {
const res = await fetch('/api/admin/promo', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
code: form.code.trim(),
pd: Number(form.pd),
pv: Number(form.pv),
maxUses: Number(form.maxUses),
expiresAt: form.expiresAt,
}),
})
const data: { success?: boolean; id?: number; error?: string } = await res.json()
if (data.success && data.id) {
setCodes([
{
id: data.id,
code: form.code.trim(),
pd: Number(form.pd),
pv: Number(form.pv),
max_uses: Number(form.maxUses),
uses: 0,
active: 1,
expires_at: form.expiresAt ? new Date(form.expiresAt).toISOString() : null,
redemptions: 0,
},
...codes,
])
setForm({ code: '', pd: '0', pv: '0', maxUses: '1', expiresAt: '' })
} else {
setError(data.error === 'duplicate' ? t('promoDuplicate') : t('promoEmptyError'))
}
} finally {
setBusy(false)
}
}
async function remove(id: number) {
if (!confirm(t('promoConfirmDelete'))) return
const res = await fetch(`/api/admin/promo/${id}`, { method: 'DELETE', credentials: 'same-origin' })
if ((await res.json()).success) setCodes(codes.filter((c) => c.id !== id))
}
return (
<div>
<form onSubmit={create} className="admin-form info-box-light separate2">
<input value={form.code} onChange={(e) => upd('code', e.target.value)} placeholder={t('promoCode')} />
<input type="number" min="0" value={form.pd} onChange={(e) => upd('pd', e.target.value)} placeholder={t('promoPd')} />
<input type="number" min="0" value={form.pv} onChange={(e) => upd('pv', e.target.value)} placeholder={t('promoPv')} />
<input type="number" min="1" value={form.maxUses} onChange={(e) => upd('maxUses', e.target.value)} placeholder={t('promoMaxUses')} />
<input type="datetime-local" value={form.expiresAt} onChange={(e) => upd('expiresAt', e.target.value)} placeholder={t('promoExpires')} />
<button type="submit" disabled={busy}>{busy ? t('creating') : t('createPromo')}</button>
</form>
{error && <p className="red-form-response centered">{error}</p>}
<br />
{codes.length === 0 ? (
<p className="second-brown centered">{t('noPromo')}</p>
) : (
<table className="max-center-table">
<tbody>
{codes.map((c) => (
<PromoRow key={c.id} promo={c} onDelete={() => remove(c.id)} />
))}
</tbody>
</table>
)}
</div>
)
}
function PromoRow({ promo, onDelete }: { promo: PromoCode; onDelete: () => void }) {
const t = useTranslations('Admin')
const [pd, setPd] = useState(String(promo.pd))
const [pv, setPv] = useState(String(promo.pv))
const [maxUses, setMaxUses] = useState(String(promo.max_uses))
const [active, setActive] = useState(promo.active === 1)
const [busy, setBusy] = useState(false)
const [saved, setSaved] = useState(false)
async function save() {
setBusy(true)
setSaved(false)
try {
const res = await fetch(`/api/admin/promo/${promo.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ pd: Number(pd), pv: Number(pv), maxUses: Number(maxUses), active }),
})
if ((await res.json()).success) {
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
} finally {
setBusy(false)
}
}
return (
<tr className="team-center-table-tr">
<td className="lefted separate">
<span className="yellow-info">{promo.code}</span>
<p className="third-brown small-font">
{t('promoUses')}: {promo.uses}/{promo.max_uses} · {t('promoRedemptions')}: {promo.redemptions}
{promo.expires_at ? ` · ${new Date(promo.expires_at).toLocaleString('es-ES')}` : ''}
</p>
</td>
<td className="real-info-box">
<label className="small-font">{t('promoPd')} <input type="number" min="0" value={pd} onChange={(e) => setPd(e.target.value)} className="admin-mini-input" /></label>{' '}
<label className="small-font">{t('promoPv')} <input type="number" min="0" value={pv} onChange={(e) => setPv(e.target.value)} className="admin-mini-input" /></label>{' '}
<label className="small-font">{t('promoMaxUses')} <input type="number" min="1" value={maxUses} onChange={(e) => setMaxUses(e.target.value)} className="admin-mini-input" /></label>{' '}
<label className="small-font"><input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} /> {t('promoActive')}</label>
</td>
<td className="real-info-box">
<button onClick={save} disabled={busy} className="nw-tool-btn">{saved ? t('saved') : t('save')}</button>{' '}
<button onClick={onDelete} className="nw-tool-btn red-info2">{t('delete')}</button>
</td>
</tr>
)
}
+85
View File
@@ -0,0 +1,85 @@
'use client'
import { useState } from 'react'
import { useRouter } from '@/i18n/navigation'
function promoError(error?: string): string {
switch (error) {
case 'missingCode':
return 'Escribe un código de promoción.'
case 'notFound':
return 'El código no existe o ya no está disponible.'
case 'expired':
return 'Este código ha caducado.'
case 'exhausted':
return 'Este código ya ha alcanzado su límite de usos.'
case 'alreadyUsed':
return 'Ya has canjeado este código.'
case 'notAuthenticated':
return 'Tu sesión ha expirado. Inicia sesión de nuevo.'
default:
return 'No se pudo canjear el código. Inténtalo de nuevo.'
}
}
export function PromoCodeForm() {
const router = useRouter()
const [code, setCode] = useState('')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (busy || !code.trim()) return
setBusy(true)
setMessage(null)
try {
const res = await fetch('/api/promo/redeem', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ code: code.trim() }),
})
const data: { success?: boolean; error?: string; pd?: number; pv?: number } = await res.json()
if (data.success) {
const parts: string[] = []
if (data.pd) parts.push(`${data.pd} PD`)
if (data.pv) parts.push(`${data.pv} PV`)
const reward = parts.length ? parts.join(' y ') : 'la recompensa'
setMessage({ ok: true, text: `¡Código canjeado! Has recibido ${reward}.` })
setCode('')
router.refresh()
} else {
setMessage({ ok: false, text: promoError(data.error) })
}
} catch {
setMessage({ ok: false, text: promoError() })
} finally {
setBusy(false)
}
}
return (
<div className="centered">
<br />
<form onSubmit={handleSubmit} acceptCharset="utf-8">
<input
type="text"
maxLength={64}
placeholder="Escribe tu código de promoción"
value={code}
onChange={(e) => setCode(e.target.value)}
required
/>
<br />
<button type="submit" className="dnt-button" disabled={busy || !code.trim()}>
{busy ? 'Canjeando…' : 'CANJEAR CÓDIGO'}
</button>
</form>
<hr />
<div className="alert-message" style={{ display: message ? 'block' : 'none' }}>
{message && <span className={message.ok ? 'ok-form-response' : 'red-form-response'}>{message.text}</span>}
</div>
</div>
)
}
+311
View File
@@ -0,0 +1,311 @@
'use client'
import { useState } from 'react'
import { Turnstile } from '@/components/Turnstile'
function tradeError(error?: string): string {
switch (error) {
case 'missingFields':
return 'Rellena todos los campos.'
case 'invalidAmount':
return 'Las cantidades deben ser números enteros entre 1 y 99999.'
case 'characterNotFound':
case 'characterNotOwned':
return 'El personaje seleccionado no es válido.'
case 'captcha':
return 'Verifica que no eres un robot.'
case 'wrongPassword':
return 'La contraseña de tu cuenta es incorrecta.'
case 'invalidToken':
return 'El token de seguridad no es válido.'
case 'insufficientPoints':
return 'No tienes suficientes PD disponibles para crear este código.'
case 'codeNotFound':
return 'No existe ningún código con ese ID.'
case 'codeUnavailable':
return 'El código ya no está disponible (canjeado o expirado).'
case 'cannotRedeemOwn':
return 'No puedes canjear tu propio código.'
case 'characterOnline':
return 'El personaje debe estar desconectado para poder cobrar el oro.'
case 'insufficientGold':
return 'El personaje no tiene suficiente oro.'
case 'creatorInsufficient':
return 'El creador del código ya no tiene suficientes PD.'
case 'locked':
return 'Espera unos segundos antes de canjear otro código.'
case 'deliveryFailed':
return 'No se pudo entregar el oro (servidor de juego no disponible). Inténtalo de nuevo más tarde.'
case 'notAuthenticated':
return 'Tu sesión ha expirado. Inicia sesión de nuevo.'
default:
return 'No se pudo completar la operación. Inténtalo de nuevo.'
}
}
function PasswordInput({
id,
value,
onChange,
placeholder,
maxLength,
}: {
id: string
value: string
onChange: (v: string) => void
placeholder: string
maxLength: number
}) {
const [show, setShow] = useState(false)
return (
<>
<input
type={show ? 'text' : 'password'}
id={id}
maxLength={maxLength}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
/>{' '}
<span
className={`far ${show ? 'fa-eye-slash' : 'fa-eye'} toggle-password`}
role="button"
tabIndex={0}
onClick={() => setShow((s) => !s)}
/>
</>
)
}
/* ---------------------------------- VENTA ---------------------------------- */
function SellForm({ characters, visible }: { characters: string[]; visible: boolean }) {
const [points, setPoints] = useState('')
const [gold, setGold] = useState('')
const [character, setCharacter] = useState('')
const [password, setPassword] = useState('')
const [token, setToken] = useState('')
const [captcha, setCaptcha] = useState('')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null)
const [created, setCreated] = useState<{ code: string; points: number; gold: number } | null>(null)
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (busy) return
setBusy(true)
setMessage(null)
setCreated(null)
try {
const res = await fetch('/api/dpoints/trade/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
points: Number(points),
gold: Number(gold),
character,
password,
token: token.trim(),
turnstile: captcha,
}),
})
const data: { success?: boolean; error?: string; code?: { code: string; points: number; gold: number } } = await res.json()
if (data.success && data.code) {
setCreated(data.code)
setMessage({ ok: true, text: '¡Código creado!' })
setPoints('')
setGold('')
setPassword('')
setToken('')
} else {
setMessage({ ok: false, text: tradeError(data.error) })
}
} catch {
setMessage({ ok: false, text: tradeError() })
} finally {
setBusy(false)
}
}
return (
<div id="trade-sell-div" style={{ display: visible ? 'block' : 'none' }}>
<form onSubmit={handleSubmit} acceptCharset="utf-8">
<table className="middle-center-table">
<tbody>
<tr><td>¿Cuánto valdrá el código en PD?</td></tr>
<tr><td><input type="number" maxLength={5} min="1" max="99999" step="1" value={points} onChange={(e) => setPoints(e.target.value)} placeholder="Valor del código en PD" required /></td></tr>
<tr><td>¿Cuánto costará el código en oro?</td></tr>
<tr><td><input type="number" maxLength={5} min="1" max="99999" step="1" value={gold} onChange={(e) => setGold(e.target.value)} placeholder="Valor del código en oro" required /></td></tr>
<tr><td>¿A qué personaje se enviará el oro?</td></tr>
<tr><td>
<select value={character} onChange={(e) => setCharacter(e.target.value)} required>
<option disabled value="">Selecciona un personaje</option>
{characters.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</td></tr>
<tr><td><br /><PasswordInput id="password-sell" value={password} onChange={setPassword} placeholder="Contraseña de tu cuenta" maxLength={16} /></td></tr>
<tr><td><input type="text" maxLength={6} value={token} onChange={(e) => setToken(e.target.value)} placeholder="Token de seguridad" required /></td></tr>
<tr><td><Turnstile onVerify={setCaptcha} /></td></tr>
<tr><td><button type="submit" className="trade-points-sell-button" disabled={busy}>{busy ? 'Creando…' : 'Crear código'}</button></td></tr>
</tbody>
</table>
</form>
<hr />
<div className="alert-message" id="trade-points-sell-response" style={{ display: message || created ? 'block' : 'none' }}>
{message && <span className={message.ok ? 'ok-form-response' : 'red-form-response'}>{message.text}</span>}
{created && (
<fieldset>
<legend>Código creado</legend>
<div className="separate2">
<p>ID del código: <span className="green-info2">{created.code}</span></p>
<p>Valor: <span>{created.points} PD</span> por <span>{created.gold} de oro</span></p>
<p className="second-brown">Envía este ID al comprador. El código caduca en 1 hora.</p>
</div>
</fieldset>
)}
</div>
</div>
)
}
/* ---------------------------------- COMPRA --------------------------------- */
function BuyForm({ characters, visible }: { characters: string[]; visible: boolean }) {
const [code, setCode] = useState('')
const [character, setCharacter] = useState('')
const [password, setPassword] = useState('')
const [token, setToken] = useState('')
const [captcha, setCaptcha] = useState('')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null)
const [checkInfo, setCheckInfo] = useState<{ points: number; gold: number } | null>(null)
async function handleCheck() {
if (busy || !code.trim()) return
setBusy(true)
setMessage(null)
setCheckInfo(null)
try {
const res = await fetch('/api/dpoints/trade/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ code: code.trim() }),
})
const data: { success?: boolean; error?: string; points?: number; gold?: number } = await res.json()
if (data.success && data.points != null && data.gold != null) {
setCheckInfo({ points: data.points, gold: data.gold })
setMessage({ ok: true, text: `Este código vale ${data.points} PD y cuesta ${data.gold} de oro.` })
} else {
setMessage({ ok: false, text: tradeError(data.error) })
}
} catch {
setMessage({ ok: false, text: tradeError() })
} finally {
setBusy(false)
}
}
async function handleRedeem(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (busy) return
if (!window.confirm('Vas a canjear este código. Esta acción no es reversible. ¿Deseas continuar?')) return
setBusy(true)
setMessage(null)
try {
const res = await fetch('/api/dpoints/trade/redeem', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ code: code.trim(), character, password, token: token.trim(), turnstile: captcha }),
})
const data: { success?: boolean; error?: string; points?: number; gold?: number } = await res.json()
if (data.success) {
setMessage({ ok: true, text: `¡Canje correcto! Recibiste ${data.points} PD y se retiraron ${data.gold} de oro de ${character}.` })
setCode('')
setPassword('')
setToken('')
setCheckInfo(null)
} else {
setMessage({ ok: false, text: tradeError(data.error) })
}
} catch {
setMessage({ ok: false, text: tradeError() })
} finally {
setBusy(false)
}
}
return (
<div id="trade-buy-div" style={{ display: visible ? 'block' : 'none' }}>
<form onSubmit={handleRedeem} acceptCharset="utf-8">
<table className="middle-center-table">
<tbody>
<tr><td>Ingresa el ID del código de PD</td></tr>
<tr><td><input type="text" maxLength={25} value={code} onChange={(e) => setCode(e.target.value)} placeholder="Código de PD" required /></td></tr>
{checkInfo && (
<tr><td><p className="green-info2">Vale {checkInfo.points} PD · Cuesta {checkInfo.gold} de oro</p></td></tr>
)}
<tr><td>¿De qué personaje se cobrará el oro?</td></tr>
<tr><td>
<select value={character} onChange={(e) => setCharacter(e.target.value)} required>
<option disabled value="">Selecciona un personaje</option>
{characters.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</td></tr>
<tr><td><br /><PasswordInput id="password-buy" value={password} onChange={setPassword} placeholder="Contraseña de tu cuenta" maxLength={16} /></td></tr>
<tr><td><input type="text" maxLength={6} value={token} onChange={(e) => setToken(e.target.value)} placeholder="Token de seguridad" required /></td></tr>
<tr><td><Turnstile onVerify={setCaptcha} /></td></tr>
<tr><td><button type="button" className="trade-points-check-button" disabled={busy} onClick={handleCheck}>Chequear código</button></td></tr>
<tr><td><button type="submit" className="trade-points-buy-button" disabled={busy}>{busy ? 'Procesando…' : 'Canjear código'}</button></td></tr>
</tbody>
</table>
</form>
<hr />
<div className="alert-message" id="trade-points-buy-response" style={{ display: message ? 'block' : 'none' }}>
{message && <span className={message.ok ? 'ok-form-response' : 'red-form-response'}>{message.text}</span>}
</div>
</div>
)
}
export function TradePointsForm({ characters }: { characters: string[] }) {
// Como el original: al cargar no se muestra ningún formulario; se revela al
// pulsar VENTA o COMPRA.
const [active, setActive] = useState<'sell' | 'buy' | null>(null)
if (characters.length === 0) {
return (
<div className="centered">
<br />
<span>No tienes personajes en esta cuenta.</span>
</div>
)
}
return (
<div className="centered">
<br />
<br />
<button
type="button"
id="trade-sell-a"
className={`trade-option-button${active === 'sell' ? ' dnt-selected' : ''}`}
onClick={() => setActive('sell')}
>
VENTA
</button>
<button
type="button"
id="trade-buy-a"
className={`trade-option-button${active === 'buy' ? ' dnt-selected' : ''}`}
onClick={() => setActive('buy')}
>
COMPRA
</button>
<br />
<SellForm characters={characters} visible={active === 'sell'} />
<BuyForm characters={characters} visible={active === 'buy'} />
<br />
</div>
)
}
+134
View File
@@ -0,0 +1,134 @@
'use client'
import { useState } from 'react'
import { useRouter } from '@/i18n/navigation'
function transferError(error?: string): string {
switch (error) {
case 'missingFields':
return 'Rellena el personaje de destino, la cantidad y el token de seguridad.'
case 'invalidAmount':
return 'La cantidad debe ser un número entero mayor que 0.'
case 'invalidToken':
return 'El token de seguridad no es válido.'
case 'characterNotFound':
return 'No existe ningún personaje con ese nombre.'
case 'sameAccount':
return 'No puedes transferirte PD a tu propia cuenta.'
case 'insufficientFunds':
return 'No tienes suficientes PD para realizar esta transferencia.'
case 'notAuthenticated':
return 'Tu sesión ha expirado. Inicia sesión de nuevo.'
default:
return 'No se pudo completar la transferencia. Inténtalo de nuevo.'
}
}
export function TransferDPointsForm({ balance }: { balance: number }) {
const router = useRouter()
const [character, setCharacter] = useState('')
const [amount, setAmount] = useState('')
const [token, setToken] = useState('')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null)
if (balance <= 0) {
return (
<div className="centered">
<br />
<br />
<span>No tienes PD</span>
<br />
</div>
)
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
if (busy) return
const n = Number(amount)
if (!character.trim() || !token.trim()) {
setMessage({ ok: false, text: transferError('missingFields') })
return
}
if (!Number.isInteger(n) || n <= 0) {
setMessage({ ok: false, text: transferError('invalidAmount') })
return
}
if (n > balance) {
setMessage({ ok: false, text: transferError('insufficientFunds') })
return
}
if (!window.confirm(`Vas a transferir ${n} PD al personaje "${character.trim()}".\nEsta acción no es reversible. ¿Deseas continuar?`)) return
setBusy(true)
setMessage(null)
try {
const res = await fetch('/api/dpoints/transfer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ character: character.trim(), amount: n, token: token.trim() }),
})
const data: { success?: boolean; error?: string } = await res.json()
if (data.success) {
setMessage({ ok: true, text: `Has transferido ${n} PD al personaje "${character.trim()}".` })
setCharacter('')
setAmount('')
setToken('')
router.refresh()
} else {
setMessage({ ok: false, text: transferError(data.error) })
}
} catch {
setMessage({ ok: false, text: transferError() })
} finally {
setBusy(false)
}
}
return (
<div className="centered">
<br />
<p className="yellow-info centered">Tienes {balance} PD disponibles</p>
<br />
<form onSubmit={handleSubmit} acceptCharset="utf-8">
<input
type="text"
placeholder="Nombre del personaje de destino"
value={character}
onChange={(e) => setCharacter(e.target.value)}
required
/>
<br />
<input
type="number"
placeholder="Cantidad de PD"
min="1"
max={balance}
step="1"
value={amount}
onChange={(e) => setAmount(e.target.value)}
required
/>
<br />
<input
type="text"
placeholder="Token de seguridad"
maxLength={6}
value={token}
onChange={(e) => setToken(e.target.value)}
required
/>
<br />
<button type="submit" className="transfer-button" disabled={busy}>
{busy ? 'Transfiriendo…' : 'TRANSFERIR PD'}
</button>
</form>
<hr />
<div className="alert-message" style={{ display: message ? 'block' : 'none' }}>
{message && <span className={message.ok ? 'ok-form-response' : 'red-form-response'}>{message.text}</span>}
</div>
</div>
)
}
+74
View File
@@ -0,0 +1,74 @@
import type { RowDataPacket, ResultSetHeader } from 'mysql2'
import { db, DB } from './db'
export interface PromoCode {
id: number
code: string
pd: number
pv: number
max_uses: number
uses: number
active: number
expires_at: string | null
redemptions: number
}
/** Normaliza un valor datetime-local ("YYYY-MM-DDTHH:MM") a DATETIME de MySQL, o null. */
function toMysqlDate(v: string): string | null {
const s = v.trim()
if (!s) return null
return s.replace('T', ' ').slice(0, 19)
}
export async function listPromoCodes(): Promise<PromoCode[]> {
const [rows] = await db(DB.default).query<RowDataPacket[]>(
`SELECT p.id, p.code, p.pd, p.pv, p.max_uses, p.uses, p.active, p.expires_at,
(SELECT COUNT(*) FROM home_promoredemption r WHERE r.promo_id = p.id) AS redemptions
FROM home_promocode p ORDER BY p.id DESC`,
)
return rows.map((r) => ({
id: r.id,
code: r.code,
pd: r.pd,
pv: r.pv,
max_uses: r.max_uses,
uses: r.uses,
active: r.active,
expires_at: r.expires_at ? new Date(r.expires_at).toISOString() : null,
redemptions: Number(r.redemptions),
}))
}
export async function createPromoCode(
code: string,
pd: number,
pv: number,
maxUses: number,
expiresAt: string,
): Promise<{ id?: number; error?: string }> {
try {
const [res] = await db(DB.default).query<ResultSetHeader>(
'INSERT INTO home_promocode (code, pd, pv, max_uses, uses, active, expires_at, created_at) VALUES (?, ?, ?, ?, 0, 1, ?, NOW())',
[code, pd, pv, maxUses, toMysqlDate(expiresAt)],
)
return { id: res.insertId }
} catch (e) {
if ((e as { code?: string }).code === 'ER_DUP_ENTRY') return { error: 'duplicate' }
return { error: 'genericError' }
}
}
export async function updatePromoCode(
id: number,
fields: { pd: number; pv: number; maxUses: number; active: boolean },
): Promise<void> {
await db(DB.default).query(
'UPDATE home_promocode SET pd = ?, pv = ?, max_uses = ?, active = ? WHERE id = ?',
[fields.pd, fields.pv, fields.maxUses, fields.active ? 1 : 0, id],
)
}
export async function deletePromoCode(id: number): Promise<void> {
await db(DB.default).query('DELETE FROM home_promoredemption WHERE promo_id = ?', [id])
await db(DB.default).query('DELETE FROM home_promocode WHERE id = ?', [id])
}
+15
View File
@@ -19,6 +19,21 @@ export async function getGameCharacters(accountId: number): Promise<GameCharacte
}
}
/** Id de la cuenta dueña de un personaje por su nombre (único), o null. */
export async function getAccountIdByCharacterName(name: string): Promise<number | null> {
const trimmed = name.trim()
if (!trimmed) return null
try {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT account FROM characters WHERE name = ? LIMIT 1',
[trimmed],
)
return rows[0] ? Number(rows[0].account) : null
} catch {
return null
}
}
/** True si el personaje pertenece a la cuenta y está desconectado. */
export async function characterIsOfflineAndOwned(accountId: number, name: string): Promise<boolean> {
const chars = await getGameCharacters(accountId)
+60
View File
@@ -25,3 +25,63 @@ export async function creditDPoints(accountId: number, points: number): Promise<
return false
}
}
/** Saldo de PD (donate points) de una cuenta de juego. 0 si no tiene fila. */
export async function getDPointsBalance(accountId: number): Promise<number> {
if (!accountId) return 0
try {
const [rows] = await db(DB.default).query<RowDataPacket[]>(
'SELECT dp FROM home_api_points WHERE accountID = ?',
[accountId],
)
return rows[0] ? Number(rows[0].dp) : 0
} catch {
return 0
}
}
/**
* Transfiere `points` PD desde una cuenta de origen a una de destino de forma
* atómica (transacción con bloqueo de fila del origen). Es irreversible.
*/
export async function transferDPoints(
fromAccountId: number,
toAccountId: number,
points: number,
): Promise<{ success: boolean; error?: string }> {
if (!fromAccountId || !toAccountId || !Number.isInteger(points) || points <= 0) {
return { success: false, error: 'invalidRequest' }
}
if (fromAccountId === toAccountId) return { success: false, error: 'sameAccount' }
const conn = await db(DB.default).getConnection()
try {
await conn.beginTransaction()
// Bloquea la fila del origen para evitar dobles gastos concurrentes.
const [src] = await conn.query<RowDataPacket[]>(
'SELECT dp FROM home_api_points WHERE accountID = ? FOR UPDATE',
[fromAccountId],
)
const balance = src[0] ? Number(src[0].dp) : 0
if (balance < points) {
await conn.rollback()
return { success: false, error: 'insufficientFunds' }
}
await conn.query('UPDATE home_api_points SET dp = dp - ? WHERE accountID = ?', [points, fromAccountId])
await conn.query(
'INSERT INTO home_api_points (accountID, vp, dp) VALUES (?, 0, ?) ON DUPLICATE KEY UPDATE dp = dp + ?',
[toAccountId, points, points],
)
await conn.commit()
return { success: true }
} catch {
try {
await conn.rollback()
} catch {
/* ignore */
}
return { success: false, error: 'genericError' }
} finally {
conn.release()
}
}
+88
View File
@@ -0,0 +1,88 @@
import type { RowDataPacket, ResultSetHeader } from 'mysql2'
import { db, DB } from './db'
/**
* Códigos de promoción: se canjean por PD y/o PV. Cada código tiene usos totales
* limitados (`max_uses`) y solo puede canjearse una vez por cuenta. Los códigos
* son sensibles a mayúsculas/minúsculas (columna con collation utf8mb4_bin).
*/
export interface RedeemResult {
success: boolean
error?: string
pd?: number
pv?: number
}
export async function redeemPromoCode(accountId: number, rawCode: string): Promise<RedeemResult> {
const code = rawCode.trim()
if (!accountId) return { success: false, error: 'notAuthenticated' }
if (!code) return { success: false, error: 'missingCode' }
if (code.length > 64) return { success: false, error: 'notFound' }
const conn = await db(DB.default).getConnection()
try {
await conn.beginTransaction()
// Bloquea la fila del código (comparación binaria => sensible a may/min).
const [rows] = await conn.query<RowDataPacket[]>(
'SELECT id, pd, pv, max_uses, uses, active, expires_at FROM home_promocode WHERE code = ? COLLATE utf8mb4_bin LIMIT 1 FOR UPDATE',
[code],
)
const promo = rows[0]
if (!promo || promo.active !== 1) {
await conn.rollback()
return { success: false, error: 'notFound' }
}
if (promo.expires_at && new Date(promo.expires_at).getTime() <= Date.now()) {
await conn.rollback()
return { success: false, error: 'expired' }
}
if (Number(promo.uses) >= Number(promo.max_uses)) {
await conn.rollback()
return { success: false, error: 'exhausted' }
}
// Registra el canje (la clave única promo+cuenta evita el doble uso).
try {
await conn.query('INSERT INTO home_promoredemption (promo_id, account_id, redeemed_at) VALUES (?, ?, NOW())', [
promo.id,
accountId,
])
} catch (e) {
await conn.rollback()
if ((e as { code?: string }).code === 'ER_DUP_ENTRY') return { success: false, error: 'alreadyUsed' }
return { success: false, error: 'genericError' }
}
// Consume un uso (condicional, por si hubo carrera con otro canje).
const [upd] = await conn.query<ResultSetHeader>(
'UPDATE home_promocode SET uses = uses + 1 WHERE id = ? AND uses < max_uses',
[promo.id],
)
if (upd.affectedRows !== 1) {
await conn.rollback()
return { success: false, error: 'exhausted' }
}
// Acredita PD/PV a la cuenta (upsert).
const pd = Number(promo.pd)
const pv = Number(promo.pv)
await conn.query(
'INSERT INTO home_api_points (accountID, vp, dp) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE vp = vp + VALUES(vp), dp = dp + VALUES(dp)',
[accountId, pv, pd],
)
await conn.commit()
return { success: true, pd, pv }
} catch {
try {
await conn.rollback()
} catch {
/* ignore */
}
return { success: false, error: 'genericError' }
} finally {
conn.release()
}
}
+297
View File
@@ -0,0 +1,297 @@
import crypto from 'node:crypto'
import type { RowDataPacket, ResultSetHeader } from 'mysql2'
import { db, DB } from './db'
import { authenticate } from './auth'
import { checkSecurityToken } from './security-token'
import { transferDPoints } from './dpoints'
import { executeSoapCommand } from './soap'
/**
* Comercio de PD: códigos de 1 solo uso que valen X PD a cambio de X oro.
*
* - El vendedor crea un código (VENTA) indicando PD, oro y el personaje de SU
* cuenta que recibirá el oro por correo cuando se canjee.
* - El comprador canjea el código (COMPRA) indicando de qué personaje de SU
* cuenta se cobra el oro.
*
* Al canjear (decisión de diseño «al canjear», no escrow):
* 1. Se descuenta el oro del personaje del comprador (debe estar desconectado).
* 2. Los PD pasan de la cuenta del creador a la del comprador.
* 3. El oro se envía por correo (SOAP) al personaje del creador.
* Cada código dura 1 hora. Tras un canje correcto, la cuenta del comprador queda
* «bloqueada» 5 s (no puede canjear otro código) como medida de seguridad.
*/
const CODE_TTL_MS = 60 * 60 * 1000 // 1 hora
const REDEEM_LOCK_SECONDS = 5
const GOLD_TO_COPPER = 10000
const MAX_AMOUNT = 99999 // el input admite máx. 5 dígitos
/** Nombres de personaje válidos (evita inyección en comandos SOAP). */
const NAME_RE = /^[A-Za-z]{1,12}$/
export interface Result {
success: boolean
error?: string
}
export interface TradeCodeInfo {
code: string
points: number
gold: number
expiresAt: string
}
function validAmount(n: number): boolean {
return Number.isInteger(n) && n >= 1 && n <= MAX_AMOUNT
}
/** Genera un código único legible de <=25 caracteres (p.ej. TP-3F9K2A7QX8B1). */
function generateCode(): string {
return 'TP-' + crypto.randomBytes(9).toString('hex').toUpperCase() // "TP-" + 18 hex = 21
}
/** Marca como expirados los códigos activos cuyo plazo venció (limpieza perezosa). */
async function expireOldCodes(): Promise<void> {
try {
await db(DB.default).query(
"UPDATE home_tradecode SET status = 'expired' WHERE status = 'active' AND expires_at <= NOW()",
)
} catch {
/* ignore */
}
}
/** True si el personaje pertenece a la cuenta. Devuelve el saldo (copper) si sí. */
async function characterMoney(accountId: number, name: string): Promise<{ money: number; online: boolean } | null> {
const [rows] = await db(DB.characters).query<RowDataPacket[]>(
'SELECT money, online FROM characters WHERE name = ? AND account = ? LIMIT 1',
[name, accountId],
)
if (!rows[0]) return null
return { money: Number(rows[0].money), online: rows[0].online === 1 }
}
/* -------------------------------- VENTA ----------------------------------- */
export async function createTradeCode(params: {
creatorAccount: number
creatorEmail: string
creatorCharacter: string
points: number
gold: number
password: string
token: string
turnstileOk: boolean
}): Promise<{ success: boolean; error?: string; code?: TradeCodeInfo }> {
const { creatorAccount, creatorEmail, creatorCharacter, points, gold, password, token, turnstileOk } = params
if (!creatorCharacter || !password || !token) return { success: false, error: 'missingFields' }
if (!validAmount(points) || !validAmount(gold)) return { success: false, error: 'invalidAmount' }
if (!NAME_RE.test(creatorCharacter)) return { success: false, error: 'characterNotFound' }
if (!turnstileOk) return { success: false, error: 'captcha' }
// El personaje que recibirá el oro debe ser de la cuenta del creador.
const owned = await characterMoney(creatorAccount, creatorCharacter)
if (!owned) return { success: false, error: 'characterNotOwned' }
if (!(await authenticate(creatorEmail, password))) return { success: false, error: 'wrongPassword' }
if (!(await checkSecurityToken(creatorAccount, token))) return { success: false, error: 'invalidToken' }
await expireOldCodes()
// No hay escrow: comprobamos que el saldo cubre TODOS los códigos activos + este,
// para no poder crear códigos que no se puedan respaldar.
try {
const [bal] = await db(DB.default).query<RowDataPacket[]>(
'SELECT dp FROM home_api_points WHERE accountID = ?',
[creatorAccount],
)
const balance = bal[0] ? Number(bal[0].dp) : 0
const [act] = await db(DB.default).query<RowDataPacket[]>(
"SELECT COALESCE(SUM(points), 0) AS reserved FROM home_tradecode WHERE creator_account = ? AND status = 'active' AND expires_at > NOW()",
[creatorAccount],
)
const reserved = Number(act[0]?.reserved ?? 0)
if (reserved + points > balance) return { success: false, error: 'insufficientPoints' }
} catch {
return { success: false, error: 'genericError' }
}
const code = generateCode()
const expiresAt = new Date(Date.now() + CODE_TTL_MS)
try {
await db(DB.default).query(
`INSERT INTO home_tradecode
(code, creator_account, creator_character, points, gold, status, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, 'active', NOW(), ?)`,
[code, creatorAccount, creatorCharacter, points, gold, expiresAt],
)
} catch {
return { success: false, error: 'genericError' }
}
return { success: true, code: { code, points, gold, expiresAt: expiresAt.toISOString() } }
}
/* ------------------------------- CHEQUEAR --------------------------------- */
export async function checkTradeCode(code: string): Promise<{ success: boolean; error?: string; points?: number; gold?: number; expiresAt?: string }> {
const trimmed = code.trim()
if (!trimmed) return { success: false, error: 'missingFields' }
await expireOldCodes()
try {
const [rows] = await db(DB.default).query<RowDataPacket[]>(
"SELECT points, gold, status, expires_at FROM home_tradecode WHERE code = ? LIMIT 1",
[trimmed],
)
const row = rows[0]
if (!row) return { success: false, error: 'codeNotFound' }
if (row.status !== 'active' || new Date(row.expires_at).getTime() <= Date.now()) {
return { success: false, error: 'codeUnavailable' }
}
return { success: true, points: Number(row.points), gold: Number(row.gold), expiresAt: new Date(row.expires_at).toISOString() }
} catch {
return { success: false, error: 'genericError' }
}
}
/* -------------------------------- CANJEAR --------------------------------- */
export async function redeemTradeCode(params: {
buyerAccount: number
buyerEmail: string
buyerCharacter: string
code: string
password: string
token: string
turnstileOk: boolean
}): Promise<{ success: boolean; error?: string; points?: number; gold?: number }> {
const { buyerAccount, buyerEmail, buyerCharacter, code, password, token, turnstileOk } = params
const trimmedCode = code.trim()
if (!trimmedCode || !buyerCharacter || !password || !token) return { success: false, error: 'missingFields' }
if (!NAME_RE.test(buyerCharacter)) return { success: false, error: 'characterNotOwned' }
if (!turnstileOk) return { success: false, error: 'captcha' }
if (!(await authenticate(buyerEmail, password))) return { success: false, error: 'wrongPassword' }
if (!(await checkSecurityToken(buyerAccount, token))) return { success: false, error: 'invalidToken' }
// Bloqueo de 5 s: la cuenta no puede canjear si acaba de canjear otro código.
try {
const [recent] = await db(DB.default).query<RowDataPacket[]>(
`SELECT id FROM home_tradecode
WHERE redeemer_account = ? AND redeemed_at > (NOW() - INTERVAL ? SECOND) LIMIT 1`,
[buyerAccount, REDEEM_LOCK_SECONDS],
)
if (recent[0]) return { success: false, error: 'locked' }
} catch {
/* ignore */
}
await expireOldCodes()
// Carga y valida el código.
let creatorAccount = 0
let creatorCharacter = ''
let points = 0
let gold = 0
try {
const [rows] = await db(DB.default).query<RowDataPacket[]>(
"SELECT creator_account, creator_character, points, gold, status, expires_at FROM home_tradecode WHERE code = ? LIMIT 1",
[trimmedCode],
)
const row = rows[0]
if (!row) return { success: false, error: 'codeNotFound' }
if (row.status !== 'active' || new Date(row.expires_at).getTime() <= Date.now()) {
return { success: false, error: 'codeUnavailable' }
}
creatorAccount = Number(row.creator_account)
creatorCharacter = String(row.creator_character)
points = Number(row.points)
gold = Number(row.gold)
} catch {
return { success: false, error: 'genericError' }
}
if (creatorAccount === buyerAccount) return { success: false, error: 'cannotRedeemOwn' }
if (!NAME_RE.test(creatorCharacter)) return { success: false, error: 'genericError' }
// El personaje del comprador debe ser suyo, estar OFFLINE y tener oro suficiente.
const buyerChar = await characterMoney(buyerAccount, buyerCharacter)
if (!buyerChar) return { success: false, error: 'characterNotOwned' }
if (buyerChar.online) return { success: false, error: 'characterOnline' }
const costCopper = gold * GOLD_TO_COPPER
if (buyerChar.money < costCopper) return { success: false, error: 'insufficientGold' }
// 1) Reclama el código de forma atómica (evita doble canje concurrente).
let claimed = false
try {
const [res] = await db(DB.default).query<ResultSetHeader>(
`UPDATE home_tradecode
SET status = 'redeemed', redeemer_account = ?, redeemer_character = ?, redeemed_at = NOW()
WHERE code = ? AND status = 'active' AND expires_at > NOW()`,
[buyerAccount, buyerCharacter, trimmedCode],
)
claimed = res.affectedRows === 1
} catch {
return { success: false, error: 'genericError' }
}
if (!claimed) return { success: false, error: 'codeUnavailable' }
const revertClaim = async () => {
try {
await db(DB.default).query(
"UPDATE home_tradecode SET status = 'active', redeemer_account = NULL, redeemer_character = NULL, redeemed_at = NULL WHERE code = ?",
[trimmedCode],
)
} catch {
/* ignore */
}
}
// 2) Cobra el oro al personaje del comprador (condicional: offline y saldo).
let goldTaken = false
try {
const [res] = await db(DB.characters).query<ResultSetHeader>(
'UPDATE characters SET money = money - ? WHERE name = ? AND account = ? AND online = 0 AND money >= ?',
[costCopper, buyerCharacter, buyerAccount, costCopper],
)
goldTaken = res.affectedRows === 1
} catch {
goldTaken = false
}
if (!goldTaken) {
await revertClaim()
return { success: false, error: 'insufficientGold' }
}
const refundGold = async () => {
try {
await db(DB.characters).query('UPDATE characters SET money = money + ? WHERE name = ? AND account = ?', [
costCopper,
buyerCharacter,
buyerAccount,
])
} catch {
/* ignore */
}
}
// 3) Mueve los PD del creador al comprador (transacción con bloqueo de fila).
const pdMove = await transferDPoints(creatorAccount, buyerAccount, points)
if (!pdMove.success) {
await refundGold()
await revertClaim()
return { success: false, error: pdMove.error === 'insufficientFunds' ? 'creatorInsufficient' : 'genericError' }
}
// 4) Envía el oro por correo al personaje del creador (SOAP). Último paso.
const subject = 'Comercio de PD'
const body = `Has recibido ${gold} de oro por el canje de tu codigo ${trimmedCode}.`
const soap = await executeSoapCommand(`.send money ${creatorCharacter} '${subject}' '${body}' ${costCopper}`)
if (soap === null) {
// Compensa: revierte PD y oro, y reabre el código.
await transferDPoints(buyerAccount, creatorAccount, points)
await refundGold()
await revertClaim()
return { success: false, error: 'deliveryFailed' }
}
return { success: true, points, gold }
}
+15 -1
View File
@@ -482,7 +482,21 @@
"show": "Show",
"hide": "Hide",
"categoryNotEmpty": "Category has forums; delete them first.",
"backToPanel": "← Back to panel"
"backToPanel": "← Back to panel",
"managePromo": "Manage promo codes",
"promo": "Promo codes",
"createPromo": "Create code",
"promoCode": "Code",
"promoPd": "PD",
"promoPv": "PV",
"promoMaxUses": "Max uses",
"promoExpires": "Expires (optional)",
"promoActive": "Active",
"promoUses": "Uses",
"promoRedemptions": "Redemptions",
"noPromo": "No promo codes.",
"promoDuplicate": "A code with that text already exists.",
"promoEmptyError": "Enter the code and at least PD or PV (with max uses ≥ 1)."
},
"Vote": {
"title": "Vote for us",
+15 -1
View File
@@ -482,7 +482,21 @@
"show": "Mostrar",
"hide": "Ocultar",
"categoryNotEmpty": "La categoría tiene foros; bórralos primero.",
"backToPanel": "← Volver al panel"
"backToPanel": "← Volver al panel",
"managePromo": "Gestionar códigos de promoción",
"promo": "Códigos de promoción",
"createPromo": "Crear código",
"promoCode": "Código",
"promoPd": "PD",
"promoPv": "PV",
"promoMaxUses": "Usos máx.",
"promoExpires": "Caduca (opcional)",
"promoActive": "Activo",
"promoUses": "Usos",
"promoRedemptions": "Canjes",
"noPromo": "No hay códigos de promoción.",
"promoDuplicate": "Ya existe un código con ese texto.",
"promoEmptyError": "Escribe el código y al menos PD o PV (con usos máx. ≥ 1)."
},
"Vote": {
"title": "Votar por nosotros",
+27
View File
@@ -0,0 +1,27 @@
-- Códigos de promoción (/promo-code): se canjean por PD y/o PV, con usos limitados.
-- BD: django_wow (DB.default). Ver lib/promo.ts.
-- `code` con collation *_bin => sensible a mayúsculas/minúsculas.
CREATE TABLE IF NOT EXISTS home_promocode (
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
pd INT NOT NULL DEFAULT 0, -- PD (donate points) que otorga
pv INT NOT NULL DEFAULT 0, -- PV (vote points) que otorga
max_uses INT NOT NULL DEFAULT 1, -- usos totales permitidos
uses INT NOT NULL DEFAULT 0,
active TINYINT NOT NULL DEFAULT 1,
expires_at DATETIME NULL, -- NULL = sin caducidad
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Un canje por cuenta y código.
CREATE TABLE IF NOT EXISTS home_promoredemption (
id BIGINT NOT NULL AUTO_INCREMENT,
promo_id BIGINT NOT NULL,
account_id INT UNSIGNED NOT NULL,
redeemed_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_promo_account (promo_id, account_id),
KEY idx_account (account_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+21
View File
@@ -0,0 +1,21 @@
-- Comercio de PD (/trade-points): códigos de 1 solo uso PD<->oro.
-- BD: django_wow (DB.default). Ver lib/trade.ts.
CREATE TABLE IF NOT EXISTS home_tradecode (
id BIGINT NOT NULL AUTO_INCREMENT,
code VARCHAR(25) NOT NULL,
creator_account INT UNSIGNED NOT NULL,
creator_character VARCHAR(12) NOT NULL, -- personaje del creador que recibe el oro
points INT NOT NULL, -- valor en PD
gold INT NOT NULL, -- coste en oro
status ENUM('active','redeemed','expired','cancelled') NOT NULL DEFAULT 'active',
redeemer_account INT UNSIGNED NULL,
redeemer_character VARCHAR(12) NULL, -- personaje del comprador que paga el oro
created_at DATETIME NOT NULL,
expires_at DATETIME NOT NULL,
redeemed_at DATETIME NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_code (code),
KEY idx_creator_status (creator_account, status),
KEY idx_redeemer (redeemer_account, redeemed_at),
KEY idx_status_expires (status, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;