e500bd7a84
El carrito enseñaba PD/PV aunque eligieras tarjeta, y por defecto ya presuponía saldo. Ahora se muestran SIEMPRE las dos monedas y se apaga la que no se va a cobrar con el método elegido, así que no presupone nada ni se contradice. Los euros por línea van con hasta 3 decimales a propósito: un ítem en PV con precio impar cuesta medio céntimo (75 PV = 0,375 €), y redondear cada línea a 2 haría que las líneas no sumaran el total (0,38 + 0,38 = 0,76 contra un total de 0,75). El total sí va a céntimos: es el importe que se cobra de verdad. Y con mínimo 2 decimales, que es dinero: "1,50 €", no "1,5 €". El formateo va por Intl con el locale de la web, así que en español sale con coma; antes el selector interpolaba el número crudo y decía "0.75 €" con punto. Mínimos de las pasarelas: SumUp ya estaba en 1 € y Stripe estaba en 0,50 €, ahora también 1 €. Estaban sueltos en la ruta; se centralizan en `lib/store-pricing`, un módulo PURO que importan tanto la ruta como el componente, para que el carrito enseñe exactamente lo que se valida y se cobra. `storeEuroTotal` pasa a vivir ahí (una sola implementación) y lib/store lo envuelve con un guard: si alguien cambia PD_PER_UNIT o VP_PRICE_FACTOR sin tocar store-pricing, revienta al primer uso en vez de cobrar mal en silencio (esos módulos tocan BD y no pueden llegar al bundle del cliente, de ahí la duplicación, igual que en PaymentMethodSelect). Por debajo del mínimo, la opción de tarjeta se desactiva y dice "Mínimo 1,00 €" en vez de dejarte pulsar Enviar para que la API la rechace. El método efectivo se deriva en el render (si el carrito baja del mínimo con tarjeta ya elegida, cae al saldo) en vez de sincronizarlo con un setState en un efecto, que provocaría renders en cascada. Verificado en el navegador: con 0,375 € las dos tarjetas salen desactivadas y elegido el saldo; con 1,50 € se habilitan. Las dos líneas de 0,375 € suman el total de 0,75 €. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
394 lines
16 KiB
TypeScript
394 lines
16 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 { CARD_MIN_EUR, 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 = '/nw-themes/nw-ryu/nw-images/nw-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, 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 ?? ''
|
|
// 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 ok = card
|
|
? window.confirm(t('confirmCard', { eur: eurNumber(eurTotal, locale), 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: 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('successMsg', { 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 (
|
|
<>
|
|
<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">
|
|
{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.id}>
|
|
<td>
|
|
<WowheadLink id={l.itemId}>{l.name}</WowheadLink>
|
|
</td>
|
|
<td><span>{l.qty}</span></td>
|
|
<td>
|
|
<span className={`store-price${payingCard ? ' store-price-off' : ''}`}>
|
|
<span className={l.currency === 'pv' ? 'vp-color' : 'dp-color'}>{l.currency === 'pv' ? 'PV' : 'PD'}:</span> <span>{l.price}</span>
|
|
</span>
|
|
<span className={`store-price store-price-eur${payingCard ? '' : ' store-price-off'}`}>
|
|
{formatEur(lineEur(l.price, l.currency), locale)}
|
|
</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={`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)}>×</span>
|
|
<p className={modal.ok ? 'ok-form-response' : 'red-form-response'}>{modal.text}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)
|
|
}
|