From f232f0fc5fa0a203fa0393d8f8744162af180184 Mon Sep 17 00:00:00 2001 From: adevopg Date: Wed, 15 Jul 2026 10:10:33 +0000 Subject: [PATCH] =?UTF-8?q?store:=20poder=20cambiar=20la=20cantidad=20de?= =?UTF-8?q?=20un=20mismo=20art=C3=ADculo=20en=20el=20carrito?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- web-next/app/api/store/send/route.ts | 6 ++- web-next/app/globals.css | 9 ++++ web-next/components/StoreBrowser.tsx | 73 +++++++++++++++++++++------- web-next/lib/store-pricing.ts | 9 ++++ web-next/lib/store.ts | 62 ++++++++++++++++++----- web-next/messages/en.json | 1 + web-next/messages/es.json | 1 + 7 files changed, 128 insertions(+), 33 deletions(-) diff --git a/web-next/app/api/store/send/route.ts b/web-next/app/api/store/send/route.ts index dab1eb7..3776f01 100644 --- a/web-next/app/api/store/send/route.ts +++ b/web-next/app/api/store/send/route.ts @@ -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 { diff --git a/web-next/app/globals.css b/web-next/app/globals.css index 2cc1f20..caf06be 100644 --- a/web-next/app/globals.css +++ b/web-next/app/globals.css @@ -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; } diff --git a/web-next/components/StoreBrowser.tsx b/web-next/components/StoreBrowser.tsx index f7b66c4..c7b61a7 100644 --- a/web-next/components/StoreBrowser.tsx +++ b/web-next/components/StoreBrowser.tsx @@ -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 + cart: Map onAdd: (it: StoreItem) => void }) { const t = useTranslations('Store') @@ -77,7 +77,7 @@ function CategoryNode({ {cat.items.length > 0 && (
{cat.items.map((it) => { - const added = cart.has(it.id) + const copies = cart.get(it.id)?.copies ?? 0 return (
{/* 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')}
) @@ -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(null) const [loading, setLoading] = useState(false) - const [cart, setCart] = useState>(new Map()) + const [cart, setCart] = useState>(new Map()) const [method, setMethod] = useState('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 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({ ) : ( lines.map((l) => ( - + - {l.name} + {l.it.name} + + + 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 && × {l.it.qty}} - {l.qty} - {l.currency === 'pv' ? 'PV' : 'PD'}: {l.price} + {l.it.currency === 'pv' ? 'PV' : 'PD'}:{' '} + {l.it.price * l.copies} - {formatEur(lineEur(l.price, l.currency), locale)} + {formatEur(lineEur(l.it.price, l.it.currency) * l.copies, locale)} - diff --git a/web-next/lib/store-pricing.ts b/web-next/lib/store-pricing.ts index 8b110b8..4a98545 100644 --- a/web-next/lib/store-pricing.ts +++ b/web-next/lib/store-pricing.ts @@ -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) diff --git a/web-next/lib/store.ts b/web-next/lib/store.ts index 8141060..d7c3afe 100644 --- a/web-next/lib/store.ts +++ b/web-next/lib/store.ts @@ -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 { - 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 { + const copiesById = new Map() + 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( @@ -152,11 +171,28 @@ export async function priceStoreCart(itemRowIds: number[]): Promise ({ 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 { - 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( diff --git a/web-next/messages/en.json b/web-next/messages/en.json index 511a4cc..511ef49 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -1944,6 +1944,7 @@ "quantity": "Quantity", "add": "Add", "added": "Added", + "addedCount": "Added ({n})", "cartTitle": "Cart", "selectedCharacter": "Selected character", "colItem": "Item", diff --git a/web-next/messages/es.json b/web-next/messages/es.json index 53acead..d22cd87 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -1944,6 +1944,7 @@ "quantity": "Cantidad", "add": "Añadir", "added": "Añadido", + "addedCount": "Añadido ({n})", "cartTitle": "Carrito", "selectedCharacter": "Personaje seleccionado", "colItem": "Objeto",