026fbcb81c
Porta la tienda de ítems/transfiguración del sistema antiguo: elegir personaje → árbol de categorías (3 niveles) → carrito → enviar por correo. Se paga con el saldo PD o PV de la cuenta (cada ítem lleva su precio y moneda). - BD: home_store_category (árbol por code "1-1-1") + home_store_item (mismos item_id, nombres, iconos, precios y moneda que el original; 2947 en PD, 121 en PV). Seed en sql/store_catalog.sql, extraído del HTML real. - lib/store.ts: getStoreCatalog (árbol anidado), getStoreBalances, priceStoreCart (precios REALES de BD), purchaseStoreCart (cobro atómico PD+PV con FOR UPDATE y reembolso si el envío SOAP falla; `.send items` troceado a 12/correo). - API: GET /api/store/catalog (tras elegir personaje) y POST /api/store/send. - components/StoreBrowser.tsx: selector de personaje, árbol colapsable (ítems montados solo al abrir), carrito con totales PD/PV, enviar, modal de resultado. - página /store con la info y el título "Tienda - REINO". Enlaces de ítems e iconos por wowhead/zamimg (con tooltip y locale). El enlace ya existía en AccountTools. i18n namespace Store (es/en). CSS del árbol/carrito/modal. Verificado: catálogo E2E (750 cats/3068 ítems), cobro atómico + reembolso y detección de saldo insuficiente (cuenta 15). SOAP real sin probar (worldserver caído en este entorno). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
206 lines
7.1 KiB
TypeScript
206 lines
7.1 KiB
TypeScript
import type { RowDataPacket } from 'mysql2'
|
|
import { db, DB } from './db'
|
|
import { executeSoapCommand } from './soap'
|
|
|
|
/**
|
|
* Tienda de ítems (transfiguración/equipo), portada del sistema antiguo (BENNU).
|
|
* Catálogo en `home_store_category` (árbol por `code` "1-1-1") + `home_store_item`
|
|
* (precio en PD o PV, cantidad, icono). Se paga con el saldo PD/PV de la cuenta y
|
|
* los ítems se envían por correo al personaje elegido (SOAP `.send items`).
|
|
*/
|
|
|
|
export type Currency = 'pd' | 'pv'
|
|
const SAFE_NAME = /^[A-Za-z]{1,12}$/
|
|
const MAIL_ITEM_LIMIT = 12 // máximo de ítems por correo en AzerothCore
|
|
|
|
export interface StoreItem {
|
|
id: number // fila de home_store_item (clave del carrito)
|
|
itemId: number
|
|
name: string
|
|
icon: string | null
|
|
qty: number
|
|
currency: Currency
|
|
price: number
|
|
preview: string | null
|
|
}
|
|
|
|
export interface StoreCategory {
|
|
code: string
|
|
name: string
|
|
depth: number
|
|
children: StoreCategory[]
|
|
items: StoreItem[]
|
|
}
|
|
|
|
interface CatRow extends RowDataPacket {
|
|
code: string
|
|
parent_code: string | null
|
|
name: string
|
|
depth: number
|
|
sort: number
|
|
}
|
|
interface ItemRow extends RowDataPacket {
|
|
id: number
|
|
category_code: string
|
|
item_id: number
|
|
name: string
|
|
icon: string | null
|
|
quantity: number
|
|
currency: Currency
|
|
price: number
|
|
preview: string | null
|
|
sort: number
|
|
}
|
|
|
|
/** Árbol completo de la tienda (categorías anidadas con sus ítems en las hojas). */
|
|
export async function getStoreCatalog(): Promise<StoreCategory[]> {
|
|
let cats: CatRow[] = []
|
|
let items: ItemRow[] = []
|
|
try {
|
|
;[cats] = await db(DB.default).query<CatRow[]>(
|
|
'SELECT code, parent_code, name, depth, sort FROM home_store_category ORDER BY sort',
|
|
)
|
|
;[items] = await db(DB.default).query<ItemRow[]>(
|
|
'SELECT id, category_code, item_id, name, icon, quantity, currency, price, preview, sort FROM home_store_item ORDER BY sort',
|
|
)
|
|
} catch {
|
|
return []
|
|
}
|
|
|
|
const nodes = new Map<string, StoreCategory>()
|
|
for (const c of cats) {
|
|
nodes.set(c.code, { code: c.code, name: c.name, depth: c.depth, children: [], items: [] })
|
|
}
|
|
const roots: StoreCategory[] = []
|
|
for (const c of cats) {
|
|
const node = nodes.get(c.code)!
|
|
if (c.parent_code && nodes.has(c.parent_code)) nodes.get(c.parent_code)!.children.push(node)
|
|
else roots.push(node)
|
|
}
|
|
for (const it of items) {
|
|
const leaf = nodes.get(it.category_code)
|
|
if (!leaf) continue
|
|
leaf.items.push({
|
|
id: it.id,
|
|
itemId: it.item_id,
|
|
name: it.name,
|
|
icon: it.icon,
|
|
qty: it.quantity,
|
|
currency: it.currency,
|
|
price: it.price,
|
|
preview: it.preview,
|
|
})
|
|
}
|
|
return roots
|
|
}
|
|
|
|
/** Saldos PD y PV de la cuenta (para mostrar y validar). */
|
|
export async function getStoreBalances(accountId: number): Promise<{ dp: number; vp: number }> {
|
|
if (!accountId) return { dp: 0, vp: 0 }
|
|
try {
|
|
const [rows] = await db(DB.default).query<RowDataPacket[]>(
|
|
'SELECT dp, vp FROM home_api_points WHERE accountID = ?',
|
|
[accountId],
|
|
)
|
|
return rows[0] ? { dp: Number(rows[0].dp), vp: Number(rows[0].vp) } : { dp: 0, vp: 0 }
|
|
} catch {
|
|
return { dp: 0, vp: 0 }
|
|
}
|
|
}
|
|
|
|
export interface PricedCart {
|
|
lines: { id: number; itemId: number; qty: number; currency: Currency; price: number }[]
|
|
pdTotal: number
|
|
vpTotal: number
|
|
}
|
|
|
|
/**
|
|
* Valida el carrito contra la BD (precios/cantidades/moneda REALES, nunca del
|
|
* cliente) a partir de las filas `home_store_item.id`. Devuelve null si vacío.
|
|
*/
|
|
export async function priceStoreCart(itemRowIds: number[]): Promise<PricedCart | null> {
|
|
const ids = [...new Set((Array.isArray(itemRowIds) ? itemRowIds : []).map((n) => Math.floor(Number(n))).filter((n) => n > 0))]
|
|
if (ids.length === 0) return null
|
|
let rows: ItemRow[] = []
|
|
try {
|
|
;[rows] = await db(DB.default).query<ItemRow[]>(
|
|
`SELECT id, item_id, quantity, currency, price FROM home_store_item WHERE id IN (${ids.map(() => '?').join(',')})`,
|
|
ids,
|
|
)
|
|
} catch {
|
|
return null
|
|
}
|
|
if (rows.length === 0) return null
|
|
const lines = rows.map((r) => ({ id: Number(r.id), itemId: Number(r.item_id), qty: Number(r.quantity), currency: r.currency, price: Number(r.price) }))
|
|
const pdTotal = lines.filter((l) => l.currency === 'pd').reduce((s, l) => s + l.price, 0)
|
|
const vpTotal = lines.filter((l) => l.currency === 'pv').reduce((s, l) => s + l.price, 0)
|
|
return { lines, pdTotal, vpTotal }
|
|
}
|
|
|
|
/**
|
|
* Cobra el carrito (descuenta PD y PV de forma atómica, ambos a la vez) y envía
|
|
* los ítems por correo al personaje. Reembolsa si el envío por SOAP falla.
|
|
* Devuelve un código de error si algo no cuadra.
|
|
*/
|
|
export async function purchaseStoreCart(
|
|
accountId: number,
|
|
character: string,
|
|
cart: PricedCart,
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
if (!SAFE_NAME.test(character)) return { success: false, error: 'invalidCharacter' }
|
|
if (cart.pdTotal <= 0 && cart.vpTotal <= 0) return { success: false, error: 'emptyCart' }
|
|
|
|
// Cobro atómico: bloquea la fila y exige saldo suficiente en AMBAS monedas.
|
|
const conn = await db(DB.default).getConnection()
|
|
let charged = false
|
|
try {
|
|
await conn.beginTransaction()
|
|
const [pts] = await conn.query<RowDataPacket[]>(
|
|
'SELECT dp, vp FROM home_api_points WHERE accountID = ? FOR UPDATE',
|
|
[accountId],
|
|
)
|
|
const dp = pts[0] ? Number(pts[0].dp) : 0
|
|
const vp = pts[0] ? Number(pts[0].vp) : 0
|
|
if (dp < cart.pdTotal) { await conn.rollback(); return { success: false, error: 'insufficientPd' } }
|
|
if (vp < cart.vpTotal) { await conn.rollback(); return { success: false, error: 'insufficientVp' } }
|
|
await conn.query('UPDATE home_api_points SET dp = dp - ?, vp = vp - ? WHERE accountID = ?', [cart.pdTotal, cart.vpTotal, accountId])
|
|
await conn.commit()
|
|
charged = true
|
|
} catch {
|
|
try { await conn.rollback() } catch { /* ignore */ }
|
|
return { success: false, error: 'genericError' }
|
|
} finally {
|
|
conn.release()
|
|
}
|
|
|
|
// Envío por correo (troceado a 12 ítems). Si falla, se reembolsa el saldo.
|
|
const ok = await sendStoreItems(character, cart.lines.map((l) => ({ i: l.itemId, q: l.qty })))
|
|
if (!ok) {
|
|
if (charged) {
|
|
await db(DB.default)
|
|
.query('UPDATE home_api_points SET dp = dp + ?, vp = vp + ? WHERE accountID = ?', [cart.pdTotal, cart.vpTotal, accountId])
|
|
.catch(() => {})
|
|
}
|
|
return { success: false, error: 'deliveryFailed' }
|
|
}
|
|
return { success: true }
|
|
}
|
|
|
|
/** Envía los ítems de la tienda por correo (SOAP `.send items`), troceado. */
|
|
async function sendStoreItems(character: string, items: { i: number; q: number }[]): Promise<boolean> {
|
|
if (!SAFE_NAME.test(character)) return false
|
|
const clean = items
|
|
.map((it) => ({ i: Math.floor(Number(it.i)), q: Math.floor(Number(it.q)) }))
|
|
.filter((it) => it.i > 0 && it.q >= 1)
|
|
if (clean.length === 0) return false
|
|
const subject = 'Tienda'
|
|
const body = 'Has recibido los objetos de la tienda. ¡Que los disfrutes!'
|
|
for (let n = 0; n < clean.length; n += MAIL_ITEM_LIMIT) {
|
|
const chunk = clean.slice(n, n + MAIL_ITEM_LIMIT)
|
|
const itemsStr = chunk.map((it) => `${it.i}:${it.q}`).join(' ')
|
|
const res = await executeSoapCommand(`.send items "${character}" "${subject}" "${body}" ${itemsStr}`)
|
|
if (res === null) return false
|
|
}
|
|
return true
|
|
}
|