From 66d056c04b1b5031a595b513856cea3be4be6107 Mon Sep 17 00:00:00 2001 From: adevopg Date: Tue, 14 Jul 2026 09:27:11 +0000 Subject: [PATCH] =?UTF-8?q?web-next:=20herramientas=20de=20PD=20(transferi?= =?UTF-8?q?r,=20comercio,=20c=C3=B3digos=20de=20promoci=C3=B3n)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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) --- web-next/app/[locale]/admin/page.tsx | 1 + web-next/app/[locale]/admin/promo/page.tsx | 29 ++ web-next/app/[locale]/promo-code/page.tsx | 52 +++ web-next/app/[locale]/trade-points/page.tsx | 86 +++++ .../app/[locale]/transfer-d-points/page.tsx | 56 ++++ web-next/app/api/admin/promo/[id]/route.ts | 31 ++ web-next/app/api/admin/promo/route.ts | 28 ++ web-next/app/api/dpoints/trade/check/route.ts | 19 ++ .../app/api/dpoints/trade/create/route.ts | 33 ++ .../app/api/dpoints/trade/redeem/route.ts | 32 ++ web-next/app/api/dpoints/transfer/route.ts | 40 +++ web-next/app/api/promo/redeem/route.ts | 19 ++ web-next/components/AdminPromoManager.tsx | 143 ++++++++ web-next/components/PromoCodeForm.tsx | 85 +++++ web-next/components/TradePointsForm.tsx | 311 ++++++++++++++++++ web-next/components/TransferDPointsForm.tsx | 134 ++++++++ web-next/lib/admin-promo.ts | 74 +++++ web-next/lib/characters.ts | 15 + web-next/lib/dpoints.ts | 60 ++++ web-next/lib/promo.ts | 88 +++++ web-next/lib/trade.ts | 297 +++++++++++++++++ web-next/messages/en.json | 16 +- web-next/messages/es.json | 16 +- web-next/sql/home_promocode.sql | 27 ++ web-next/sql/home_tradecode.sql | 21 ++ 25 files changed, 1711 insertions(+), 2 deletions(-) create mode 100644 web-next/app/[locale]/admin/promo/page.tsx create mode 100644 web-next/app/[locale]/promo-code/page.tsx create mode 100644 web-next/app/[locale]/trade-points/page.tsx create mode 100644 web-next/app/[locale]/transfer-d-points/page.tsx create mode 100644 web-next/app/api/admin/promo/[id]/route.ts create mode 100644 web-next/app/api/admin/promo/route.ts create mode 100644 web-next/app/api/dpoints/trade/check/route.ts create mode 100644 web-next/app/api/dpoints/trade/create/route.ts create mode 100644 web-next/app/api/dpoints/trade/redeem/route.ts create mode 100644 web-next/app/api/dpoints/transfer/route.ts create mode 100644 web-next/app/api/promo/redeem/route.ts create mode 100644 web-next/components/AdminPromoManager.tsx create mode 100644 web-next/components/PromoCodeForm.tsx create mode 100644 web-next/components/TradePointsForm.tsx create mode 100644 web-next/components/TransferDPointsForm.tsx create mode 100644 web-next/lib/admin-promo.ts create mode 100644 web-next/lib/promo.ts create mode 100644 web-next/lib/trade.ts create mode 100644 web-next/sql/home_promocode.sql create mode 100644 web-next/sql/home_tradecode.sql diff --git a/web-next/app/[locale]/admin/page.tsx b/web-next/app/[locale]/admin/page.tsx index 626059d..0c29106 100644 --- a/web-next/app/[locale]/admin/page.tsx +++ b/web-next/app/[locale]/admin/page.tsx @@ -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[] = [] diff --git a/web-next/app/[locale]/admin/promo/page.tsx b/web-next/app/[locale]/admin/promo/page.tsx new file mode 100644 index 0000000..2f332ff --- /dev/null +++ b/web-next/app/[locale]/admin/promo/page.tsx @@ -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 ( + +
+
+ {t('backToPanel')} + +
+
+
+ ) +} diff --git a/web-next/app/[locale]/promo-code/page.tsx b/web-next/app/[locale]/promo-code/page.tsx new file mode 100644 index 0000000..915c6e0 --- /dev/null +++ b/web-next/app/[locale]/promo-code/page.tsx @@ -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 ( + + +
+

+ Los Códigos de promoción 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. +

+

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

+

Aconsejamos poner atención a las redes sociales y los anuncios del servidor para saber cuándo hay un código disponible.

+
+
+ NOTA +
+

+ Los códigos son sensibles a mayúsculas y minúsculas por lo que siempre debes escribirlos igual a como + aparecen publicados. +

+
+
+ + +
+
+ ) +} diff --git a/web-next/app/[locale]/trade-points/page.tsx b/web-next/app/[locale]/trade-points/page.tsx new file mode 100644 index 0000000..49694f2 --- /dev/null +++ b/web-next/app/[locale]/trade-points/page.tsx @@ -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 ( + + +
+

+ La herramienta Comercio de PD permite comprar o vender PD a cambio de oro de una forma segura. +

+
+

El sistema permite crear códigos de 1 solo uso que valdrán X cantidad de PD a cambio de X cantidad de oro.

+

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

+
+
+

Venta

+

1) Decide cuánto valdrá el código en PD.

+

2) Decide por cuánto oro se podrá canjear.

+

3) Escoge a qué personaje de la cuenta se enviará el oro cuando el código sea canjeado.

+

4) Completa los datos con la contraseña y Token de Seguridad.

+

5) Usa el botón CREAR CÓDIGO.

+

6) Un recuadro aparecerá con los datos del Código creado con el ID que se debe usar para enviar a otro jugador.

+
+
+
+

Compra

+

1) Ingresa ID del código que te ha dado otro jugador.

+

2) Chequea el valor en PD y costo en oro con el botón CHEQUEAR CÓDIGO.

+

3) Escoge de qué personaje de la cuenta se descontará el oro cuando el código sea canjeado.

+

4) Completa los datos con la contraseña y Token de Seguridad.

+

5) Usa el botón CANJEAR CÓDIGO.

+

6) Un recuadro aparecerá con los datos del Código canjeado, PD recibidos y oro retirado del personaje seleccionado.

+
+
+
+

Cada código tiene una duración de 1 hora.

+

Una vez canjeado el código:

+

- Los PD serán retirados de la cuenta del creador del código y enviados a quien lo haya canjeado.

+

- El oro será retirado y enviado por correo al creador.

+

- La transacción quedará registrada en el Historial de PD y PV.

+
+

La cuenta de quien canjee el código será bloqueada por 5s como medida de seguridad si el trámite es exitoso.

+
+

+ Si aún no tienes Token de seguridad, puedes solicitarlo{' '} + aquí. +

+
+
+ NOTA +
+

+ Por favor, asegúrate de haber escrito todos los datos correctos ya que esta acción no es reversible una + vez realizada. +

+
+
+ + c.name)} /> +
+
+ ) +} diff --git a/web-next/app/[locale]/transfer-d-points/page.tsx b/web-next/app/[locale]/transfer-d-points/page.tsx new file mode 100644 index 0000000..c298d79 --- /dev/null +++ b/web-next/app/[locale]/transfer-d-points/page.tsx @@ -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 ( + + +
+

+ La herramienta Transferir PD permite transferir PD desde tu cuenta a cualquier otra cuenta. +

+
+

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

+
+

+ Si aún no tienes Token de seguridad, puedes solicitarlo{' '} + aquí. +

+
+
+ NOTA +
+

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

+
+
+ + +
+
+ ) +} diff --git a/web-next/app/api/admin/promo/[id]/route.ts b/web-next/app/api/admin/promo/[id]/route.ts new file mode 100644 index 0000000..07e8577 --- /dev/null +++ b/web-next/app/api/admin/promo/[id]/route.ts @@ -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 = {} + 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 }) +} diff --git a/web-next/app/api/admin/promo/route.ts b/web-next/app/api/admin/promo/route.ts new file mode 100644 index 0000000..4d6d3a0 --- /dev/null +++ b/web-next/app/api/admin/promo/route.ts @@ -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 = {} + 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 }) +} diff --git a/web-next/app/api/dpoints/trade/check/route.ts b/web-next/app/api/dpoints/trade/check/route.ts new file mode 100644 index 0000000..91d9c4a --- /dev/null +++ b/web-next/app/api/dpoints/trade/check/route.ts @@ -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 = {} + try { + body = await request.json() + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } + + return Response.json(await checkTradeCode(String(body.code ?? ''))) +} diff --git a/web-next/app/api/dpoints/trade/create/route.ts b/web-next/app/api/dpoints/trade/create/route.ts new file mode 100644 index 0000000..4c594a9 --- /dev/null +++ b/web-next/app/api/dpoints/trade/create/route.ts @@ -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 = {} + 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) +} diff --git a/web-next/app/api/dpoints/trade/redeem/route.ts b/web-next/app/api/dpoints/trade/redeem/route.ts new file mode 100644 index 0000000..0c65e34 --- /dev/null +++ b/web-next/app/api/dpoints/trade/redeem/route.ts @@ -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 = {} + 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) +} diff --git a/web-next/app/api/dpoints/transfer/route.ts b/web-next/app/api/dpoints/transfer/route.ts new file mode 100644 index 0000000..671a418 --- /dev/null +++ b/web-next/app/api/dpoints/transfer/route.ts @@ -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 = {} + 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) +} diff --git a/web-next/app/api/promo/redeem/route.ts b/web-next/app/api/promo/redeem/route.ts new file mode 100644 index 0000000..3d4712e --- /dev/null +++ b/web-next/app/api/promo/redeem/route.ts @@ -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 = {} + 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 ?? ''))) +} diff --git a/web-next/components/AdminPromoManager.tsx b/web-next/components/AdminPromoManager.tsx new file mode 100644 index 0000000..357fbec --- /dev/null +++ b/web-next/components/AdminPromoManager.tsx @@ -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(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 ( +
+
+ upd('code', e.target.value)} placeholder={t('promoCode')} /> + upd('pd', e.target.value)} placeholder={t('promoPd')} /> + upd('pv', e.target.value)} placeholder={t('promoPv')} /> + upd('maxUses', e.target.value)} placeholder={t('promoMaxUses')} /> + upd('expiresAt', e.target.value)} placeholder={t('promoExpires')} /> + +
+ {error &&

{error}

} +
+ {codes.length === 0 ? ( +

{t('noPromo')}

+ ) : ( + + + {codes.map((c) => ( + remove(c.id)} /> + ))} + +
+ )} +
+ ) +} + +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 ( + + + {promo.code} +

+ {t('promoUses')}: {promo.uses}/{promo.max_uses} · {t('promoRedemptions')}: {promo.redemptions} + {promo.expires_at ? ` · ${new Date(promo.expires_at).toLocaleString('es-ES')}` : ''} +

+ + + {' '} + {' '} + {' '} + + + + {' '} + + + + ) +} diff --git a/web-next/components/PromoCodeForm.tsx b/web-next/components/PromoCodeForm.tsx new file mode 100644 index 0000000..9823abe --- /dev/null +++ b/web-next/components/PromoCodeForm.tsx @@ -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) { + 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 ( +
+
+
+ setCode(e.target.value)} + required + /> +
+ +
+
+
+ {message && {message.text}} +
+
+ ) +} diff --git a/web-next/components/TradePointsForm.tsx b/web-next/components/TradePointsForm.tsx new file mode 100644 index 0000000..3f4d320 --- /dev/null +++ b/web-next/components/TradePointsForm.tsx @@ -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 ( + <> + onChange(e.target.value)} + placeholder={placeholder} + />{' '} + 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) { + 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 ( +
+
+ + + + + + + + + + + + + +
¿Cuánto valdrá el código en PD?
setPoints(e.target.value)} placeholder="Valor del código en PD" required />
¿Cuánto costará el código en oro?
setGold(e.target.value)} placeholder="Valor del código en oro" required />
¿A qué personaje se enviará el oro?
+ +

setToken(e.target.value)} placeholder="Token de seguridad" required />
+
+
+
+ {message && {message.text}} + {created && ( +
+ Código creado +
+

ID del código: {created.code}

+

Valor: {created.points} PD por {created.gold} de oro

+

Envía este ID al comprador. El código caduca en 1 hora.

+
+
+ )} +
+
+ ) +} + +/* ---------------------------------- 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) { + 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 ( +
+
+ + + + + {checkInfo && ( + + )} + + + + + + + + +
Ingresa el ID del código de PD
setCode(e.target.value)} placeholder="Código de PD" required />

Vale {checkInfo.points} PD · Cuesta {checkInfo.gold} de oro

¿De qué personaje se cobrará el oro?
+ +

setToken(e.target.value)} placeholder="Token de seguridad" required />
+
+
+
+ {message && {message.text}} +
+
+ ) +} + +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 ( +
+
+ No tienes personajes en esta cuenta. +
+ ) + } + + return ( +
+
+
+ + +
+ + +
+
+ ) +} diff --git a/web-next/components/TransferDPointsForm.tsx b/web-next/components/TransferDPointsForm.tsx new file mode 100644 index 0000000..147bcd8 --- /dev/null +++ b/web-next/components/TransferDPointsForm.tsx @@ -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 ( +
+
+
+ No tienes PD +
+
+ ) + } + + async function handleSubmit(e: React.FormEvent) { + 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 ( +
+
+

Tienes {balance} PD disponibles

+
+
+ setCharacter(e.target.value)} + required + /> +
+ setAmount(e.target.value)} + required + /> +
+ setToken(e.target.value)} + required + /> +
+ +
+
+
+ {message && {message.text}} +
+
+ ) +} diff --git a/web-next/lib/admin-promo.ts b/web-next/lib/admin-promo.ts new file mode 100644 index 0000000..ab89252 --- /dev/null +++ b/web-next/lib/admin-promo.ts @@ -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 { + const [rows] = await db(DB.default).query( + `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( + '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 { + 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 { + 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]) +} diff --git a/web-next/lib/characters.ts b/web-next/lib/characters.ts index c565505..76bdb12 100644 --- a/web-next/lib/characters.ts +++ b/web-next/lib/characters.ts @@ -19,6 +19,21 @@ export async function getGameCharacters(accountId: number): Promise { + const trimmed = name.trim() + if (!trimmed) return null + try { + const [rows] = await db(DB.characters).query( + '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 { const chars = await getGameCharacters(accountId) diff --git a/web-next/lib/dpoints.ts b/web-next/lib/dpoints.ts index 88e6bea..b57baeb 100644 --- a/web-next/lib/dpoints.ts +++ b/web-next/lib/dpoints.ts @@ -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 { + if (!accountId) return 0 + try { + const [rows] = await db(DB.default).query( + '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( + '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() + } +} diff --git a/web-next/lib/promo.ts b/web-next/lib/promo.ts new file mode 100644 index 0000000..e1cb256 --- /dev/null +++ b/web-next/lib/promo.ts @@ -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 { + 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( + '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( + '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() + } +} diff --git a/web-next/lib/trade.ts b/web-next/lib/trade.ts new file mode 100644 index 0000000..0e8c3f6 --- /dev/null +++ b/web-next/lib/trade.ts @@ -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 { + 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( + '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( + '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( + "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( + "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( + `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( + "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( + `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( + '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 } +} diff --git a/web-next/messages/en.json b/web-next/messages/en.json index 726feb4..9535ee0 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -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", diff --git a/web-next/messages/es.json b/web-next/messages/es.json index 2111eba..36663c2 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -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", diff --git a/web-next/sql/home_promocode.sql b/web-next/sql/home_promocode.sql new file mode 100644 index 0000000..3970644 --- /dev/null +++ b/web-next/sql/home_promocode.sql @@ -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; diff --git a/web-next/sql/home_tradecode.sql b/web-next/sql/home_tradecode.sql new file mode 100644 index 0000000..c188059 --- /dev/null +++ b/web-next/sql/home_tradecode.sql @@ -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;