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
+4 -2
View File
@@ -1,7 +1,7 @@
import { randomUUID } from 'crypto'
import { getSession } from '@/lib/session'
import { getGameCharacters } from '@/lib/characters'
import { priceStoreCart, purchaseStoreCart, storeEuroTotal, createStoreOrder } from '@/lib/store'
import { priceStoreCart, purchaseStoreCart, storeEuroTotal, createStoreOrder, type StoreCartLine } from '@/lib/store'
import { CARD_MIN_EUR } from '@/lib/store-pricing'
import { createCheckoutSession } from '@/lib/stripe'
import { createSumUpCheckout, sumupConfigured } from '@/lib/sumup'
@@ -23,7 +23,9 @@ export async function POST(request: Request) {
return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
}
let body: { character?: string; items?: number[]; provider?: string; locale?: string } = {}
// `items` viene del cliente y NO se cree nada de él salvo los ids y las
// copias: precios, moneda y lote los relee priceStoreCart de la BD.
let body: { character?: string; items?: StoreCartLine[]; provider?: string; locale?: string } = {}
try {
body = await request.json()
} catch {
+9
View File
@@ -593,6 +593,15 @@ textarea:focus {
/* Precios del carrito: se enseñan SIEMPRE las dos monedas (saldo PD/PV y euros)
y se apaga la que no se va a cobrar con el método elegido. Así el carrito no
presupone nada antes de elegir ni se contradice al pagar con tarjeta. */
/* Cantidad por línea del carrito. 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 pierde y el
input sale de 290px, reventando la tabla. */
#cart-list input.store-copies {
width: 4em;
height: 24px;
text-align: center;
}
.store-price { display: block; transition: opacity .2s, color .2s; }
.store-price-eur { color: #ebdec2; }
.store-price-total { font-weight: bold; }
+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>
+9
View File
@@ -22,6 +22,15 @@ export const CARD_MIN_EUR: Record<'stripe' | 'sumup', number> = {
sumup: 1,
}
/**
* Máximo de copias de un mismo ítem por línea del carrito (igual que el MAX_QTY
* de send-gift). OJO con la diferencia: `copies` es cuántas VECES compras la
* línea, y no se confunde con `home_store_item.quantity`, que es el tamaño del
* lote que entrega cada copia (Paño de lino = 20). 2 copias de Paño de lino =
* 40 telas, y se envían como dos entradas `2589:20`.
*/
export const MAX_COPIES = 100
/** Euros de una línea, SIN redondear: un ítem en PV de precio impar cuesta medio céntimo. */
export function lineEur(price: number, currency: 'pd' | 'pv'): number {
return price / (currency === 'pv' ? PV_PER_EUR : PD_PER_EUR)
+49 -13
View File
@@ -3,7 +3,7 @@ import { db, DB } from './db'
import { executeSoapCommand } from './soap'
import { PD_PER_UNIT } from './dpoints'
import { VP_PRICE_FACTOR } from './pay-with-dpoints'
import { PD_PER_EUR, PV_PER_EUR, storeEuroTotal as pureEuroTotal } from './store-pricing'
import { MAX_COPIES, PD_PER_EUR, PV_PER_EUR, storeEuroTotal as pureEuroTotal } from './store-pricing'
/**
* Tienda de ítems (transfiguración/equipo), portada del sistema antiguo (BENNU).
@@ -130,19 +130,38 @@ export async function getStoreBalances(accountId: number): Promise<{ dp: number;
}
}
/** Línea que manda el cliente: fila del catálogo + cuántas copias quiere. */
export interface StoreCartLine {
id: number
copies: number
}
export interface PricedCart {
lines: { id: number; itemId: number; qty: number; currency: Currency; price: number }[]
// `qty` = lote que entrega CADA copia (home_store_item.quantity);
// `copies` = cuántas veces se compra la línea. Total de unidades = qty*copies.
lines: { id: number; itemId: number; qty: number; currency: Currency; price: number; copies: number }[]
pdTotal: number
vpTotal: number
}
/**
* Valida el carrito contra la BD (precios/cantidades/moneda REALES, nunca del
* cliente) a partir de las filas `home_store_item.id`. Devuelve null si vacío.
* cliente) a partir de las filas `home_store_item.id` y las copias pedidas.
* Devuelve null si está vacío, si alguna copia no es válida o si algún id no
* existe en el catálogo (mismo criterio que `priceCart` de send-gift).
*/
export async function priceStoreCart(itemRowIds: number[]): Promise<PricedCart | null> {
const ids = [...new Set((Array.isArray(itemRowIds) ? itemRowIds : []).map((n) => Math.floor(Number(n))).filter((n) => n > 0))]
if (ids.length === 0) return null
export async function priceStoreCart(cart: StoreCartLine[]): Promise<PricedCart | null> {
const copiesById = new Map<number, number>()
for (const l of Array.isArray(cart) ? cart : []) {
const id = Math.floor(Number(l?.id))
const copies = Math.floor(Number(l?.copies))
if (!Number.isInteger(id) || id <= 0) return null
if (!Number.isInteger(copies) || copies < 1 || copies > MAX_COPIES) return null
copiesById.set(id, Math.min(MAX_COPIES, (copiesById.get(id) ?? 0) + copies))
}
if (copiesById.size === 0) return null
const ids = [...copiesById.keys()]
let rows: ItemRow[] = []
try {
;[rows] = await db(DB.default).query<ItemRow[]>(
@@ -152,11 +171,28 @@ export async function priceStoreCart(itemRowIds: number[]): Promise<PricedCart |
} catch {
return null
}
if (rows.length === 0) return null
const lines = rows.map((r) => ({ id: Number(r.id), itemId: Number(r.item_id), qty: Number(r.quantity), currency: r.currency, price: Number(r.price) }))
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)
return { lines, pdTotal, vpTotal }
if (rows.length !== ids.length) return null // algún id no existe en el catálogo
const lines = rows.map((r) => ({
id: Number(r.id),
itemId: Number(r.item_id),
qty: Number(r.quantity),
currency: r.currency,
price: Number(r.price),
copies: copiesById.get(Number(r.id))!,
}))
const sum = (c: Currency) =>
lines.filter((l) => l.currency === c).reduce((s, l) => s + l.price * l.copies, 0)
return { lines, pdTotal: sum('pd'), vpTotal: sum('pv') }
}
/**
* Entradas para el SOAP/el pedido: cada copia va como una entrada propia
* (`2589:20 2589:20` = 2 lotes de 20), que es justo lo que ya sabe trocear
* `sendStoreItems` (12 por correo) y parsear `fulfillStoreOrder`.
*/
function cartEntries(cart: PricedCart): { i: number; q: number }[] {
return cart.lines.flatMap((l) => Array.from({ length: l.copies }, () => ({ i: l.itemId, q: l.qty })))
}
/**
@@ -196,7 +232,7 @@ export async function purchaseStoreCart(
}
// Envío por correo (troceado a 12 ítems). Si falla, se reembolsa el saldo.
const ok = await sendStoreItems(character, cart.lines.map((l) => ({ i: l.itemId, q: l.qty })))
const ok = await sendStoreItems(character, cartEntries(cart))
if (!ok) {
if (charged) {
await db(DB.default)
@@ -236,7 +272,7 @@ export async function createStoreOrder(
character: string,
cart: PricedCart,
): Promise<boolean> {
const items = cart.lines.map((l) => `${l.itemId}:${l.qty}`).join(',')
const items = cartEntries(cart).map((e) => `${e.i}:${e.q}`).join(',')
const amount = storeEuroTotal(cart.pdTotal, cart.vpTotal)
try {
await db(DB.default).query(
+1
View File
@@ -1944,6 +1944,7 @@
"quantity": "Quantity",
"add": "Add",
"added": "Added",
"addedCount": "Added ({n})",
"cartTitle": "Cart",
"selectedCharacter": "Selected character",
"colItem": "Item",
+1
View File
@@ -1944,6 +1944,7 @@
"quantity": "Cantidad",
"add": "Añadir",
"added": "Añadido",
"addedCount": "Añadido ({n})",
"cartTitle": "Carrito",
"selectedCharacter": "Personaje seleccionado",
"colItem": "Objeto",