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; }
+238
View File
@@ -0,0 +1,238 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { CharacterSelect, type CharOption } from '@/components/CharacterSelect'
import { WowheadLink } from '@/components/WowheadLink'
import { wowheadIcon } from '@/lib/wowhead'
import type { StoreCategory, StoreItem } from '@/lib/store'
const ICON_FALLBACK = 'inv_misc_questionmark'
/** Nodo de categoría colapsable; renderiza sus ítems solo cuando está abierto. */
function CategoryNode({
cat,
depth,
cart,
onAdd,
}: {
cat: StoreCategory
depth: number
cart: Map<number, StoreItem>
onAdd: (it: StoreItem) => void
}) {
const t = useTranslations('Store')
const [open, setOpen] = useState(false)
const spanClass = depth === 0 ? 'store-cat' : depth === 1 ? 'store-subcat' : 'store-sub-subcat'
return (
<li>
<span className={`${spanClass} first-brown`} role="button" tabIndex={0} onClick={() => setOpen((o) => !o)}>
<i className={`fas fa-angle-right green-info${open ? ' fa-rotate-90' : ''}`} /> {cat.name}
</span>
{open && (
<>
{cat.children.length > 0 && (
<ul className={depth === 0 ? 'store-subcat' : 'store-sub-subcat'}>
{cat.children.map((c) => (
<CategoryNode key={c.code} cat={c} depth={depth + 1} cart={cart} onAdd={onAdd} />
))}
</ul>
)}
{cat.items.length > 0 && (
<div className="item-list">
{cat.items.map((it) => {
const added = cart.has(it.id)
return (
<div className="item-box" key={it.id}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img className="item-img-box" src={wowheadIcon(it.icon || ICON_FALLBACK, 'large')} alt={it.name} loading="lazy" />
<p className="item-name centered">
<WowheadLink id={it.itemId}>{it.name}</WowheadLink>
</p>
<hr />
<p>
<span className="second-brown">{t('quantity')}:</span> <span>{it.qty}</span>
</p>
<p>
<span className={it.currency === 'pv' ? 'vp-color' : 'dp-color'}>{it.currency === 'pv' ? 'PV' : 'PD'}:</span>{' '}
<span>{it.price}</span>
</p>
<br />
<button
type="button"
className="store-add-button"
onClick={() => onAdd(it)}
disabled={added}
style={added ? { color: '#d79602' } : undefined}
>
{added ? t('added') : t('add')}
</button>
</div>
)
})}
</div>
)}
</>
)}
</li>
)
}
export function StoreBrowser({ characters, dp, vp }: { characters: CharOption[]; dp: number; vp: number }) {
const t = useTranslations('Store')
const [character, setCharacter] = useState('')
const [catalog, setCatalog] = useState<StoreCategory[] | null>(null)
const [loading, setLoading] = useState(false)
const [cart, setCart] = useState<Map<number, StoreItem>>(new Map())
const [sending, setSending] = useState(false)
const [modal, setModal] = useState<{ ok: boolean; text: string } | null>(null)
async function onSelect(name: string) {
setCharacter(name)
setCart(new Map())
if (!name || catalog) return
setLoading(true)
try {
const res = await fetch('/api/store/catalog', { credentials: 'same-origin' })
const data: { success?: boolean; catalog?: StoreCategory[] } = await res.json()
setCatalog(data.success ? data.catalog ?? [] : [])
} catch {
setCatalog([])
} finally {
setLoading(false)
}
}
function addItem(it: StoreItem) {
setCart((prev) => {
const next = new Map(prev)
next.set(it.id, it)
return next
})
}
function removeItem(id: number) {
setCart((prev) => {
const next = new Map(prev)
next.delete(id)
return next
})
}
const lines = [...cart.values()]
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)
async function send() {
if (sending || cart.size === 0) return
if (!window.confirm(t('confirm', { character, pd: pdTotal, vp: vpTotal }))) return
setSending(true)
try {
const res = await fetch('/api/store/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ character, items: [...cart.keys()] }),
})
const data: { success?: boolean; error?: string } = await res.json()
if (data.success) {
setModal({ ok: true, text: t('successMsg', { character }) })
setCart(new Map())
} else {
setModal({ ok: false, text: t.has(`errors.${data.error}`) ? t(`errors.${data.error}`) : t('errors.generic') })
}
} catch {
setModal({ ok: false, text: t('errors.generic') })
} finally {
setSending(false)
}
}
return (
<>
<div className="centered">
<br />
<p>{t('choosePlayer')}</p>
<br />
<CharacterSelect characters={characters} value={character} onChange={onSelect} placeholder={t('selectPlaceholder')} className="store-select" />
<p className="third-brown pay-method-balance">
<span className="dp-color">PD</span>: {dp} · <span className="vp-color">PV</span>: {vp}
</p>
</div>
{loading && <p className="centered second-brown">{t('loading')}</p>}
{catalog && !loading && (
<div className="box-content" id="store-div">
<ul id="store-list">
{catalog.map((c) => (
<CategoryNode key={c.code} cat={c} depth={0} cart={cart} onAdd={addItem} />
))}
</ul>
{/* Carrito */}
<div id="cart-list">
<table className="max-left-table2">
<tbody>
<tr>
<th className="width-50-td justified">{t('colItem')}</th>
<th className="justified">{t('colQty')}</th>
<th className="justified">{t('colValue')}</th>
<th className="icon-td">
<button type="button" className="store-empty-button" title={t('empty')} onClick={() => setCart(new Map())} disabled={cart.size === 0}>
<i className="fas fa-trash" />
</button>
</th>
</tr>
{lines.length === 0 ? (
<tr>
<td colSpan={4} className="second-brown centered">{t('cartEmpty')}</td>
</tr>
) : (
lines.map((l) => (
<tr key={l.id}>
<td>
<WowheadLink id={l.itemId}>{l.name}</WowheadLink>
</td>
<td><span>{l.qty}</span></td>
<td>
<span className={l.currency === 'pv' ? 'vp-color' : 'dp-color'}>{l.currency === 'pv' ? 'PV' : 'PD'}:</span> <span>{l.price}</span>
</td>
<td className="icon-td">
<button type="button" className="store-remove-button" onClick={() => removeItem(l.id)} title={t('remove')}>
<i className="fas fa-trash red-info" />
</button>
</td>
</tr>
))
)}
<tr><td colSpan={4}><hr /></td></tr>
<tr>
<td></td>
<td><span>{lines.length}</span></td>
<td>
<span className="dp-color">PD: </span><span>{pdTotal}</span><br />
<span className="vp-color">PV: </span><span>{vpTotal}</span>
</td>
<td>
<button type="button" className="store-send-button" onClick={send} disabled={sending || cart.size === 0}>
{sending ? t('sending') : t('send')}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
)}
{modal && (
<div className="modal-div" onClick={() => setModal(null)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<span className="modal-close" role="button" tabIndex={0} onClick={() => setModal(null)}>&times;</span>
<p className={modal.ok ? 'ok-form-response' : 'red-form-response'}>{modal.text}</p>
</div>
</div>
)}
</>
)
}
+205
View File
@@ -0,0 +1,205 @@
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
}
+30
View File
@@ -1927,5 +1927,35 @@
"vpCost": "{cost} VP",
"insufficientVp": "Insufficient balance ({cost} VP)",
"balanceVp": "VP: {balance}"
},
"Store": {
"title": "Store",
"infoTooltip": "- Hover over an item's name to preview its tooltip.",
"infoDb": "- You can click the item names to visit them in our <link>database</link>. There you can view weapons, armor, mounts and pets in 3D!",
"choosePlayer": "Choose the character the items will be sent to",
"selectPlaceholder": "Select a character",
"noCharacters": "You don't have any characters on this account yet.",
"loading": "Loading the store…",
"quantity": "Quantity",
"add": "Add",
"added": "Added",
"colItem": "Item",
"colQty": "Qty",
"colValue": "Value",
"remove": "Remove item",
"empty": "Empty cart",
"send": "Send",
"sending": "Sending…",
"cartEmpty": "Your cart is empty.",
"confirm": "Send the items to {character} for {pd} PD and {vp} VP?",
"successMsg": "Items sent to {character}! Check your in-game mail.",
"errors": {
"insufficientPd": "You don't have enough PD for this purchase.",
"insufficientVp": "You don't have enough VP for this purchase.",
"deliveryFailed": "The items could not be sent (game server unavailable). Your balance has been refunded.",
"invalidCharacter": "Invalid character.",
"emptyCart": "Your cart is empty.",
"generic": "An error occurred. Please try again."
}
}
}
+30
View File
@@ -1927,5 +1927,35 @@
"vpCost": "{cost} PV",
"insufficientVp": "Saldo insuficiente ({cost} PV)",
"balanceVp": "PV: {balance}"
},
"Store": {
"title": "Tienda",
"infoTooltip": "- Pasa el cursor por el nombre de un objeto para previsualizar su tooltip.",
"infoDb": "- Puedes hacer clic en los nombres de los objetos para visitarlos en nuestra <link>base de datos</link>. ¡Allí podrás ver en 3D armas, armaduras, monturas y mascotas!",
"choosePlayer": "Escoge el personaje al que se enviarán los objetos",
"selectPlaceholder": "Selecciona un personaje",
"noCharacters": "Aún no tienes personajes en esta cuenta.",
"loading": "Cargando la tienda…",
"quantity": "Cantidad",
"add": "Añadir",
"added": "Añadido",
"colItem": "Objeto",
"colQty": "Cant",
"colValue": "Valor",
"remove": "Quitar objeto",
"empty": "Vaciar carrito",
"send": "Enviar",
"sending": "Enviando…",
"cartEmpty": "Tu carrito está vacío.",
"confirm": "¿Enviar los objetos a {character} por {pd} PD y {vp} PV?",
"successMsg": "¡Objetos enviados a {character}! Revisa el correo dentro del juego.",
"errors": {
"insufficientPd": "No tienes suficientes PD para esta compra.",
"insufficientVp": "No tienes suficientes PV para esta compra.",
"deliveryFailed": "No se pudieron enviar los objetos (servidor de juego no disponible). Se te ha devuelto el saldo.",
"invalidCharacter": "El personaje no es válido.",
"emptyCart": "Tu carrito está vacío.",
"generic": "Ha ocurrido un error. Inténtalo de nuevo."
}
}
}
File diff suppressed because it is too large Load Diff