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:
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1944,6 +1944,7 @@
|
||||
"quantity": "Quantity",
|
||||
"add": "Add",
|
||||
"added": "Added",
|
||||
"addedCount": "Added ({n})",
|
||||
"cartTitle": "Cart",
|
||||
"selectedCharacter": "Selected character",
|
||||
"colItem": "Item",
|
||||
|
||||
@@ -1944,6 +1944,7 @@
|
||||
"quantity": "Cantidad",
|
||||
"add": "Añadir",
|
||||
"added": "Añadido",
|
||||
"addedCount": "Añadido ({n})",
|
||||
"cartTitle": "Carrito",
|
||||
"selectedCharacter": "Personaje seleccionado",
|
||||
"colItem": "Objeto",
|
||||
|
||||
Reference in New Issue
Block a user