store: poder cambiar la cantidad de un mismo artículo en el carrito

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>
This commit is contained in:
2026-07-15 10:10:33 +00:00
parent e500bd7a84
commit f232f0fc5f
7 changed files with 128 additions and 33 deletions
+55 -18
View File
@@ -5,7 +5,7 @@ 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 { CARD_MIN_EUR, MAX_COPIES, lineEur, storeEuroTotal } from '@/lib/store-pricing'
import type { StoreCategory, StoreItem } from '@/lib/store'
const ICON_FALLBACK = 'inv_misc_questionmark'
@@ -50,7 +50,7 @@ function CategoryNode({
}: {
cat: StoreCategory
depth: number
cart: Map<number, StoreItem>
cart: Map<number, CartLine>
onAdd: (it: StoreItem) => void
}) {
const t = useTranslations('Store')
@@ -77,7 +77,7 @@ function CategoryNode({
{cat.items.length > 0 && (
<div className="item-list">
{cat.items.map((it) => {
const added = cart.has(it.id)
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:
@@ -113,10 +113,10 @@ function CategoryNode({
type="button"
className="store-add-button"
onClick={() => onAdd(it)}
disabled={added}
style={added ? { color: '#d79602' } : undefined}
disabled={copies >= MAX_COPIES}
style={copies ? { color: '#d79602' } : undefined}
>
{added ? t('added') : t('add')}
{copies ? t('addedCount', { n: copies }) : t('add')}
</button>
</div>
)
@@ -131,6 +131,12 @@ function CategoryNode({
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,
@@ -147,7 +153,7 @@ export function StoreBrowser({
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 [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)
@@ -168,10 +174,22 @@ export function StoreBrowser({
}
}
// 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)
next.set(it.id, it)
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
})
}
@@ -189,11 +207,11 @@ export function StoreBrowser({
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)
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.qty, 0)
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).
@@ -234,7 +252,12 @@ export function StoreBrowser({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ character, items: [...cart.keys()], provider: activeMethod, locale }),
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) {
@@ -333,21 +356,35 @@ export function StoreBrowser({
</tr>
) : (
lines.map((l) => (
<tr key={l.id}>
<tr key={l.it.id}>
<td>
<WowheadLink id={l.itemId}>{l.name}</WowheadLink>
<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>{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 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.price, l.currency), locale)}
{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.id)} title={t('remove')}>
<button type="button" className="store-remove-button" onClick={() => removeItem(l.it.id)} title={t('remove')}>
<i className="fas fa-trash red-info" />
</button>
</td>