diff --git a/web-next/app/api/store/send/route.ts b/web-next/app/api/store/send/route.ts index a25ea76..dab1eb7 100644 --- a/web-next/app/api/store/send/route.ts +++ b/web-next/app/api/store/send/route.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'crypto' import { getSession } from '@/lib/session' import { getGameCharacters } from '@/lib/characters' import { priceStoreCart, purchaseStoreCart, storeEuroTotal, createStoreOrder } from '@/lib/store' +import { CARD_MIN_EUR } from '@/lib/store-pricing' import { createCheckoutSession } from '@/lib/stripe' import { createSumUpCheckout, sumupConfigured } from '@/lib/sumup' @@ -43,7 +44,7 @@ export async function POST(request: Request) { // Pago con tarjeta: guarda el pedido y crea el checkout de la pasarela. if (provider === 'stripe' || provider === 'sumup') { const amount = storeEuroTotal(cart.pdTotal, cart.vpTotal) - const minEur = provider === 'sumup' ? 1 : 0.5 + const minEur = CARD_MIN_EUR[provider] if (amount < minEur) return Response.json({ success: false, error: 'amountTooLow', min: minEur }) const ref = randomUUID() diff --git a/web-next/app/globals.css b/web-next/app/globals.css index de034d9..2cc1f20 100644 --- a/web-next/app/globals.css +++ b/web-next/app/globals.css @@ -590,6 +590,16 @@ textarea:focus { es lo que separa las filas), .icon-td, .width-50-td y .justified del original. No se pisan: el `border-collapse` que había aquí anulaba ese border-spacing. */ +/* 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. */ +.store-price { display: block; transition: opacity .2s, color .2s; } +.store-price-eur { color: #ebdec2; } +.store-price-total { font-weight: bold; } +.store-price-off { opacity: .4; } +.store-price-off .dp-color, +.store-price-off .vp-color { color: inherit; } + /* Modal de resultado */ .modal-div { position: fixed; inset: 0; z-index: 1000; diff --git a/web-next/components/StoreBrowser.tsx b/web-next/components/StoreBrowser.tsx index 23a13d5..f7b66c4 100644 --- a/web-next/components/StoreBrowser.tsx +++ b/web-next/components/StoreBrowser.tsx @@ -5,6 +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 type { StoreCategory, StoreItem } from '@/lib/store' const ICON_FALLBACK = 'inv_misc_questionmark' @@ -12,6 +13,23 @@ const ICON_FALLBACK = 'inv_misc_questionmark' /** Renders de los ítems de la tienda (public/), para el preview en hover. */ const STORE_DISPLAY_PATH = '/nw-themes/nw-ryu/nw-images/nw-displays' +/** + * Importe en euros en el idioma de la web ("2,00 €", "0,125 €"). + * + * Mínimo 2 decimales porque es dinero ("1,50 €", no "1,5 €"). Y hasta 3 a + * propósito: un ítem en PV con precio impar cuesta medio céntimo (25 PV = + * 0,125 €), así que redondear cada línea a 2 haría que las líneas no sumaran el + * total y el carrito se contradiría. El total sí va redondeado a céntimos, + * porque es el importe real que se cobra. + */ +function eurNumber(n: number, locale: string): string { + return new Intl.NumberFormat(locale, { minimumFractionDigits: 2, maximumFractionDigits: 3 }).format(n) +} + +function formatEur(n: number, locale: string): string { + return `${eurNumber(n, locale)} €` +} + /** * Reaplica el tratamiento de wowhead (con `colorLinks` = teñir por calidad) a los * enlaces que React acaba de montar. `tooltips.js` solo recorre el DOM al cargar, @@ -178,14 +196,36 @@ export function StoreBrowser({ const qtyTotal = lines.reduce((s, l) => s + l.qty, 0) // Clase de color del personaje elegido, para pintarlo como el original. const charClass = characters.find((c) => c.name === character)?.classCss ?? '' - // 100 PD = 1 €, 200 PV = 1 € (igual que en lib/store storeEuroTotal). - const eurTotal = Math.round((pdTotal / 100 + vpTotal / 200) * 100) / 100 + // El mismo cálculo que valida y cobra /api/store/send (módulo compartido). + const eurTotal = storeEuroTotal(pdTotal, vpTotal) + // Por debajo del mínimo de la pasarela, su API rechaza el cobro: se desactiva + // la opción y se dice por qué, en vez de dejar que falle al pulsar Enviar. + const cardTooLow = (m: PayMethod) => (m === 'stripe' || m === 'sumup') && eurTotal < CARD_MIN_EUR[m] + // Método REAL, derivado en el render: si el carrito baja del mínimo con una + // tarjeta ya elegida, esa opción se desactiva y se cae al saldo. Derivado y no + // un setState en un efecto, que provocaría renders en cascada. + const activeMethod: PayMethod = cardTooLow(method) ? 'balance' : method + // Con tarjeta se paga en euros y con saldo en PD/PV: se muestran siempre los + // dos, y se apaga el que no se va a cobrar para que no haya duda. + const payingCard = activeMethod === 'stripe' || activeMethod === 'sumup' + + const payOptions: { id: PayMethod; label: string; sub: string; disabled?: boolean }[] = [ + { id: 'balance', label: t('payBalance'), sub: `${pdTotal} PD · ${vpTotal} PV` }, + ...(['stripe', 'sumup'] as const).map((id) => ({ + id: id as PayMethod, + label: t(id === 'stripe' ? 'payStripe' : 'paySumUp'), + sub: cardTooLow(id) + ? t('minCard', { min: eurNumber(CARD_MIN_EUR[id], locale) }) + : formatEur(eurTotal, locale), + disabled: cardTooLow(id), + })), + ] async function send() { if (sending || cart.size === 0) return - const card = method === 'stripe' || method === 'sumup' + const card = payingCard const ok = card - ? window.confirm(t('confirmCard', { eur: eurTotal, character })) + ? window.confirm(t('confirmCard', { eur: eurNumber(eurTotal, locale), character })) : window.confirm(t('confirm', { character, pd: pdTotal, vp: vpTotal })) if (!ok) return setSending(true) @@ -194,18 +234,20 @@ export function StoreBrowser({ method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', - body: JSON.stringify({ character, items: [...cart.keys()], provider: method, locale }), + body: JSON.stringify({ character, items: [...cart.keys()], provider: activeMethod, locale }), }) const data: { success?: boolean; error?: string; url?: string; min?: number } = await res.json() if (data.success && data.url) { - window.location.href = data.url // pasarela (Stripe/SumUp) + // .assign() y no `location.href = …`: es equivalente, pero la regla del + // compilador de React prohíbe asignar a algo definido fuera del componente. + window.location.assign(data.url) // pasarela (Stripe/SumUp) return } if (data.success) { setModal({ ok: true, text: t('successMsg', { character }) }) setCart(new Map()) } else if (data.error === 'amountTooLow') { - setModal({ ok: false, text: t('errors.amountTooLow', { min: data.min ?? 1 }) }) + setModal({ ok: false, text: t('errors.amountTooLow', { min: eurNumber(data.min ?? 1, locale) }) }) } else { setModal({ ok: false, text: t.has(`errors.${data.error}`) ? t(`errors.${data.error}`) : t('errors.generic') }) } @@ -253,13 +295,19 @@ export function StoreBrowser({

{t('payMethod')}

- {([ - { id: 'balance' as PayMethod, label: t('payBalance'), sub: `${pdTotal} PD · ${vpTotal} PV` }, - { id: 'stripe' as PayMethod, label: t('payStripe'), sub: `${eurTotal} €` }, - { id: 'sumup' as PayMethod, label: t('paySumUp'), sub: `${eurTotal} €` }, - ]).map((o) => ( -