diff --git a/web-next/app/[locale]/change-faction-character/page.tsx b/web-next/app/[locale]/change-faction-character/page.tsx index d4e16cc..f1b7755 100644 --- a/web-next/app/[locale]/change-faction-character/page.tsx +++ b/web-next/app/[locale]/change-faction-character/page.tsx @@ -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 { getChangeFactionPrice } from '@/lib/prices' import { PageShell } from '@/components/PageShell' import { ServiceBox } from '@/components/ServiceBox' @@ -36,7 +37,11 @@ export default async function ChangeFactionCharacterPage({ params }: { params: P 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!), getChangeFactionPrice()]) + const [chars, price, pdBalance] = await Promise.all([ + getGameCharacters(session.accountId!), + getChangeFactionPrice(), + getDPointsBalance(session.accountId!), + ]) const t = await getTranslations('CharServiceB.changeFaction') @@ -95,8 +100,8 @@ export default async function ChangeFactionCharacterPage({ params }: { params: P checkoutEndpoint="/api/character/change-faction/checkout" payLabel={t('payLabel')} buttonClass="change-faction-button" - provider="sumup" - confirmText={t('confirm', { price })} + priceEur={price} + pdBalance={pdBalance} /> diff --git a/web-next/app/[locale]/change-race-character/page.tsx b/web-next/app/[locale]/change-race-character/page.tsx index 9a8a322..168fd2c 100644 --- a/web-next/app/[locale]/change-race-character/page.tsx +++ b/web-next/app/[locale]/change-race-character/page.tsx @@ -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 { getChangeRacePrice } from '@/lib/prices' import { PageShell } from '@/components/PageShell' import { ServiceBox } from '@/components/ServiceBox' @@ -24,7 +25,11 @@ export default async function ChangeRaceCharacterPage({ params }: { params: Prom 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!), getChangeRacePrice()]) + const [chars, price, pdBalance] = await Promise.all([ + getGameCharacters(session.accountId!), + getChangeRacePrice(), + getDPointsBalance(session.accountId!), + ]) const t = await getTranslations('CharServiceB.changeRace') @@ -61,8 +66,8 @@ export default async function ChangeRaceCharacterPage({ params }: { params: Prom checkoutEndpoint="/api/character/change-race/checkout" payLabel={t('payLabel')} buttonClass="change-race-button" - provider="sumup" - confirmText={t('confirm', { price })} + priceEur={price} + pdBalance={pdBalance} /> diff --git a/web-next/app/[locale]/customize-character/page.tsx b/web-next/app/[locale]/customize-character/page.tsx index f0e26d8..d6c71ba 100644 --- a/web-next/app/[locale]/customize-character/page.tsx +++ b/web-next/app/[locale]/customize-character/page.tsx @@ -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 { getCustomizePrice } from '@/lib/prices' import { PageShell } from '@/components/PageShell' import { ServiceBox } from '@/components/ServiceBox' @@ -24,7 +25,11 @@ export default async function CustomizeCharacterPage({ params }: { params: Promi 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!), getCustomizePrice()]) + const [chars, price, pdBalance] = await Promise.all([ + getGameCharacters(session.accountId!), + getCustomizePrice(), + getDPointsBalance(session.accountId!), + ]) const t = await getTranslations('CharServiceB.customize') @@ -62,8 +67,8 @@ export default async function CustomizeCharacterPage({ params }: { params: Promi checkoutEndpoint="/api/character/customize/checkout" payLabel={t('payLabel')} buttonClass="customize-button" - provider="sumup" - confirmText={t('confirm', { price })} + priceEur={price} + pdBalance={pdBalance} /> diff --git a/web-next/app/[locale]/gold-character/page.tsx b/web-next/app/[locale]/gold-character/page.tsx index 89b3888..9247ef7 100644 --- a/web-next/app/[locale]/gold-character/page.tsx +++ b/web-next/app/[locale]/gold-character/page.tsx @@ -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 { getGoldOptions } from '@/lib/prices' import { PageShell } from '@/components/PageShell' import { ServiceBox } from '@/components/ServiceBox' @@ -26,7 +27,11 @@ export default async function GoldCharacterPage({ params }: { params: Promise<{ if (!session.bnetId) redirect({ href: '/log-in', locale }) if (!session.username) redirect({ href: '/select-account', locale }) - const [chars, options] = await Promise.all([getGameCharacters(session.accountId!), getGoldOptions()]) + const [chars, options, pdBalance] = await Promise.all([ + getGameCharacters(session.accountId!), + getGoldOptions(), + getDPointsBalance(session.accountId!), + ]) const t = await getTranslations('CharServiceB.gold') @@ -72,7 +77,7 @@ export default async function GoldCharacterPage({ params }: { params: Promise<{
- ({ name: c.name, classCss: c.classCss }))} options={options} /> + ({ name: c.name, classCss: c.classCss }))} options={options} pdBalance={pdBalance} /> ) diff --git a/web-next/app/[locale]/level-up-character/page.tsx b/web-next/app/[locale]/level-up-character/page.tsx index c47f244..6524a1b 100644 --- a/web-next/app/[locale]/level-up-character/page.tsx +++ b/web-next/app/[locale]/level-up-character/page.tsx @@ -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 { getLevelUpPrice } from '@/lib/prices' import { PageShell } from '@/components/PageShell' import { ServiceBox } from '@/components/ServiceBox' @@ -24,7 +25,11 @@ export default async function LevelUpCharacterPage({ params }: { params: Promise 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!), getLevelUpPrice()]) + const [chars, price, pdBalance] = await Promise.all([ + getGameCharacters(session.accountId!), + getLevelUpPrice(), + getDPointsBalance(session.accountId!), + ]) const t = await getTranslations('CharServiceB.levelUp') @@ -57,8 +62,8 @@ export default async function LevelUpCharacterPage({ params }: { params: Promise checkoutEndpoint="/api/character/level-up/checkout" payLabel={t('payLabel')} buttonClass="level-up-button" - provider="sumup" - confirmText={t('confirm', { price })} + priceEur={price} + pdBalance={pdBalance} /> diff --git a/web-next/app/[locale]/rename-character/page.tsx b/web-next/app/[locale]/rename-character/page.tsx index 01468f2..fa96f19 100644 --- a/web-next/app/[locale]/rename-character/page.tsx +++ b/web-next/app/[locale]/rename-character/page.tsx @@ -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 { getRenamePrice } from '@/lib/prices' import { PageShell } from '@/components/PageShell' import { ServiceBox } from '@/components/ServiceBox' @@ -24,7 +25,11 @@ export default async function RenameCharacterPage({ params }: { params: Promise< 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!), getRenamePrice()]) + const [chars, price, pdBalance] = await Promise.all([ + getGameCharacters(session.accountId!), + getRenamePrice(), + getDPointsBalance(session.accountId!), + ]) const t = await getTranslations('CharServiceB.rename') @@ -55,8 +60,8 @@ export default async function RenameCharacterPage({ params }: { params: Promise< checkoutEndpoint="/api/character/rename/checkout" payLabel={t('payLabel')} buttonClass="rename-button" - provider="sumup" - confirmText={t('confirm', { price })} + priceEur={price} + pdBalance={pdBalance} /> diff --git a/web-next/app/[locale]/restore-items/page.tsx b/web-next/app/[locale]/restore-items/page.tsx index fb03265..5c01717 100644 --- a/web-next/app/[locale]/restore-items/page.tsx +++ b/web-next/app/[locale]/restore-items/page.tsx @@ -4,6 +4,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 { getRestoreItemPrice } from '@/lib/prices' import { PageShell } from '@/components/PageShell' import { ServiceBox } from '@/components/ServiceBox' @@ -26,7 +27,11 @@ export default async function RestoreItemsPage({ params }: { params: Promise<{ l 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!), getRestoreItemPrice()]) + const [chars, price, pdBalance] = await Promise.all([ + getGameCharacters(session.accountId!), + getRestoreItemPrice(), + getDPointsBalance(session.accountId!), + ]) return ( @@ -63,7 +68,7 @@ export default async function RestoreItemsPage({ params }: { params: Promise<{ l

{t.rich('restoreItems.requires', { price, s: (c) => {c}, e: (c) => {c} })}


- ({ name: c.name, classCss: c.classCss }))} price={price} /> + ({ name: c.name, classCss: c.classCss }))} price={price} pdBalance={pdBalance} />
diff --git a/web-next/app/[locale]/send-gift/page.tsx b/web-next/app/[locale]/send-gift/page.tsx index 970f70a..9478c67 100644 --- a/web-next/app/[locale]/send-gift/page.tsx +++ b/web-next/app/[locale]/send-gift/page.tsx @@ -4,6 +4,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server' import { redirect, Link } from '@/i18n/navigation' import { getSession } from '@/lib/session' import { getGameCharacters } from '@/lib/characters' +import { getDPointsBalance } from '@/lib/dpoints' import { getGiftCatalog } from '@/lib/gift' import { PageShell } from '@/components/PageShell' import { ServiceBox } from '@/components/ServiceBox' @@ -26,7 +27,11 @@ export default async function SendGiftPage({ params }: { params: Promise<{ local if (!session.bnetId) redirect({ href: '/log-in', locale }) if (!session.username) redirect({ href: '/select-account', locale }) - const [chars, catalog] = await Promise.all([getGameCharacters(session.accountId!), getGiftCatalog()]) + const [chars, catalog, pdBalance] = await Promise.all([ + getGameCharacters(session.accountId!), + getGiftCatalog(), + getDPointsBalance(session.accountId!), + ]) const currency = process.env.SUMUP_CURRENCY === 'EUR' ? '€' : process.env.SUMUP_CURRENCY || '€' return ( @@ -64,6 +69,7 @@ export default async function SendGiftPage({ params }: { params: Promise<{ local characters={chars.map((c) => ({ name: c.name, classCss: c.classCss }))} catalog={catalog} currency={currency} + pdBalance={pdBalance} /> diff --git a/web-next/app/[locale]/service-success/page.tsx b/web-next/app/[locale]/service-success/page.tsx index 2608a24..a25a33d 100644 --- a/web-next/app/[locale]/service-success/page.tsx +++ b/web-next/app/[locale]/service-success/page.tsx @@ -11,18 +11,22 @@ export default async function ServiceSuccessPage({ searchParams, }: { params: Promise<{ locale: string }> - searchParams: Promise<{ service?: string; session_id?: string; provider?: string; ref?: string }> + searchParams: Promise<{ service?: string; session_id?: string; provider?: string; ref?: string; name?: string }> }) { const { locale } = await params setRequestLocale(locale) - const { service: urlService, session_id, provider, ref } = await searchParams + const { service: urlService, session_id, provider, ref, name } = await searchParams const t = await getTranslations('Paid') // Entrega compartida con el webhook/reconciliación (idempotente vía reclamo atómico). let status: 'delivered' | 'already' | 'error' = 'error' let okName: string | null = null let service = urlService ?? null - if (provider === 'sumup' && ref) { + if (provider === 'pd') { + // Pago con saldo PD: la entrega ya se ejecutó en el checkout; aquí solo se muestra. + status = 'delivered' + okName = name ?? null + } else if (provider === 'sumup' && ref) { const r = await fulfillSumUpCheckout(ref) service = r.service ?? urlService ?? null okName = r.character ?? null diff --git a/web-next/app/[locale]/transfer-character/page.tsx b/web-next/app/[locale]/transfer-character/page.tsx index 6e1d811..81a9b9e 100644 --- a/web-next/app/[locale]/transfer-character/page.tsx +++ b/web-next/app/[locale]/transfer-character/page.tsx @@ -4,6 +4,7 @@ import { getTranslations, setRequestLocale } from 'next-intl/server' import { redirect, Link } from '@/i18n/navigation' import { getSession } from '@/lib/session' import { getGameCharacters } from '@/lib/characters' +import { getDPointsBalance } from '@/lib/dpoints' import { getTransferPrice } from '@/lib/prices' import { PageShell } from '@/components/PageShell' import { ServiceBox } from '@/components/ServiceBox' @@ -26,7 +27,11 @@ export default async function TransferCharacterPage({ params }: { params: Promis 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!), getTransferPrice()]) + const [chars, price, pdBalance] = await Promise.all([ + getGameCharacters(session.accountId!), + getTransferPrice(), + getDPointsBalance(session.accountId!), + ]) return ( @@ -66,7 +71,7 @@ export default async function TransferCharacterPage({ params }: { params: Promis

{t.rich('transfer.requires', { price, s: (c) => {c}, e: (c) => {c} })}

- ({ name: c.name, classCss: c.classCss }))} price={price} /> + ({ name: c.name, classCss: c.classCss }))} price={price} pdBalance={pdBalance} />
) diff --git a/web-next/app/api/character/[service]/checkout/route.ts b/web-next/app/api/character/[service]/checkout/route.ts index ffa393f..91581b9 100644 --- a/web-next/app/api/character/[service]/checkout/route.ts +++ b/web-next/app/api/character/[service]/checkout/route.ts @@ -4,6 +4,12 @@ import { getGameCharacters } from '@/lib/characters' import { createCheckoutSession } from '@/lib/stripe' import { createSumUpCheckout, sumupConfigured } from '@/lib/sumup' import { getPaidService } from '@/lib/paid-services' +import { payServiceWithDPoints } from '@/lib/pay-with-dpoints' + +/** Locale seguro (es/en) para construir la URL de retorno. */ +function safeLocale(v: unknown): string { + return v === 'en' ? 'en' : 'es' +} export async function POST(request: Request, { params }: { params: Promise<{ service: string }> }) { const { service } = await params @@ -49,6 +55,19 @@ export async function POST(request: Request, { params }: { params: Promise<{ ser const ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() || '0.0.0.0' const productName = cfg.productName(character, metadata) + // Pago con saldo PD: se descuenta el saldo y se ejecuta la acción al momento + // (sin pasarela). Devuelve la URL de éxito para que el form redirija igual que + // con Stripe/SumUp; la entrega ya se ha realizado aquí. + if (String(body.provider) === 'pd') { + const r = await payServiceWithDPoints(session.accountId, price, () => cfg.fulfill(character, metadata)) + if (!r.success) return Response.json({ success: false, error: r.error }) + const locale = safeLocale(body.locale) + return Response.json({ + success: true, + url: `${site}/${locale}/service-success?service=${service}&provider=pd&name=${encodeURIComponent(character)}`, + }) + } + // Pago por SumUp (euros): al pagar, la reconciliación/return ejecuta la acción // del servicio sobre el personaje (no acredita PD). if (String(body.provider) === 'sumup') { diff --git a/web-next/app/api/gift/checkout/route.ts b/web-next/app/api/gift/checkout/route.ts index 001a675..7b004ae 100644 --- a/web-next/app/api/gift/checkout/route.ts +++ b/web-next/app/api/gift/checkout/route.ts @@ -3,7 +3,8 @@ import { getSession } from '@/lib/session' import { getGameCharacters, getAccountIdByCharacterName } from '@/lib/characters' import { checkSecurityToken } from '@/lib/security-token' import { createSumUpCheckout, sumupConfigured } from '@/lib/sumup' -import { priceCart, isValidCharName, type CartLine } from '@/lib/gift' +import { priceCart, isValidCharName, sendGiftByMail, type CartLine } from '@/lib/gift' +import { payServiceWithDPoints } from '@/lib/pay-with-dpoints' /** * Inicia el pago (SumUp) de un regalo. Condiciones: @@ -18,7 +19,7 @@ export async function POST(request: Request) { const session = await getSession() if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 }) - let body: { source?: string; destination?: string; cart?: CartLine[]; security_token?: string; provider?: string } = {} + let body: { source?: string; destination?: string; cart?: CartLine[]; security_token?: string; provider?: string; locale?: string } = {} try { body = await request.json() } catch { @@ -58,14 +59,28 @@ export async function POST(request: Request) { return Response.json({ success: false, message: 'Tu carrito está vacío o contiene objetos no válidos.' }) } + const site = process.env.SITE_URL || '' + const items = priced.lines.map((l) => ({ i: l.itemId, q: l.qty })) + + // Pago con saldo PD: se descuenta el saldo y se envía el regalo al momento. + if (String(body.provider) === 'pd') { + const r = await payServiceWithDPoints(session.accountId, priced.total, () => + sendGiftByMail(destination, source, items), + ) + if (!r.success) return Response.json({ success: false, error: r.error, message: 'No se pudo completar el pago con PD.' }) + const locale = String(body.locale) === 'en' ? 'en' : 'es' + return Response.json({ + success: true, + url: `${site}/${locale}/service-success?service=send-gift&provider=pd&name=${encodeURIComponent(destination)}`, + }) + } + if (String(body.provider) !== 'sumup' || !sumupConfigured()) { return Response.json({ success: false, error: 'notConfigured', message: 'El método de pago no está disponible ahora mismo.' }) } - const site = process.env.SITE_URL || '' const ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() || '0.0.0.0' const reference = randomUUID() - const items = priced.lines.map((l) => ({ i: l.itemId, q: l.qty })) const description = `Regalo para ${destination} (${priced.lines.length} objeto${priced.lines.length === 1 ? '' : 's'})` const r = await createSumUpCheckout({ diff --git a/web-next/app/globals.css b/web-next/app/globals.css index 65fa7cb..f9c9209 100644 --- a/web-next/app/globals.css +++ b/web-next/app/globals.css @@ -482,3 +482,37 @@ textarea:focus { .twofa-login-info { display: flex; flex-direction: column-reverse; align-items: center; } .twofa-login-preview { float: none; display: block; clear: both; margin: 10px auto 8px auto; max-width: 90vw; } } + +/* ==== Selector de forma de pago (PD / Stripe / SumUp) ==== */ +.pay-method-select { margin: 10px auto 4px auto; max-width: 560px; } +.pay-method-options { + display: flex; + gap: 10px; + justify-content: center; + flex-wrap: wrap; + margin: 8px 0; +} +.pay-method-option { + flex: 1 1 150px; + min-width: 140px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 12px 10px; + cursor: pointer; + border: 2px solid #352e2b; + border-radius: 8px; + background: rgba(0, 0, 0, 0.25); + transition: border-color .25s, background .25s; +} +.pay-method-option:hover { border-color: #6b5a4e; } +.pay-method-option.selected { + border-color: #d79602; + background: rgba(215, 150, 2, 0.10); +} +.pay-method-option.disabled { cursor: not-allowed; opacity: .55; } +.pay-method-option input[type='radio'] { accent-color: #d79602; } +.pay-method-label { color: #ebdec2; font-weight: bold; } +.pay-method-sub { font-size: 13px; } +.pay-method-balance { margin-top: 2px; font-size: 13px; } diff --git a/web-next/components/GoldForm.tsx b/web-next/components/GoldForm.tsx index 5588d90..f77c30c 100644 --- a/web-next/components/GoldForm.tsx +++ b/web-next/components/GoldForm.tsx @@ -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('pd') const [busy, setBusy] = useState(false) const [error, setError] = useState(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 ))} + {amount && }
diff --git a/web-next/components/PaymentMethodSelect.tsx b/web-next/components/PaymentMethodSelect.tsx new file mode 100644 index 0000000..0cd88f8 --- /dev/null +++ b/web-next/components/PaymentMethodSelect.tsx @@ -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 ( +
+

{t('method')}

+
+ {options.map((o) => ( + + ))} +
+

{t('balance', { balance: pdBalance })}

+
+ ) +} diff --git a/web-next/components/RestoreItemsForm.tsx b/web-next/components/RestoreItemsForm.tsx index a7786f2..5c03cc9 100644 --- a/web-next/components/RestoreItemsForm.tsx +++ b/web-next/components/RestoreItemsForm.tsx @@ -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, 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(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 ? (

{t('restoreItems.noDeletedItems')}

) : ( + <> + {items.map((it) => ( @@ -139,6 +146,7 @@ export function RestoreItemsForm({ characters, price }: { characters: CharOption ))}
+ )}

{ e.preventDefault(); reset() }}>{t('restoreItems.queryOther')}

diff --git a/web-next/components/SendGiftForm.tsx b/web-next/components/SendGiftForm.tsx index 349005a..25bea02 100644 --- a/web-next/components/SendGiftForm.tsx +++ b/web-next/components/SendGiftForm.tsx @@ -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>(new Map()) + const [method, setMethod] = useState('pd') const [busy, setBusy] = useState(false) const [error, setError] = useState(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({

{t.rich('sendGift.total', { total, currency, s: (c) => {c} })}

+ {total > 0 && }
diff --git a/web-next/lib/dpoints.ts b/web-next/lib/dpoints.ts index b57baeb..5815e0a 100644 --- a/web-next/lib/dpoints.ts +++ b/web-next/lib/dpoints.ts @@ -85,3 +85,43 @@ export async function transferDPoints( conn.release() } } + +/** + * Descuenta `points` PD del saldo de una cuenta de forma atómica (bloqueo de + * fila para evitar dobles gastos concurrentes). Se usa para pagar servicios con + * el saldo PD. Devuelve `insufficientFunds` si no hay saldo suficiente. + * Si la acción posterior falla, reembolsa con `creditDPoints`. + */ +export async function spendDPoints( + accountId: number, + points: number, +): Promise<{ success: boolean; error?: string }> { + if (!accountId || !Number.isInteger(points) || points <= 0) { + return { success: false, error: 'invalidRequest' } + } + const conn = await db(DB.default).getConnection() + try { + await conn.beginTransaction() + const [src] = await conn.query( + 'SELECT dp FROM home_api_points WHERE accountID = ? FOR UPDATE', + [accountId], + ) + const balance = src[0] ? Number(src[0].dp) : 0 + if (balance < points) { + await conn.rollback() + return { success: false, error: 'insufficientFunds' } + } + await conn.query('UPDATE home_api_points SET dp = dp - ? WHERE accountID = ?', [points, accountId]) + await conn.commit() + return { success: true } + } catch { + try { + await conn.rollback() + } catch { + /* ignore */ + } + return { success: false, error: 'genericError' } + } finally { + conn.release() + } +} diff --git a/web-next/lib/pay-with-dpoints.ts b/web-next/lib/pay-with-dpoints.ts new file mode 100644 index 0000000..0463edd --- /dev/null +++ b/web-next/lib/pay-with-dpoints.ts @@ -0,0 +1,35 @@ +import { spendDPoints, creditDPoints, PD_PER_UNIT } from './dpoints' + +/** Coste en PD de un precio en euros (100 PD = 1 €). */ +export function dpointsCost(priceEur: number): number { + return Math.round(priceEur * PD_PER_UNIT) +} + +/** + * Paga un servicio con el saldo PD: descuenta el coste de forma atómica y + * ejecuta la acción al momento (sin pasarela). Si la ejecución falla, reembolsa + * el saldo. Devuelve `insufficientPd` si no hay saldo suficiente. + */ +export async function payServiceWithDPoints( + accountId: number, + priceEur: number, + fulfill: () => Promise, +): Promise<{ success: boolean; error?: string }> { + const cost = dpointsCost(priceEur) + if (!(cost > 0)) return { success: false, error: 'invalidRequest' } + const spent = await spendDPoints(accountId, cost) + if (!spent.success) { + return { success: false, error: spent.error === 'insufficientFunds' ? 'insufficientPd' : 'genericError' } + } + let ok = false + try { + ok = await fulfill() + } catch { + ok = false + } + if (!ok) { + await creditDPoints(accountId, cost) // reembolso si no se pudo ejecutar + return { success: false, error: 'fulfillFailed' } + } + return { success: true } +} diff --git a/web-next/messages/en.json b/web-next/messages/en.json index 060fa64..216b57d 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -354,6 +354,11 @@ "send-gift": { "title": "Send gift", "success": "The gift has been mailed to character {name}." + }, + "restore-item": { + "title": "Restore item", + "pay": "Restore for {price} €", + "success": "The item has been returned to character {name}." } }, "SecurityToken": { @@ -1894,5 +1899,23 @@ "disabling": "Disabling...", "disable": "Disable 2FA" } + }, + "Pay": { + "method": "Payment method", + "pd": "PD balance", + "stripe": "Card (Stripe)", + "sumup": "Card (SumUp)", + "pdCost": "{cost} PD", + "eur": "{price} €", + "balance": "Available balance: {balance} PD", + "insufficient": "Insufficient balance ({cost} PD)", + "confirm": "Confirm the payment of {amount}?", + "errors": { + "insufficientPd": "You don't have enough PD balance for this service.", + "fulfillFailed": "The service could not be completed. Your PD balance has been refunded.", + "notConfigured": "This payment method is not available right now.", + "invalidRequest": "Invalid request.", + "genericError": "An error occurred. Please try again." + } } } diff --git a/web-next/messages/es.json b/web-next/messages/es.json index 64b30c2..6fe0549 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -354,6 +354,11 @@ "send-gift": { "title": "Enviar regalo", "success": "El regalo ha sido enviado por correo al personaje {name}." + }, + "restore-item": { + "title": "Recuperar ítem", + "pay": "Recuperar por {price} €", + "success": "El ítem ha sido devuelto al personaje {name}." } }, "SecurityToken": { @@ -1894,5 +1899,23 @@ "disabling": "Desactivando...", "disable": "Desactivar 2FA" } + }, + "Pay": { + "method": "Forma de pago", + "pd": "Saldo PD", + "stripe": "Tarjeta (Stripe)", + "sumup": "Tarjeta (SumUp)", + "pdCost": "{cost} PD", + "eur": "{price} €", + "balance": "Saldo disponible: {balance} PD", + "insufficient": "Saldo insuficiente ({cost} PD)", + "confirm": "¿Confirmas el pago de {amount}?", + "errors": { + "insufficientPd": "No tienes saldo PD suficiente para este servicio.", + "fulfillFailed": "No se pudo completar el servicio. Se te ha devuelto el saldo PD.", + "notConfigured": "Esta forma de pago no está disponible ahora mismo.", + "invalidRequest": "Solicitud no válida.", + "genericError": "Ha ocurrido un error. Inténtalo de nuevo." + } } }