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:
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user