'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 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 (
  • {/* `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). */} setOpen((o) => !o)}> {' '} {/* El texto sin color va SUELTO, no envuelto: el tema tiene `span { color: #fff }`, así que meterlo en un lo pondría blanco en vez de heredar el color de la cabecera. */} {cat.name.map((p, i) => p.cls ? ( {p.text} ) : ( {p.text} ), )} {open && ( <> {cat.children.length > 0 && (
      {cat.children.map((c) => ( ))}
    )} {cat.items.length > 0 && (
    {cat.items.map((it) => { const copies = cart.get(it.id)?.copies ?? 0 return (
    {/* 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 && (
    {/* `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). */}
    )} {/* eslint-disable-next-line @next/next/no-img-element */} {it.name}

    {it.name}


    {t('quantity')}: {it.qty}

    {it.currency === 'pv' ? 'PV' : 'PD'}:{' '} {it.price}


    ) })}
    )} )}
  • ) } 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(null) const [loading, setLoading] = useState(false) const [cart, setCart] = useState>(new Map()) const [method, setMethod] = useState('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 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 && (

    {t('gift.choosePlayer')}


    setDestination(e.target.value)} placeholder={t('gift.destPlaceholder')} />
    setConfirmDest(e.target.value)} placeholder={t('gift.confirmPlaceholder')} />

    {/* `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í. */}
    {formError && {formError}}
    )} {!gift && (

    {t('choosePlayer')}


    PD: {dp} · PV: {vp}

    )} {loading &&

    {t('loading')}

    } {catalog && !loading && (!gift || shown) && (
      {catalog.map((c) => ( ))}
    {/* Carrito: caja propia con título y personaje elegido, como el original. */}

    {t('cartTitle')} - {realm.toUpperCase()}

    {t('selectedCharacter')}: {character}

    {lines.length > 0 && (

    {t('payMethod')}

    {payOptions.map((o) => ( ))}
    )} {lines.length === 0 ? ( ) : ( lines.map((l) => ( )) )}
    {t('colItem')} {t('colQty')} {t('colValue')}
    {t('cartEmpty')}
    {l.it.name} 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 && × {l.it.qty}} {l.it.currency === 'pv' ? 'PV' : 'PD'}:{' '} {l.it.price * l.copies} {formatEur(lineEur(l.it.price, l.it.currency) * l.copies, locale)}

    {qtyTotal} PD: {pdTotal}
    PV: {vpTotal}
    {formatEur(eurTotal, locale)}
    )} {modal && (
    setModal(null)}>
    e.stopPropagation()}> setModal(null)}>×

    {modal.text}

    )} ) }