From 87f9b0b003dce58493438a9323fa525b83ba6ab9 Mon Sep 17 00:00:00 2001 From: adevopg Date: Sun, 12 Jul 2026 23:25:57 +0000 Subject: [PATCH] =?UTF-8?q?Generaliza=20los=20servicios=20de=20personaje?= =?UTF-8?q?=20de=20pago=20(5)=20con=20infra=20com=C3=BAn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/paid-services.ts: config PAID_SERVICES (rename/customize/change-race/ change-faction/level-up) con comando SOAP, precio y productName por servicio. - app/api/character/[service]/checkout: ruta de checkout GENÉRICA (valida servicio + personaje, crea la sesión Stripe con successUrl /service-success?service=...). - app/[locale]/service-success: página de éxito ÚNICA que verifica el pago (claimPaidCheckout) y ejecuta el comando SOAP del servicio. - components/ServicePageContent: contenido común (guardas + PaidServiceForm). - 5 páginas mínimas /rename /customize /change-race /change-faction /level-up. - Catálogo Paid (por servicio: title/pay/success) sustituye a Rename. Verificado: 5 páginas redirigen a login, checkout 401 sin sesión, servicio inválido 404, service-success con pago inválido no entrega (muestra error). Pendiente: gold (cantidad) y transfer (destino+token), variantes del patrón. Co-Authored-By: Claude Opus 4.8 (1M context) --- web-next/app/[locale]/change-faction/page.tsx | 8 ++++ web-next/app/[locale]/change-race/page.tsx | 8 ++++ web-next/app/[locale]/customize/page.tsx | 8 ++++ web-next/app/[locale]/level-up/page.tsx | 8 ++++ web-next/app/[locale]/rename/page.tsx | 29 ++---------- .../success => service-success}/page.tsx | 20 ++++---- .../api/character/[service]/checkout/route.ts | 45 ++++++++++++++++++ web-next/components/ServicePageContent.tsx | 32 +++++++++++++ web-next/lib/paid-services.ts | 47 +++++++++++++++++++ web-next/messages/en.json | 35 ++++++++++---- web-next/messages/es.json | 35 ++++++++++---- 11 files changed, 224 insertions(+), 51 deletions(-) create mode 100644 web-next/app/[locale]/change-faction/page.tsx create mode 100644 web-next/app/[locale]/change-race/page.tsx create mode 100644 web-next/app/[locale]/customize/page.tsx create mode 100644 web-next/app/[locale]/level-up/page.tsx rename web-next/app/[locale]/{rename/success => service-success}/page.tsx (50%) create mode 100644 web-next/app/api/character/[service]/checkout/route.ts create mode 100644 web-next/components/ServicePageContent.tsx create mode 100644 web-next/lib/paid-services.ts diff --git a/web-next/app/[locale]/change-faction/page.tsx b/web-next/app/[locale]/change-faction/page.tsx new file mode 100644 index 0000000..c07ace2 --- /dev/null +++ b/web-next/app/[locale]/change-faction/page.tsx @@ -0,0 +1,8 @@ +import { ServicePageContent } from '@/components/ServicePageContent' + +export const dynamic = 'force-dynamic' + +export default async function Page({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params + return +} diff --git a/web-next/app/[locale]/change-race/page.tsx b/web-next/app/[locale]/change-race/page.tsx new file mode 100644 index 0000000..04b9b04 --- /dev/null +++ b/web-next/app/[locale]/change-race/page.tsx @@ -0,0 +1,8 @@ +import { ServicePageContent } from '@/components/ServicePageContent' + +export const dynamic = 'force-dynamic' + +export default async function Page({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params + return +} diff --git a/web-next/app/[locale]/customize/page.tsx b/web-next/app/[locale]/customize/page.tsx new file mode 100644 index 0000000..13503b7 --- /dev/null +++ b/web-next/app/[locale]/customize/page.tsx @@ -0,0 +1,8 @@ +import { ServicePageContent } from '@/components/ServicePageContent' + +export const dynamic = 'force-dynamic' + +export default async function Page({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params + return +} diff --git a/web-next/app/[locale]/level-up/page.tsx b/web-next/app/[locale]/level-up/page.tsx new file mode 100644 index 0000000..0da8e19 --- /dev/null +++ b/web-next/app/[locale]/level-up/page.tsx @@ -0,0 +1,8 @@ +import { ServicePageContent } from '@/components/ServicePageContent' + +export const dynamic = 'force-dynamic' + +export default async function Page({ params }: { params: Promise<{ locale: string }> }) { + const { locale } = await params + return +} diff --git a/web-next/app/[locale]/rename/page.tsx b/web-next/app/[locale]/rename/page.tsx index bc82548..6319f92 100644 --- a/web-next/app/[locale]/rename/page.tsx +++ b/web-next/app/[locale]/rename/page.tsx @@ -1,31 +1,8 @@ -import { getTranslations, setRequestLocale } from 'next-intl/server' -import { redirect } from '@/i18n/navigation' -import { getSession } from '@/lib/session' -import { getGameCharacters } from '@/lib/characters' -import { getRenamePrice } from '@/lib/prices' -import { PaidServiceForm } from '@/components/PaidServiceForm' +import { ServicePageContent } from '@/components/ServicePageContent' export const dynamic = 'force-dynamic' -export default async function RenamePage({ params }: { params: Promise<{ locale: string }> }) { +export default async function Page({ params }: { params: Promise<{ locale: string }> }) { const { locale } = await params - setRequestLocale(locale) - const t = await getTranslations('Rename') - - const session = await getSession() - if (!session.bnetId) redirect({ href: '/login', locale }) - if (!session.username) redirect({ href: '/select-account', locale }) - - const [chars, price] = await Promise.all([getGameCharacters(session.accountId!), getRenamePrice()]) - - return ( -
-

{t('title')}

- c.name)} - checkoutEndpoint="/api/character/rename/checkout" - payLabel={t('pay', { price })} - /> -
- ) + return } diff --git a/web-next/app/[locale]/rename/success/page.tsx b/web-next/app/[locale]/service-success/page.tsx similarity index 50% rename from web-next/app/[locale]/rename/success/page.tsx rename to web-next/app/[locale]/service-success/page.tsx index a1d82be..38c96f0 100644 --- a/web-next/app/[locale]/rename/success/page.tsx +++ b/web-next/app/[locale]/service-success/page.tsx @@ -1,36 +1,38 @@ import { getTranslations, setRequestLocale } from 'next-intl/server' import { claimPaidCheckout } from '@/lib/stripe' import { executeSoapCommand } from '@/lib/soap' +import { getPaidService } from '@/lib/paid-services' export const dynamic = 'force-dynamic' -export default async function RenameSuccessPage({ +export default async function ServiceSuccessPage({ params, searchParams, }: { params: Promise<{ locale: string }> - searchParams: Promise<{ session_id?: string }> + searchParams: Promise<{ service?: string; session_id?: string }> }) { const { locale } = await params setRequestLocale(locale) - const { session_id } = await searchParams - const t = await getTranslations('Rename') + const { service, session_id } = await searchParams + const t = await getTranslations('Paid') + const cfg = service ? getPaidService(service) : null - // Verifica el pago (una sola vez) y ejecuta el comando SOAP. + // Verifica el pago (una vez) y ejecuta el comando SOAP del servicio. let okName: string | null = null - if (session_id) { + if (cfg && session_id) { const claim = await claimPaidCheckout(session_id) if (claim) { - const resp = await executeSoapCommand(`.char rename ${claim.characterName}`) + const resp = await executeSoapCommand(cfg.command(claim.characterName)) if (resp !== null) okName = claim.characterName } } return (
-

{t('successTitle')}

+

{service ? t(`${service}.title`) : ''}

{okName ? ( -

{t('success', { name: okName })}

+

{t(`${service}.success`, { name: okName })}

) : (

{t('error')}

)} diff --git a/web-next/app/api/character/[service]/checkout/route.ts b/web-next/app/api/character/[service]/checkout/route.ts new file mode 100644 index 0000000..4e14a37 --- /dev/null +++ b/web-next/app/api/character/[service]/checkout/route.ts @@ -0,0 +1,45 @@ +import { getSession } from '@/lib/session' +import { getGameCharacters } from '@/lib/characters' +import { createCheckoutSession } from '@/lib/stripe' +import { getPaidService } from '@/lib/paid-services' + +export async function POST(request: Request, { params }: { params: Promise<{ service: string }> }) { + const { service } = await params + const cfg = getPaidService(service) + if (!cfg) return Response.json({ success: false, error: 'invalidService' }, { status: 404 }) + + const session = await getSession() + if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 }) + + let character = '' + try { + const body = await request.json() + character = String(body.character ?? '') + } catch { + return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 }) + } + if (!character) return Response.json({ success: false, error: 'invalidCharacter' }) + + const chars = await getGameCharacters(session.accountId) + if (!chars.find((c) => c.name === character)) { + return Response.json({ success: false, error: 'invalidCharacter' }) + } + + const price = await cfg.getPrice() + const site = process.env.SITE_URL || '' + const ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() || '0.0.0.0' + + const r = await createCheckoutSession({ + accountId: session.accountId, + username: session.username || '', + email: session.bnetEmail || '', + acoreIp: ip, + amount: price, + characterName: character, + productName: cfg.productName(character), + successUrl: `${site}/service-success?service=${service}`, + cancelUrl: `${site}/${service}`, + }) + if (!r.success || !r.url) return Response.json({ success: false, error: 'stripeError' }) + return Response.json({ success: true, url: r.url }) +} diff --git a/web-next/components/ServicePageContent.tsx b/web-next/components/ServicePageContent.tsx new file mode 100644 index 0000000..b2a857b --- /dev/null +++ b/web-next/components/ServicePageContent.tsx @@ -0,0 +1,32 @@ +import { notFound } from 'next/navigation' +import { getTranslations, setRequestLocale } from 'next-intl/server' +import { redirect } from '@/i18n/navigation' +import { getSession } from '@/lib/session' +import { getGameCharacters } from '@/lib/characters' +import { getPaidService } from '@/lib/paid-services' +import { PaidServiceForm } from '@/components/PaidServiceForm' + +/** Contenido común de una página de servicio de pago (guardas + form). */ +export async function ServicePageContent({ service, locale }: { service: string; locale: string }) { + setRequestLocale(locale) + const cfg = getPaidService(service) + if (!cfg) notFound() + + const t = await getTranslations('Paid') + const session = await getSession() + if (!session.bnetId) redirect({ href: '/login', locale }) + if (!session.username) redirect({ href: '/select-account', locale }) + + const [chars, price] = await Promise.all([getGameCharacters(session.accountId!), cfg!.getPrice()]) + + return ( +
+

{t(`${service}.title`)}

+ c.name)} + checkoutEndpoint={`/api/character/${service}/checkout`} + payLabel={t(`${service}.pay`, { price })} + /> +
+ ) +} diff --git a/web-next/lib/paid-services.ts b/web-next/lib/paid-services.ts new file mode 100644 index 0000000..9589e02 --- /dev/null +++ b/web-next/lib/paid-services.ts @@ -0,0 +1,47 @@ +import { + getRenamePrice, + getCustomizePrice, + getChangeRacePrice, + getChangeFactionPrice, + getLevelUpPrice, +} from './prices' + +export interface PaidServiceConfig { + command: (character: string) => string + getPrice: () => Promise + productName: (character: string) => string +} + +// Servicios de personaje de pago. slug = ruta (/rename, /customize, ...) y clave de +// catálogo (Paid.). Comandos SOAP tal cual las success views de Django. +export const PAID_SERVICES: Record = { + rename: { + command: (c) => `.char rename ${c}`, + getPrice: getRenamePrice, + productName: (c) => `Renombrar personaje: ${c}`, + }, + customize: { + command: (c) => `.char customi ${c}`, + getPrice: getCustomizePrice, + productName: (c) => `Personalizar personaje: ${c}`, + }, + 'change-race': { + command: (c) => `.char changerace ${c}`, + getPrice: getChangeRacePrice, + productName: (c) => `Cambiar raza: ${c}`, + }, + 'change-faction': { + command: (c) => `.char changef ${c}`, + getPrice: getChangeFactionPrice, + productName: (c) => `Cambiar facción: ${c}`, + }, + 'level-up': { + command: (c) => `.char level ${c} 80`, + getPrice: getLevelUpPrice, + productName: (c) => `Subir a nivel 80: ${c}`, + }, +} + +export function getPaidService(slug: string): PaidServiceConfig | null { + return PAID_SERVICES[slug] ?? null +} diff --git a/web-next/messages/en.json b/web-next/messages/en.json index 4404216..4a1d35b 100644 --- a/web-next/messages/en.json +++ b/web-next/messages/en.json @@ -136,13 +136,32 @@ "soapError": "Server communication error. Please try again later.", "genericError": "Something went wrong. Please try again later." }, - "Rename": { - "title": "Rename character", - "pay": "Rename for {price} €", - "successTitle": "Rename character", - "success": "Character {name} has been flagged for rename. The change will apply on next login.", - "error": "The operation could not be completed. If you were charged, contact support.", - "invalidCharacter": "Select a valid character.", - "stripeError": "Could not start the payment. Please try again later." + "Paid": { + "rename": { + "title": "Rename character", + "pay": "Rename for {price} €", + "success": "Character {name} has been flagged for rename (applies on next login)." + }, + "customize": { + "title": "Customize character", + "pay": "Customize for {price} €", + "success": "Character {name} has been flagged for customization." + }, + "change-race": { + "title": "Change race", + "pay": "Change race for {price} €", + "success": "Character {name} has been flagged for race change." + }, + "change-faction": { + "title": "Change faction", + "pay": "Change faction for {price} €", + "success": "Character {name} has changed faction successfully." + }, + "level-up": { + "title": "Level up to 80", + "pay": "Level up to 80 for {price} €", + "success": "Character {name} has been leveled to 80." + }, + "error": "The operation could not be completed. If you were charged, contact support." } } diff --git a/web-next/messages/es.json b/web-next/messages/es.json index 7825f9b..2076e93 100644 --- a/web-next/messages/es.json +++ b/web-next/messages/es.json @@ -136,13 +136,32 @@ "soapError": "Error de comunicación con el servidor. Inténtalo más tarde.", "genericError": "Algo ha salido mal. Inténtalo más tarde." }, - "Rename": { - "title": "Renombrar personaje", - "pay": "Renombrar por {price} €", - "successTitle": "Renombrar personaje", - "success": "El personaje {name} ha sido marcado para renombrar. El cambio se aplicará en el próximo inicio de sesión.", - "error": "No se pudo completar la operación. Si se te ha cobrado, contacta con soporte.", - "invalidCharacter": "Selecciona un personaje válido.", - "stripeError": "No se pudo iniciar el pago. Inténtalo más tarde." + "Paid": { + "rename": { + "title": "Renombrar personaje", + "pay": "Renombrar por {price} €", + "success": "El personaje {name} ha sido marcado para renombrar (se aplica al próximo inicio de sesión)." + }, + "customize": { + "title": "Personalizar personaje", + "pay": "Personalizar por {price} €", + "success": "El personaje {name} ha sido marcado para personalización." + }, + "change-race": { + "title": "Cambiar de raza", + "pay": "Cambiar raza por {price} €", + "success": "El personaje {name} ha sido marcado para cambio de raza." + }, + "change-faction": { + "title": "Cambiar de facción", + "pay": "Cambiar facción por {price} €", + "success": "El personaje {name} ha cambiado de facción con éxito." + }, + "level-up": { + "title": "Subir a nivel 80", + "pay": "Subir a nivel 80 por {price} €", + "success": "El personaje {name} ha sido subido al nivel 80." + }, + "error": "No se pudo completar la operación. Si se te ha cobrado, contacta con soporte." } }