f232f0fc5f
El carrito era un conjunto: añadir dos veces el mismo ítem no hacía nada y el
botón se quedaba en "Añadido". Ahora cada línea lleva sus copias, editables con
un <input number> en la columna Cant, y volver a pulsar Añadir suma una (mismo
patrón que el carrito de send-gift, que ya lo hacía así).
Ojo con los dos "cantidad" que conviven, que no son lo mismo:
`home_store_item.quantity` es el LOTE que entrega cada copia (Paño de lino = 20)
y `copies` es cuántas veces se compra la línea. Por eso la fila enseña "3 × 20"
y el total de la columna Cant sigue siendo unidades (60), como en el original.
Cada copia se manda al SOAP como una entrada propia (`2589:20 2589:20`), que es
justo lo que ya sabía trocear sendStoreItems (12 por correo) y parsear
fulfillStoreOrder: el formato del pedido de tarjeta no cambia.
El servidor no se fía del cliente: priceStoreCart recibe {id, copies}, valida las
copias (1..MAX_COPIES, como el MAX_QTY de send-gift), rechaza el carrito si algún
id no existe y recalcula los totales como precio*copias contra la BD.
Verificado contra la API con saldo de prueba, no solo en la interfaz: con 500 PD
y un ítem de 200, 2 copias (400) pasan el cobro y 3 (600) dan insufficientPd. Si
el servidor ignorase las copias ambas darían lo mismo, así que el corte exacto
entre 2 y 3 prueba que multiplica. Comprobado también que el reembolso del
deliveryFailed devuelve los 400 PD, y que copias=0 o 101 se rechazan.
El CSS del input va con `#cart-list` por especificidad, no por gusto: el tema
define `input[type=number] { width: 290px }` y se carga el último para ganar la
cascada, así que un `.store-copies` a secas perdía y el input salía de 290px
reventando la tabla (send-gift se libra porque usa estilo en línea).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
431 lines
18 KiB
TypeScript
431 lines
18 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, 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 = '/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, 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>
|
||
<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 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 (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={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
|
||
}
|
||
|
||
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, 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 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)
|
||
}
|
||
}
|
||
|
||
// 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 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: lines.map((l) => ({ id: l.it.id, copies: l.copies })),
|
||
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.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)}>×</span>
|
||
<p className={modal.ok ? 'ok-form-response' : 'red-form-response'}>{modal.text}</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</>
|
||
)
|
||
}
|