Files
NightSpire/web-next/components/StoreBrowser.tsx
T
Inna 47b5a2a145 Mostrar los errores de send-gift: el tema los dejaba invisibles
El botón «Mostrar regalos» parecía no hacer nada: setFormError se llamaba
bien, pero el tema deja `.alert-message` con display:none esperando que la
rellene y la revele el JS antiguo (jQuery), que ya no existe. La caja se
pintaba y no se veía. Se fuerza el display desde React, como el resto de
formularios.

De paso, `.modal-content` estaba definida en globals.css Y en theme.css.
Como theme.css se importa el último (a propósito, para ganarle al preflight
de Tailwind), ganaba él a igual especificidad y el bloque de globals.css era
código muerto: el modal de la tienda salía como hoja pegada al fondo con
borde gris en vez de tarjeta centrada dorada. Se acota a
`.modal-div .modal-content` para ganar por especificidad y no por orden.

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

571 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { Fragment, useEffect, useState } from 'react'
import { useTranslations, useLocale } from 'next-intl'
import { CharacterSelect, type CharOption } from '@/components/CharacterSelect'
import { SecretInput } from '@/components/SecretInput'
import { WowheadLink } from '@/components/WowheadLink'
import { wowheadIcon } from '@/lib/wowhead'
import { CARD_MIN_EUR, MAX_COPIES, lineEur, storeEuroTotal } from '@/lib/store-pricing'
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 = '/ns-themes/ns-ryu/ns-images/ns-displays'
/**
* Importe en euros en el idioma de la web ("2,00 €", "0,125 €").
*
* Mínimo 2 decimales porque es dinero ("1,50 €", no "1,5 €"). Y hasta 3 a
* propósito: un ítem en PV con precio impar cuesta medio céntimo (25 PV =
* 0,125 €), así que redondear cada línea a 2 haría que las líneas no sumaran el
* total y el carrito se contradiría. El total sí va redondeado a céntimos,
* porque es el importe real que se cobra.
*/
function eurNumber(n: number, locale: string): string {
return new Intl.NumberFormat(locale, { minimumFractionDigits: 2, maximumFractionDigits: 3 }).format(n)
}
function formatEur(n: number, locale: string): string {
return `${eurNumber(n, locale)} €`
}
/**
* 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, CartLine>
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>
{/* `cat.css` y no `first-brown` fijo: el original colorea algunas
categorías enteras por clase (p.ej. "Brujo" en color de brujo) poniendo
esa clase EN LUGAR de first-brown. Y dentro del nombre, cada parte
puede llevar la suya ("Guerrero, Sacerdote, Druida", "[Nivel 232 a
245]" en otro tono). */}
<span className={`${spanClass} ${cat.css}`} role="button" tabIndex={0} onClick={() => setOpen((o) => !o)}>
<i className={`fas fa-angle-right green-info${open ? ' fa-rotate-90' : ''}`} />{' '}
{/* El texto sin color va SUELTO, no envuelto: el tema tiene
`span { color: #fff }`, así que meterlo en un <span> lo pondría
blanco en vez de heredar el color de la cabecera. */}
{cat.name.map((p, i) =>
p.cls ? (
<span key={i} className={`no-toggle ${p.cls}`}>
{p.text}
</span>
) : (
<Fragment key={i}>{p.text}</Fragment>
),
)}
</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 copies = cart.get(it.id)?.copies ?? 0
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 (ns-st-tooltip-w, ns-img-display) ya viene
en el tema. */}
{it.preview && (
<div className="second-brown ns-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="ns-st-img-tooltip">
<ins className="ns-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={copies >= MAX_COPIES}
style={copies ? { color: '#d79602' } : undefined}
>
{copies ? t('addedCount', { n: copies }) : t('add')}
</button>
</div>
)
})}
</div>
)}
</>
)}
</li>
)
}
type PayMethod = 'balance' | 'stripe' | 'sumup'
/** Línea del carrito: el ítem del catálogo y cuántas copias se llevan. */
interface CartLine {
it: StoreItem
copies: number
}
/**
* Árbol + carrito de la tienda. En modo `gift` es lo mismo pero regalando: el
* original hace justo esto ("los objetos disponibles son los mismos de la
* tienda"), en dos pasos —primero de quién a quién y el token, y solo después el
* catálogo— y el correo lo recibe otro personaje.
*/
export function StoreBrowser({
characters,
dp,
vp,
realm,
mode = 'store',
}: {
characters: CharOption[]
dp: number
vp: number
realm: string
mode?: 'store' | 'gift'
}) {
const gift = mode === 'gift'
const [destination, setDestination] = useState('')
const [confirmDest, setConfirmDest] = useState('')
const [token, setToken] = useState('')
const [shown, setShown] = useState(false)
const [formError, setFormError] = useState('')
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, CartLine>>(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 loadCatalog() {
if (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)
}
}
async function onSelect(name: string) {
setCharacter(name)
setCart(new Map())
// En la tienda el catálogo sale al elegir personaje; en el regalo hay que
// decir antes a quién y con qué token ("Mostrar Regalos").
if (name && !gift) await loadCatalog()
}
/**
* Paso 1 del regalo: valida y solo entonces enseña el catálogo.
*
* La confirmación del nombre se compara aquí (es cosa de la pantalla), pero
* que el personaje de destino exista y que el token sea correcto lo dice el
* SERVIDOR: si no, elegirías objetos para descubrir al final que el destino no
* existe. El nombre del destino puede ir en mayúsculas o minúsculas; el
* servidor devuelve el canónico y es el que se usa a partir de aquí.
*/
async function showGifts(e: React.FormEvent) {
e.preventDefault()
if (!character || !destination.trim() || !token.trim()) return setFormError(t('gift.errMissing'))
if (destination.trim().toLowerCase() !== confirmDest.trim().toLowerCase()) {
return setFormError(t('gift.errConfirm'))
}
setFormError('')
setLoading(true)
try {
const res = await fetch('/api/gift/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ source: character, destination: destination.trim(), security_token: token }),
})
const data: { success?: boolean; error?: string; destination?: string } = await res.json()
if (!data.success) {
setFormError(t.has(`errors.${data.error}`) ? t(`errors.${data.error}`) : t('errors.generic'))
return
}
if (data.destination) setDestination(data.destination)
} catch {
setFormError(t('errors.generic'))
return
} finally {
setLoading(false)
}
await loadCatalog()
setShown(true)
}
// Volver a añadir un ítem ya en el carrito suma una copia (como send-gift).
function addItem(it: StoreItem) {
setCart((prev) => {
const next = new Map(prev)
const cur = next.get(it.id)
next.set(it.id, { it, copies: Math.min(MAX_COPIES, (cur?.copies ?? 0) + 1) })
return next
})
}
function setCopies(id: number, n: number) {
setCart((prev) => {
const cur = prev.get(id)
if (!cur) return prev
const next = new Map(prev)
// El <input type=number> deja escribir vacío o basura: se acota siempre.
next.set(id, { ...cur, copies: Math.max(1, Math.min(MAX_COPIES, Math.floor(n) || 1)) })
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.it.currency === 'pd').reduce((s, l) => s + l.it.price * l.copies, 0)
const vpTotal = lines.filter((l) => l.it.currency === 'pv').reduce((s, l) => s + l.it.price * l.copies, 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.it.qty * l.copies, 0)
// Clase de color del personaje elegido, para pintarlo como el original.
const charClass = characters.find((c) => c.name === character)?.classCss ?? ''
// El mismo cálculo que valida y cobra /api/store/send (módulo compartido).
const eurTotal = storeEuroTotal(pdTotal, vpTotal)
// Por debajo del mínimo de la pasarela, su API rechaza el cobro: se desactiva
// la opción y se dice por qué, en vez de dejar que falle al pulsar Enviar.
const cardTooLow = (m: PayMethod) => (m === 'stripe' || m === 'sumup') && eurTotal < CARD_MIN_EUR[m]
// Método REAL, derivado en el render: si el carrito baja del mínimo con una
// tarjeta ya elegida, esa opción se desactiva y se cae al saldo. Derivado y no
// un setState en un efecto, que provocaría renders en cascada.
const activeMethod: PayMethod = cardTooLow(method) ? 'balance' : method
// Con tarjeta se paga en euros y con saldo en PD/PV: se muestran siempre los
// dos, y se apaga el que no se va a cobrar para que no haya duda.
const payingCard = activeMethod === 'stripe' || activeMethod === 'sumup'
const payOptions: { id: PayMethod; label: string; sub: string; disabled?: boolean }[] = [
{ id: 'balance', label: t('payBalance'), sub: `${pdTotal} PD · ${vpTotal} PV` },
...(['stripe', 'sumup'] as const).map((id) => ({
id: id as PayMethod,
label: t(id === 'stripe' ? 'payStripe' : 'paySumUp'),
sub: cardTooLow(id)
? t('minCard', { min: eurNumber(CARD_MIN_EUR[id], locale) })
: formatEur(eurTotal, locale),
disabled: cardTooLow(id),
})),
]
async function send() {
if (sending || cart.size === 0) return
const card = payingCard
const who = gift ? destination.trim() : character
const ok = card
? window.confirm(t('confirmCard', { eur: eurNumber(eurTotal, locale), character: who }))
: window.confirm(t('confirm', { character: who, pd: pdTotal, vp: vpTotal }))
if (!ok) return
setSending(true)
try {
const items = lines.map((l) => ({ id: l.it.id, copies: l.copies }))
const res = await fetch(gift ? '/api/gift/send' : '/api/store/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify(
gift
? { source: character, destination: destination.trim(), security_token: token, items, provider: activeMethod, locale }
: { character, items, provider: activeMethod, locale },
),
})
const data: { success?: boolean; error?: string; url?: string; min?: number } = await res.json()
if (data.success && data.url) {
// .assign() y no `location.href = …`: es equivalente, pero la regla del
// compilador de React prohíbe asignar a algo definido fuera del componente.
window.location.assign(data.url) // pasarela (Stripe/SumUp)
return
}
if (data.success) {
setModal({ ok: true, text: t(gift ? 'gift.successMsg' : 'successMsg', { character: gift ? destination.trim() : character }) })
setCart(new Map())
} else if (data.error === 'amountTooLow') {
setModal({ ok: false, text: t('errors.amountTooLow', { min: eurNumber(data.min ?? 1, locale) }) })
} 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 (
<>
{/* Paso 1 del regalo (#char-select-div en el original): de quién a quién y
token. Desaparece al pulsar "Mostrar Regalos" y deja sitio al catálogo. */}
{gift && !shown && (
<div className="centered" id="char-select-div">
<br />
<p>{t('gift.choosePlayer')}</p>
<br />
<form noValidate onSubmit={showGifts} acceptCharset="utf-8">
<CharacterSelect characters={characters} value={character} onChange={onSelect} placeholder={t('selectPlaceholder')} />
<table className="middle-center-table">
<tbody>
<tr>
<td>
<input
type="text"
maxLength={12}
value={destination}
onChange={(e) => setDestination(e.target.value)}
placeholder={t('gift.destPlaceholder')}
/>
</td>
</tr>
<tr>
<td>
<input
type="text"
maxLength={12}
value={confirmDest}
onChange={(e) => setConfirmDest(e.target.value)}
placeholder={t('gift.confirmPlaceholder')}
/>
</td>
</tr>
<tr>
<td>
<SecretInput value={token} onChange={setToken} placeholder={t('gift.tokenPlaceholder')} />
</td>
</tr>
<tr>
<td>
<button type="submit" className="show-gift-button" disabled={loading}>
{loading ? t('gift.showing') : t('gift.show')}
</button>
</td>
</tr>
</tbody>
</table>
</form>
<hr />
{/* `display:block` a mano: el tema deja `.alert-message` OCULTA
(display:none) para que la rellene y la revele el JS. Sin esto la
caja se pintaba pero NO se veía: al pulsar «Mostrar regalos» no
salía ningún error. El resto de formularios ya lo hacen así. */}
<div className="alert-message" id="show-gif-response" style={{ display: formError ? 'block' : 'none' }}>
{formError && <span className="red-form-response">{formError}</span>}
</div>
</div>
)}
{!gift && (
<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 && (!gift || shown) && (
<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">
{payOptions.map((o) => (
<label
key={o.id}
className={`pay-method-option${activeMethod === o.id ? ' selected' : ''}${o.disabled ? ' disabled' : ''}`}
>
<input
type="radio"
name="store-pay"
value={o.id}
checked={activeMethod === o.id}
disabled={o.disabled}
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.it.id}>
<td>
<WowheadLink id={l.it.itemId}>{l.it.name}</WowheadLink>
</td>
<td>
<input
type="number"
className="store-copies"
min={1}
max={MAX_COPIES}
value={l.copies}
onChange={(e) => setCopies(l.it.id, Number(e.target.value))}
aria-label={t('colQty')}
/>
{/* Cada copia entrega un lote (Paño de lino = 20): se dice
cuántas unidades salen, que es lo que enseña el total. */}
{l.it.qty > 1 && <span className="second-brown"> × {l.it.qty}</span>}
</td>
<td>
<span className={`store-price${payingCard ? ' store-price-off' : ''}`}>
<span className={l.it.currency === 'pv' ? 'vp-color' : 'dp-color'}>{l.it.currency === 'pv' ? 'PV' : 'PD'}:</span>{' '}
<span>{l.it.price * l.copies}</span>
</span>
<span className={`store-price store-price-eur${payingCard ? '' : ' store-price-off'}`}>
{formatEur(lineEur(l.it.price, l.it.currency) * l.copies, locale)}
</span>
</td>
<td className="icon-td">
<button type="button" className="store-remove-button" onClick={() => removeItem(l.it.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={`store-price${payingCard ? ' store-price-off' : ''}`}>
<span className="dp-color">PD: </span><span>{pdTotal}</span><br />
<span className="vp-color">PV: </span><span>{vpTotal}</span>
</span>
<span className={`store-price store-price-eur store-price-total${payingCard ? '' : ' store-price-off'}`}>
{formatEur(eurTotal, locale)}
</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>
)}
</>
)
}