Files
NightSpire/web-next/components/StoreBrowser.tsx
T
Inna 2daf920c01 store: nombres de ítem en el idioma de la web (desde home_item_data)
El catálogo original solo tiene el nombre en español, así que /en/store venía
mostrando los ítems en español. Ahora salen de `home_item_data` (extraída de
los DB2 del cliente, commit 4ba9c42), que es el único sitio con el nombre en
inglés.

- getStoreCatalog(locale) hace LEFT JOIN con home_item_data y elige name_es o
  name_en. Si falta el nombre en ese idioma se cae al del catálogo, que siempre
  está, así que ningún ítem puede quedarse sin nombre.
- La columna se elige de una lista cerrada ('en' -> name_en, resto -> name_es):
  nunca se interpola el locale crudo, que viene de la URL.
- /api/store/catalog recibe el locale por query: la ruta no cuelga de [locale].

Verificado en los dos idiomas: 45327 sale "Grito de la sirena" en /es y
"Siren's Cry" en /en. El árbol sigue devolviendo las 3068 filas y ninguna sin
nombre (el JOIN no duplica: `entry` es clave primaria).

Pendiente: los nombres de las CATEGORÍAS siguen solo en español
(home_store_category.name), así que /en/store se ve a medias. No son datos del
juego, así que los DB2 no ayudan: hay que traducir 218 nombres distintos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 08:31:57 +00:00

336 lines
14 KiB
TypeScript

'use client'
import { useEffect, useState } from 'react'
import { useTranslations, useLocale } 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'
/** Renders de los ítems de la tienda (public/), para el preview en hover. */
const STORE_DISPLAY_PATH = '/nw-themes/nw-ryu/nw-images/nw-displays'
/**
* Reaplica el tratamiento de wowhead (con `colorLinks` = teñir por calidad) a los
* enlaces que React acaba de montar. `tooltips.js` solo recorre el DOM al cargar,
* así que sin esto los ítems de una categoría recién abierta o del carrito se
* quedan con el azul por defecto del tema. Es lo mismo que hace el store original
* con $WowheadPower.refreshLinks() tras cada AJAX.
*/
function refreshWowheadLinks() {
;(window as unknown as { $WowheadPower?: { refreshLinks: () => void } }).$WowheadPower?.refreshLinks()
}
/** 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'
// Los ítems de la hoja se montan al abrirla: hay que teñirlos entonces.
useEffect(() => {
if (open) refreshWowheadLinks()
}, [open])
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}>
{/* Previsualización del render al pasar el ratón, como el original:
solo la tienen los ítems equipables (los demás no tienen
apariencia). El CSS (nw-st-tooltip-w, nw-img-display) ya viene
en el tema. */}
{it.preview && (
<div className="second-brown nw-st-tooltip-w">
{/* `fas`, no el `fa` suelto del original: cargamos Font Awesome 6,
donde solo .fas/.fa-solid activan la fuente (el `fa` de FA4/5
no pinta nada sin el shim de compatibilidad). */}
<i className="fas fa-image" />
<span className="nw-st-img-tooltip">
<ins className="nw-img-display" style={{ backgroundImage: `url(${STORE_DISPLAY_PATH}/${it.preview}.png)` }} />
</span>
</div>
)}
{/* 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>
)
}
type PayMethod = 'balance' | 'stripe' | 'sumup'
export function StoreBrowser({
characters,
dp,
vp,
realm,
}: {
characters: CharOption[]
dp: number
vp: number
realm: string
}) {
const t = useTranslations('Store')
const locale = useLocale()
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 [method, setMethod] = useState<PayMethod>('balance')
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?locale=${encodeURIComponent(locale)}`, { 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()]
// Las filas del carrito las repinta React: hay que volver a teñirlas.
useEffect(() => {
refreshWowheadLinks()
}, [cart])
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)
// Total de la columna "Cant": como en el original, la SUMA de las cantidades de
// cada línea (un ítem que da un mazo de 20 cuenta 20), no el número de líneas.
const qtyTotal = lines.reduce((s, l) => s + l.qty, 0)
// Clase de color del personaje elegido, para pintarlo como el original.
const charClass = characters.find((c) => c.name === character)?.classCss ?? ''
// 100 PD = 1 €, 200 PV = 1 € (igual que en lib/store storeEuroTotal).
const eurTotal = Math.round((pdTotal / 100 + vpTotal / 200) * 100) / 100
async function send() {
if (sending || cart.size === 0) return
const card = method === 'stripe' || method === 'sumup'
const ok = card
? window.confirm(t('confirmCard', { eur: eurTotal, character }))
: window.confirm(t('confirm', { character, pd: pdTotal, vp: vpTotal }))
if (!ok) 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()], provider: method, locale }),
})
const data: { success?: boolean; error?: string; url?: string; min?: number } = await res.json()
if (data.success && data.url) {
window.location.href = data.url // pasarela (Stripe/SumUp)
return
}
if (data.success) {
setModal({ ok: true, text: t('successMsg', { character }) })
setCart(new Map())
} else if (data.error === 'amountTooLow') {
setModal({ ok: false, text: t('errors.amountTooLow', { min: data.min ?? 1 }) })
} 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: caja propia con título y personaje elegido, como el original. */}
<div className="title-box-content">
<h2>
{t('cartTitle')} - <span className="blue-info">{realm.toUpperCase()}</span>
</h2>
</div>
<div className="body-box-content">
<p className="centered">
{t('selectedCharacter')}: <span className={`${charClass} big-font`}>{character}</span>
</p>
<div id="cart-list">
{lines.length > 0 && (
<div className="pay-method-select">
<p className="second-brown">{t('payMethod')}</p>
<div className="pay-method-options">
{([
{ id: 'balance' as PayMethod, label: t('payBalance'), sub: `${pdTotal} PD · ${vpTotal} PV` },
{ id: 'stripe' as PayMethod, label: t('payStripe'), sub: `${eurTotal} €` },
{ id: 'sumup' as PayMethod, label: t('paySumUp'), sub: `${eurTotal} €` },
]).map((o) => (
<label key={o.id} className={`pay-method-option${method === o.id ? ' selected' : ''}`}>
<input type="radio" name="store-pay" value={o.id} checked={method === o.id} onChange={() => setMethod(o.id)} />
<span className="pay-method-label">{o.label}</span>
<span className="pay-method-sub yellow-info">{o.sub}</span>
</label>
))}
</div>
</div>
)}
<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>{qtyTotal}</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>
</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>
)}
</>
)
}