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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user