14ebd1e1f1
Replica el preview del store original: un icono en el cuadro del ítem que, al pasar el ratón, muestra el render del objeto. El CSS ya estaba en el tema (nw-st-tooltip-w / nw-st-img-tooltip / nw-img-display, copiado del original); solo faltaban el markup y las imágenes. - Los 545 PNG se sirven desde nw-images/nw-displays/ en vez de enlazar al servidor original, para no depender de un tercero que puede cortarlo. - Solo lo llevan los ítems equipables (610 de 3068): el resto no tiene apariencia, igual que en el original. El dato ya estaba en la columna `preview` de home_store_item y llegaba hasta el componente sin usarse. - El icono va con `fas`, no con el `fa` suelto del original: cargamos Font Awesome 6, donde solo .fas/.fa-solid activan la fuente, y el `fa` de FA4/5 no pintaría nada sin el shim de compatibilidad. Descartado el visor 3D de wowhead: zamimg sirve el meta del modelo con lista blanca de orígenes (403 desde nuestro dominio, 200 solo desde wowhead.com), así que solo funcionaría proxeando sus assets para saltarse esa comprobación. Verificado en el navegador: oculto por defecto, visible en hover, imagen 200. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
336 lines
14 KiB
TypeScript
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', { 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)}>×</span>
|
|
<p className={modal.ok ? 'ok-form-response' : 'red-form-response'}>{modal.text}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|