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