Implementa la tienda /store (catálogo BENNU: 750 categorías, 3068 ítems)

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>
This commit is contained in:
2026-07-14 23:54:57 +00:00
parent 033ebc6861
commit 026fbcb81c
9 changed files with 4537 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
import type { Metadata } from 'next'
import { getTranslations, setRequestLocale } from 'next-intl/server'
import { redirect } from '@/i18n/navigation'
import { getSession } from '@/lib/session'
import { getGameCharacters } from '@/lib/characters'
import { getStoreBalances } from '@/lib/store'
import { getRealmName } from '@/lib/realm'
import { wowheadWotlkHome } from '@/lib/wowhead'
import { PageShell } from '@/components/PageShell'
import { ServiceBox } from '@/components/ServiceBox'
import { StoreBrowser } from '@/components/StoreBrowser'
export const dynamic = 'force-dynamic'
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params
const t = await getTranslations({ locale, namespace: 'Store' })
return { title: t('title') }
}
export default async function StorePage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params
setRequestLocale(locale)
const session = await getSession()
if (!session.bnetId) redirect({ href: '/log-in', locale })
if (!session.username) redirect({ href: '/select-account', locale })
const [chars, balances, realm] = await Promise.all([
getGameCharacters(session.accountId!),
getStoreBalances(session.accountId!),
getRealmName(),
])
const t = await getTranslations('Store')
return (
<PageShell title={<>{t('title')} - <span className="blue-info">{realm.toUpperCase()}</span></>}>
<ServiceBox>
<br />
<p>{t('infoTooltip')}</p>
<p>
{t.rich('infoDb', {
link: (c) => (
<a href={wowheadWotlkHome(locale)} target="_blank" rel="noopener noreferrer">
{c}
</a>
),
})}
</p>
</ServiceBox>
<StoreBrowser
characters={chars.map((c) => ({ name: c.name, classCss: c.classCss }))}
dp={balances.dp}
vp={balances.vp}
/>
</PageShell>
)
}
+12
View File
@@ -0,0 +1,12 @@
import { getSession } from '@/lib/session'
import { getStoreCatalog } from '@/lib/store'
/** Árbol de la tienda (categorías + ítems). Se carga tras elegir personaje. */
export async function GET() {
const session = await getSession()
if (!session.accountId || !session.username) {
return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
}
const catalog = await getStoreCatalog()
return Response.json({ success: true, catalog })
}
+35
View File
@@ -0,0 +1,35 @@
import { getSession } from '@/lib/session'
import { getGameCharacters } from '@/lib/characters'
import { priceStoreCart, purchaseStoreCart } from '@/lib/store'
/**
* Compra de la tienda: cobra el carrito (PD/PV) y envía los ítems por correo al
* personaje elegido (uno de la cuenta). El precio se recalcula en el servidor a
* partir de las filas de `home_store_item`; nunca se confía en el cliente.
*/
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: { character?: string; items?: number[] } = {}
try {
body = await request.json()
} catch {
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
}
const character = String(body.character ?? '')
const chars = await getGameCharacters(session.accountId)
if (!character || !chars.find((c) => c.name === character)) {
return Response.json({ success: false, error: 'invalidCharacter' })
}
const cart = await priceStoreCart(Array.isArray(body.items) ? body.items : [])
if (!cart) return Response.json({ success: false, error: 'emptyCart' })
const r = await purchaseStoreCart(session.accountId, character, cart)
if (!r.success) return Response.json({ success: false, error: r.error })
return Response.json({ success: true })
}
+67
View File
@@ -516,3 +516,70 @@ textarea:focus {
.pay-method-label { color: #ebdec2; font-weight: bold; }
.pay-method-sub { font-size: 13px; }
.pay-method-balance { margin-top: 2px; font-size: 13px; }
/* ==== Tienda (/store) ==== */
#store-list, #store-list ul { list-style: none; margin: 0; padding-left: 14px; }
#store-list > li { margin: 4px 0; }
#store-list .store-cat,
#store-list .store-subcat,
#store-list .store-sub-subcat {
display: inline-block !important;
cursor: pointer;
padding: 3px 0;
user-select: none;
}
#store-list .store-cat:hover,
#store-list .store-subcat:hover,
#store-list .store-sub-subcat:hover { color: #fff; }
#store-list .store-cat i,
#store-list .store-subcat i,
#store-list .store-sub-subcat i { transition: transform .2s; margin-right: 4px; }
/* Rejilla de objetos dentro de una hoja (visible: React solo la monta al abrir) */
#store-list .item-list {
display: flex !important;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
margin: 8px 0 8px 10px;
}
#store-list .item-box {
width: 150px;
border: 2px solid #352e2b;
border-radius: 8px;
background: rgba(0, 0, 0, 0.25);
padding: 10px 8px;
text-align: center;
}
#store-list .item-box .item-img-box { width: 44px; height: 44px; border-radius: 6px; }
#store-list .item-box .item-name { margin: 4px 0; min-height: 32px; }
#store-list .item-box hr { margin: 6px 0; border-color: #352e2b; }
#store-div { margin-top: 14px; }
/* Carrito */
#cart-list { margin-top: 16px; }
#cart-list table.max-left-table2 { width: 100%; max-width: 620px; margin: 0 auto; border-collapse: collapse; }
#cart-list th, #cart-list td { padding: 6px 8px; }
#cart-list th.justified, #cart-list td.justified { text-align: left; }
#cart-list .icon-td { text-align: center; width: 42px; }
#cart-list .width-50-td { width: 50%; }
/* Modal de resultado */
.modal-div {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0, 0, 0, 0.6);
display: flex; align-items: center; justify-content: center;
}
.modal-content {
position: relative;
background: #17110e;
border: 2px solid #d79602;
border-radius: 10px;
padding: 26px 30px;
max-width: 90vw; min-width: 260px;
text-align: center;
}
.modal-close {
position: absolute; top: 6px; right: 12px;
cursor: pointer; font-size: 22px; color: #b1997f;
}
.modal-close:hover { color: #fff; }