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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ?? '')))
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user