Permitir pagar los servicios con PD, Stripe o SumUp (elección)

Los 9 servicios con precio en euros (rename, customize, change-race,
change-faction, level-up, gold, transfer, restore-item, send-gift) ahora
ofrecen un selector de forma de pago con las 3 opciones: saldo PD, tarjeta
(Stripe) o tarjeta (SumUp).

- lib/dpoints: spendDPoints() descuenta PD de forma atómica (FOR UPDATE).
- lib/pay-with-dpoints: paga con saldo PD, ejecuta la acción al momento y
  reembolsa si la ejecución falla; 100 PD = 1 €.
- rutas checkout (character/[service] y gift): rama provider='pd' que valida
  saldo, descuenta, ejecuta fulfill y devuelve la URL de éxito (sin pasarela).
- service-success: rama provider='pd' (la entrega ya se hizo en el checkout).
- PaymentMethodSelect: componente compartido con coste por método y saldo;
  desactiva PD si no hay saldo suficiente.
- Formularios (PaidServiceForm, Gold, Transfer, RestoreItems, SendGift) y sus
  páginas pasan el saldo PD y envían el método elegido + locale.
- i18n: namespace Pay (es/en); añadidas claves Paid.restore-item que faltaban.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-14 21:44:31 +00:00
parent 942fe2e397
commit 1b0920d11f
24 changed files with 420 additions and 66 deletions
+14 -8
View File
@@ -1,24 +1,29 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { useTranslations, useLocale } from 'next-intl'
import type { GoldOption } from '@/lib/prices'
import { CharacterSelect, type CharOption } from '@/components/CharacterSelect'
import { PaymentMethodSelect, pdCostOf, type PayMethod } from '@/components/PaymentMethodSelect'
export function GoldForm({ characters, options }: { characters: CharOption[]; options: GoldOption[] }) {
export function GoldForm({ characters, options, pdBalance }: { characters: CharOption[]; options: GoldOption[]; pdBalance: number }) {
const t = useTranslations('Services')
const tp = useTranslations('Paid')
const tpay = useTranslations('Pay')
const locale = useLocale()
const [character, setCharacter] = useState('')
const [amount, setAmount] = useState('')
const [method, setMethod] = useState<PayMethod>('pd')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const price = options.find((o) => String(o.gold_amount) === amount)?.price ?? 0
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (busy || !character || !amount) return
const opt = options.find((o) => String(o.gold_amount) === amount)
const price = opt?.price ?? 0
if (!window.confirm(`¿Estás seguro de enviar ${amount} de oro a ${character} por ${price} € (SumUp)?`)) return
const payAmount = method === 'pd' ? tpay('pdCost', { cost: pdCostOf(price) }) : tpay('eur', { price })
if (!window.confirm(tpay('confirm', { amount: payAmount }))) return
setBusy(true)
setError(null)
try {
@@ -26,12 +31,12 @@ export function GoldForm({ characters, options }: { characters: CharOption[]; op
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ character, gold_amount: amount, provider: 'sumup' }),
body: JSON.stringify({ character, gold_amount: amount, provider: method, locale }),
})
const data: { success?: boolean; url?: string; message?: string } = await res.json()
const data: { success?: boolean; url?: string; message?: string; error?: string } = await res.json()
if (data.success && data.url) window.location.href = data.url
else {
setError(data.message || t('genericError'))
setError(data.message || (data.error && tpay.has(`errors.${data.error}`) ? tpay(`errors.${data.error}`) : t('genericError')))
setBusy(false)
}
} catch {
@@ -55,6 +60,7 @@ export function GoldForm({ characters, options }: { characters: CharOption[]; op
</option>
))}
</select>
{amount && <PaymentMethodSelect value={method} onChange={setMethod} priceEur={price} pdBalance={pdBalance} />}
<br />
<button type="submit" className="gold-button" disabled={busy || !character || !amount}>
{busy ? t('processing') : tp('gold.send')}
+22 -13
View File
@@ -1,29 +1,38 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { useTranslations, useLocale } from 'next-intl'
import { CharacterSelect, type CharOption } from '@/components/CharacterSelect'
import { PaymentMethodSelect, pdCostOf, type PayMethod } from '@/components/PaymentMethodSelect'
interface Props {
characters: CharOption[]
checkoutEndpoint: string
payLabel: string // ya formateado con el precio
payLabel: string // texto de acción del botón
priceEur: number // precio del servicio en euros (para calcular el coste en PD)
pdBalance: number // saldo PD de la cuenta
buttonClass?: string
provider?: 'stripe' | 'sumup' // pasarela; por defecto Stripe
confirmText?: string // si se pasa, pide confirmación antes de redirigir al pago
}
/** Selector de personaje + botón de pago. Redirige al Checkout (Stripe o SumUp). */
export function PaidServiceForm({ characters, checkoutEndpoint, payLabel, buttonClass = '', provider, confirmText }: Props) {
/** Selector de personaje + forma de pago (PD / Stripe / SumUp) + botón. */
export function PaidServiceForm({ characters, checkoutEndpoint, payLabel, priceEur, pdBalance, buttonClass = '' }: Props) {
const t = useTranslations('Services')
const tp = useTranslations('Pay')
const locale = useLocale()
const [character, setCharacter] = useState('')
const [method, setMethod] = useState<PayMethod>(pdBalance >= pdCostOf(priceEur) ? 'pd' : 'sumup')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
function msgFor(err?: string): string {
return err && tp.has(`errors.${err}`) ? tp(`errors.${err}`) : t('genericError')
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (busy || !character) return
if (confirmText && !window.confirm(confirmText)) return
const amount = method === 'pd' ? tp('pdCost', { cost: pdCostOf(priceEur) }) : tp('eur', { price: priceEur })
if (!window.confirm(tp('confirm', { amount }))) return
setBusy(true)
setError(null)
try {
@@ -31,13 +40,13 @@ export function PaidServiceForm({ characters, checkoutEndpoint, payLabel, button
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ character, ...(provider ? { provider } : {}) }),
body: JSON.stringify({ character, provider: method, locale }),
})
const data: { success?: boolean; url?: string; message?: string } = await res.json()
const data: { success?: boolean; url?: string; message?: string; error?: string } = await res.json()
if (data.success && data.url) {
window.location.href = data.url // Checkout (Stripe o SumUp)
window.location.href = data.url // éxito PD, o Checkout (Stripe/SumUp)
} else {
setError(data.message || t('genericError'))
setError(data.message || msgFor(data.error))
setBusy(false)
}
} catch {
@@ -54,9 +63,9 @@ export function PaidServiceForm({ characters, checkoutEndpoint, payLabel, button
<div className="centered">
<p>{t('choose')}</p>
<br />
<form onSubmit={handleSubmit} acceptCharset="utf-8">
<form onSubmit={handleSubmit} acceptCharset="utf-8" noValidate>
<CharacterSelect characters={characters} value={character} onChange={setCharacter} placeholder={t('selectCharacter')} />
<br />
<PaymentMethodSelect value={method} onChange={setMethod} priceEur={priceEur} pdBalance={pdBalance} />
<button type="submit" className={buttonClass} disabled={busy || !character}>
{busy ? t('processing') : payLabel}
</button>
@@ -0,0 +1,71 @@
'use client'
import { useTranslations } from 'next-intl'
export type PayMethod = 'pd' | 'stripe' | 'sumup'
// 100 PD = 1 € (igual que PD_PER_UNIT en lib/dpoints, que no se importa aquí
// para no arrastrar el acceso a BD al bundle del cliente).
const PD_PER_UNIT = 100
/** Coste en PD (redondeado) de un precio en euros. */
export function pdCostOf(priceEur: number): number {
return Math.round(priceEur * PD_PER_UNIT)
}
/**
* Selector de forma de pago: saldo PD, tarjeta (Stripe) o tarjeta (SumUp).
* Muestra el coste de cada método y desactiva PD si no hay saldo suficiente.
*/
export function PaymentMethodSelect({
value,
onChange,
priceEur,
pdBalance,
}: {
value: PayMethod
onChange: (m: PayMethod) => void
priceEur: number
pdBalance: number
}) {
const t = useTranslations('Pay')
const cost = pdCostOf(priceEur)
const canPd = pdBalance >= cost && cost > 0
const options: { id: PayMethod; label: string; sub: string; disabled?: boolean }[] = [
{
id: 'pd',
label: t('pd'),
sub: canPd ? t('pdCost', { cost }) : t('insufficient', { cost }),
disabled: !canPd,
},
{ id: 'stripe', label: t('stripe'), sub: t('eur', { price: priceEur }) },
{ id: 'sumup', label: t('sumup'), sub: t('eur', { price: priceEur }) },
]
return (
<div className="pay-method-select">
<p className="second-brown">{t('method')}</p>
<div className="pay-method-options">
{options.map((o) => (
<label
key={o.id}
className={`pay-method-option${value === o.id ? ' selected' : ''}${o.disabled ? ' disabled' : ''}`}
>
<input
type="radio"
name="pay-method"
value={o.id}
checked={value === o.id}
disabled={o.disabled}
onChange={() => onChange(o.id)}
/>
<span className="pay-method-label">{o.label}</span>
<span className={`pay-method-sub ${o.disabled ? 'red-form-response' : 'yellow-info'}`}>{o.sub}</span>
</label>
))}
</div>
<p className="third-brown pay-method-balance">{t('balance', { balance: pdBalance })}</p>
</div>
)
}
+13 -5
View File
@@ -1,8 +1,9 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { useTranslations, useLocale } from 'next-intl'
import { CharacterSelect, type CharOption } from '@/components/CharacterSelect'
import { PaymentMethodSelect, pdCostOf, type PayMethod } from '@/components/PaymentMethodSelect'
import { Turnstile } from '@/components/Turnstile'
const ITEM_DB = 'https://wotlk.novawow.com'
@@ -33,9 +34,12 @@ function itemsError(t: ReturnType<typeof useTranslations>, error?: string, minut
}
}
export function RestoreItemsForm({ characters, price }: { characters: CharOption[]; price: number }) {
export function RestoreItemsForm({ characters, price, pdBalance }: { characters: CharOption[]; price: number; pdBalance: number }) {
const t = useTranslations('CharService')
const tpay = useTranslations('Pay')
const locale = useLocale()
const [character, setCharacter] = useState('')
const [method, setMethod] = useState<PayMethod>(pdBalance >= pdCostOf(price) ? 'pd' : 'sumup')
const [captcha, setCaptcha] = useState('')
const [captchaKey, setCaptchaKey] = useState(0)
const [busy, setBusy] = useState(false)
@@ -80,14 +84,15 @@ export function RestoreItemsForm({ characters, price }: { characters: CharOption
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ character, recover_id: String(item.recoverId), provider: 'sumup' }),
body: JSON.stringify({ character, recover_id: String(item.recoverId), provider: method, locale }),
})
const data: { success?: boolean; url?: string; error?: string; message?: string } = await res.json()
if (data.success && data.url) {
window.location.assign(data.url) // checkout de SumUp
window.location.assign(data.url) // éxito PD, o checkout de SumUp/Stripe
return
}
setMessage({ ok: false, text: data.message || itemsError(t, data.error) })
const payErr = data.error && tpay.has(`errors.${data.error}`) ? tpay(`errors.${data.error}`) : undefined
setMessage({ ok: false, text: data.message || payErr || itemsError(t, data.error) })
setBusy(false)
} catch {
setMessage({ ok: false, text: itemsError(t) })
@@ -122,6 +127,8 @@ export function RestoreItemsForm({ characters, price }: { characters: CharOption
{items.length === 0 ? (
<p className="second-brown">{t('restoreItems.noDeletedItems')}</p>
) : (
<>
<PaymentMethodSelect value={method} onChange={setMethod} priceEur={price} pdBalance={pdBalance} />
<table className="restore-item-table">
<tbody>
{items.map((it) => (
@@ -139,6 +146,7 @@ export function RestoreItemsForm({ characters, price }: { characters: CharOption
))}
</tbody>
</table>
</>
)}
<br />
<p><a id="a-select-other" href="#" onClick={(e) => { e.preventDefault(); reset() }}>{t('restoreItems.queryOther')}</a></p>
+13 -4
View File
@@ -1,9 +1,10 @@
'use client'
import { useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useTranslations, useLocale } from 'next-intl'
import type { GiftCategory, GiftItem } from '@/lib/gift'
import { CharacterSelect, type CharOption } from '@/components/CharacterSelect'
import { PaymentMethodSelect, type PayMethod } from '@/components/PaymentMethodSelect'
// Tope de cantidad en la interfaz. El servidor lo revalida en priceCart (fuente de verdad).
const MAX_QTY = 100
@@ -49,16 +50,21 @@ export function SendGiftForm({
characters,
catalog,
currency = '€',
pdBalance = 0,
}: {
characters: CharOption[]
catalog: GiftCategory[]
currency?: string
pdBalance?: number
}) {
const t = useTranslations('CharService')
const tpay = useTranslations('Pay')
const locale = useLocale()
const [source, setSource] = useState('')
const [destination, setDestination] = useState('')
const [token, setToken] = useState('')
const [cart, setCart] = useState<Map<number, CartEntry>>(new Map())
const [method, setMethod] = useState<PayMethod>('pd')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -133,13 +139,15 @@ export function SendGiftForm({
destination: destination.trim(),
security_token: token.trim(),
cart: [...cart.values()].map((e) => ({ id: e.item.id, qty: e.qty })),
provider: 'sumup',
provider: method,
locale,
}),
})
const data: { success?: boolean; url?: string; message?: string } = await res.json()
const data: { success?: boolean; url?: string; message?: string; error?: string } = await res.json()
if (data.success && data.url) window.location.href = data.url
else {
setError(data.message || t('sendGift.errPayment'))
const payErr = data.error && tpay.has(`errors.${data.error}`) ? tpay(`errors.${data.error}`) : undefined
setError(data.message || payErr || t('sendGift.errPayment'))
setBusy(false)
}
} catch {
@@ -252,6 +260,7 @@ export function SendGiftForm({
<p>
{t.rich('sendGift.total', { total, currency, s: (c) => <span className="yellow-info">{c}</span> })}
</p>
{total > 0 && <PaymentMethodSelect value={method} onChange={setMethod} priceEur={total} pdBalance={pdBalance} />}
<br />
<button
type="submit"
+8 -1
View File
@@ -3,6 +3,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'
import { redirect } from '@/i18n/navigation'
import { getSession } from '@/lib/session'
import { getGameCharacters } from '@/lib/characters'
import { getDPointsBalance } from '@/lib/dpoints'
import { getPaidService } from '@/lib/paid-services'
import { PageShell } from '@/components/PageShell'
import { ServiceBox } from '@/components/ServiceBox'
@@ -19,7 +20,11 @@ export async function ServicePageContent({ service, locale }: { service: string;
if (!session.bnetId) redirect({ href: '/log-in', locale })
if (!session.username) redirect({ href: '/select-account', locale })
const [chars, price] = await Promise.all([getGameCharacters(session.accountId!), cfg!.price({})])
const [chars, price, pdBalance] = await Promise.all([
getGameCharacters(session.accountId!),
cfg!.price({}),
getDPointsBalance(session.accountId!),
])
return (
<PageShell title={t(`${service}.title`)}>
@@ -29,6 +34,8 @@ export async function ServicePageContent({ service, locale }: { service: string;
checkoutEndpoint={`/api/character/${service}/checkout`}
payLabel={t(`${service}.pay`, { price })}
buttonClass={`${service}-button`}
priceEur={price}
pdBalance={pdBalance}
/>
</ServiceBox>
</PageShell>
+11 -6
View File
@@ -1,8 +1,9 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { useTranslations, useLocale } from 'next-intl'
import { CharacterSelect, type CharOption } from '@/components/CharacterSelect'
import { PaymentMethodSelect, pdCostOf, type PayMethod } from '@/components/PaymentMethodSelect'
/** Campo con ojo para mostrar/ocultar (contraseña / token). */
function SecretInput({
@@ -42,12 +43,15 @@ function SecretInput({
)
}
export function TransferForm({ characters, price }: { characters: CharOption[]; price: number }) {
export function TransferForm({ characters, price, pdBalance }: { characters: CharOption[]; price: number; pdBalance: number }) {
const t = useTranslations('CharService')
const tpay = useTranslations('Pay')
const locale = useLocale()
const [character, setCharacter] = useState('')
const [destination, setDestination] = useState('')
const [password, setPassword] = useState('')
const [token, setToken] = useState('')
const [method, setMethod] = useState<PayMethod>(pdBalance >= pdCostOf(price) ? 'pd' : 'sumup')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -67,13 +71,14 @@ export function TransferForm({ characters, price }: { characters: CharOption[];
destination_account: destination.trim(),
password,
security_token: token.trim(),
provider: 'sumup',
provider: method,
locale,
}),
})
const data: { success?: boolean; url?: string; message?: string } = await res.json()
const data: { success?: boolean; url?: string; message?: string; error?: string } = await res.json()
if (data.success && data.url) window.location.href = data.url
else {
setError(data.message || t('transfer.errorGeneric'))
setError(data.message || (data.error && tpay.has(`errors.${data.error}`) ? tpay(`errors.${data.error}`) : t('transfer.errorGeneric')))
setBusy(false)
}
} catch {
@@ -94,7 +99,7 @@ export function TransferForm({ characters, price }: { characters: CharOption[];
<SecretInput id="password" value={password} onChange={setPassword} placeholder={t('transfer.passwordPlaceholder')} maxLength={16} toggleClass="toggle-password" />
<br />
<SecretInput id="security-token" value={token} onChange={setToken} placeholder={t('transfer.tokenPlaceholder')} maxLength={6} toggleClass="toggle-token" />
<br />
<PaymentMethodSelect value={method} onChange={setMethod} priceEur={price} pdBalance={pdBalance} />
<button type="submit" className="transfer-button" disabled={busy || !character || !destination.trim() || !password || !token.trim()}>
{busy ? t('transfer.submitting') : t('transfer.submit')}
</button>