diff --git a/web-next/app/[locale]/gold/page.tsx b/web-next/app/[locale]/gold/page.tsx
new file mode 100644
index 0000000..9d76a7f
--- /dev/null
+++ b/web-next/app/[locale]/gold/page.tsx
@@ -0,0 +1,26 @@
+import { getTranslations, setRequestLocale } from 'next-intl/server'
+import { redirect } from '@/i18n/navigation'
+import { getSession } from '@/lib/session'
+import { getGameCharacters } from '@/lib/characters'
+import { getGoldOptions } from '@/lib/prices'
+import { GoldForm } from '@/components/GoldForm'
+
+export const dynamic = 'force-dynamic'
+
+export default async function GoldPage({ params }: { params: Promise<{ locale: string }> }) {
+ const { locale } = await params
+ setRequestLocale(locale)
+ 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, options] = await Promise.all([getGameCharacters(session.accountId!), getGoldOptions()])
+
+ return (
+
+ {t('gold.title')}
+ c.name)} options={options} />
+
+ )
+}
diff --git a/web-next/app/[locale]/service-success/page.tsx b/web-next/app/[locale]/service-success/page.tsx
index 38c96f0..435a728 100644
--- a/web-next/app/[locale]/service-success/page.tsx
+++ b/web-next/app/[locale]/service-success/page.tsx
@@ -1,6 +1,5 @@
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'
@@ -23,8 +22,8 @@ export default async function ServiceSuccessPage({
if (cfg && session_id) {
const claim = await claimPaidCheckout(session_id)
if (claim) {
- const resp = await executeSoapCommand(cfg.command(claim.characterName))
- if (resp !== null) okName = claim.characterName
+ const ok = await cfg.fulfill(claim.characterName, claim.metadata)
+ if (ok) okName = claim.characterName
}
}
diff --git a/web-next/app/[locale]/transfer/page.tsx b/web-next/app/[locale]/transfer/page.tsx
new file mode 100644
index 0000000..6b841b5
--- /dev/null
+++ b/web-next/app/[locale]/transfer/page.tsx
@@ -0,0 +1,26 @@
+import { getTranslations, setRequestLocale } from 'next-intl/server'
+import { redirect } from '@/i18n/navigation'
+import { getSession } from '@/lib/session'
+import { getGameCharacters } from '@/lib/characters'
+import { getTransferPrice } from '@/lib/prices'
+import { TransferForm } from '@/components/TransferForm'
+
+export const dynamic = 'force-dynamic'
+
+export default async function TransferPage({ params }: { params: Promise<{ locale: string }> }) {
+ const { locale } = await params
+ setRequestLocale(locale)
+ 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!), getTransferPrice()])
+
+ return (
+
+ {t('transfer.title')}
+ c.name)} price={price} />
+
+ )
+}
diff --git a/web-next/app/api/character/[service]/checkout/route.ts b/web-next/app/api/character/[service]/checkout/route.ts
index 4e14a37..24bfc46 100644
--- a/web-next/app/api/character/[service]/checkout/route.ts
+++ b/web-next/app/api/character/[service]/checkout/route.ts
@@ -11,13 +11,13 @@ export async function POST(request: Request, { params }: { params: Promise<{ ser
const session = await getSession()
if (!session.accountId) return Response.json({ success: false, error: 'notAuthenticated' }, { status: 401 })
- let character = ''
+ let body: Record = {}
try {
- const body = await request.json()
- character = String(body.character ?? '')
+ body = await request.json()
} catch {
return Response.json({ success: false, error: 'invalidRequest' }, { status: 400 })
}
+ const character = String(body.character ?? '')
if (!character) return Response.json({ success: false, error: 'invalidCharacter' })
const chars = await getGameCharacters(session.accountId)
@@ -25,7 +25,17 @@ export async function POST(request: Request, { params }: { params: Promise<{ ser
return Response.json({ success: false, error: 'invalidCharacter' })
}
- const price = await cfg.getPrice()
+ // Campos extra del servicio (gold_amount, destination_account...) -> metadata
+ const metadata: Record = {}
+ for (const f of cfg.extraFields) {
+ const v = String(body[f] ?? '').trim()
+ if (!v) return Response.json({ success: false, error: 'missingFields' })
+ metadata[f] = v
+ }
+
+ const price = await cfg.price(metadata)
+ if (!price || price <= 0) return Response.json({ success: false, error: 'invalidRequest' })
+
const site = process.env.SITE_URL || ''
const ip = (request.headers.get('x-forwarded-for') || '').split(',')[0].trim() || '0.0.0.0'
@@ -36,9 +46,10 @@ export async function POST(request: Request, { params }: { params: Promise<{ ser
acoreIp: ip,
amount: price,
characterName: character,
- productName: cfg.productName(character),
+ productName: cfg.productName(character, metadata),
successUrl: `${site}/service-success?service=${service}`,
cancelUrl: `${site}/${service}`,
+ metadata,
})
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/GoldForm.tsx b/web-next/components/GoldForm.tsx
new file mode 100644
index 0000000..2829f12
--- /dev/null
+++ b/web-next/components/GoldForm.tsx
@@ -0,0 +1,64 @@
+'use client'
+
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+import type { GoldOption } from '@/lib/prices'
+
+export function GoldForm({ characters, options }: { characters: string[]; options: GoldOption[] }) {
+ const t = useTranslations('Services')
+ const tp = useTranslations('Paid')
+ const [character, setCharacter] = useState('')
+ const [amount, setAmount] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState(null)
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ if (busy || !character || !amount) return
+ setBusy(true)
+ setError(null)
+ try {
+ const res = await fetch('/api/character/gold/checkout', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'same-origin',
+ body: JSON.stringify({ character, gold_amount: amount }),
+ })
+ const data: { success?: boolean; url?: string } = await res.json()
+ if (data.success && data.url) window.location.href = data.url
+ else {
+ setError(t('genericError'))
+ setBusy(false)
+ }
+ } catch {
+ setError(t('genericError'))
+ setBusy(false)
+ }
+ }
+
+ const field = 'w-full rounded border border-amber-900/60 bg-[#2c1e14] px-3 py-2'
+ if (characters.length === 0) return {t('noCharactersYet')}
+
+ return (
+
+ )
+}
diff --git a/web-next/components/ServicePageContent.tsx b/web-next/components/ServicePageContent.tsx
index b2a857b..793f034 100644
--- a/web-next/components/ServicePageContent.tsx
+++ b/web-next/components/ServicePageContent.tsx
@@ -17,7 +17,7 @@ export async function ServicePageContent({ service, locale }: { service: string;
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()])
+ const [chars, price] = await Promise.all([getGameCharacters(session.accountId!), cfg!.price({})])
return (
diff --git a/web-next/components/TransferForm.tsx b/web-next/components/TransferForm.tsx
new file mode 100644
index 0000000..bbbda5f
--- /dev/null
+++ b/web-next/components/TransferForm.tsx
@@ -0,0 +1,56 @@
+'use client'
+
+import { useState } from 'react'
+import { useTranslations } from 'next-intl'
+
+export function TransferForm({ characters, price }: { characters: string[]; price: number }) {
+ const t = useTranslations('Services')
+ const tp = useTranslations('Paid')
+ const [character, setCharacter] = useState('')
+ const [destination, setDestination] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState(null)
+
+ async function handleSubmit(e: React.FormEvent) {
+ e.preventDefault()
+ if (busy || !character || !destination.trim()) return
+ setBusy(true)
+ setError(null)
+ try {
+ const res = await fetch('/api/character/transfer/checkout', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'same-origin',
+ body: JSON.stringify({ character, destination_account: destination.trim() }),
+ })
+ const data: { success?: boolean; url?: string } = await res.json()
+ if (data.success && data.url) window.location.href = data.url
+ else {
+ setError(t('genericError'))
+ setBusy(false)
+ }
+ } catch {
+ setError(t('genericError'))
+ setBusy(false)
+ }
+ }
+
+ const field = 'w-full rounded border border-amber-900/60 bg-[#2c1e14] px-3 py-2'
+ if (characters.length === 0) return {t('noCharactersYet')}
+
+ return (
+
+ )
+}
diff --git a/web-next/lib/paid-services.ts b/web-next/lib/paid-services.ts
index 9589e02..3d5fc00 100644
--- a/web-next/lib/paid-services.ts
+++ b/web-next/lib/paid-services.ts
@@ -1,44 +1,82 @@
+import { executeSoapCommand } from './soap'
+import { db, DB } from './db'
import {
getRenamePrice,
getCustomizePrice,
getChangeRacePrice,
getChangeFactionPrice,
getLevelUpPrice,
+ getTransferPrice,
+ goldPriceFor,
} from './prices'
+type Meta = Record
+
export interface PaidServiceConfig {
- command: (character: string) => string
- getPrice: () => Promise
- productName: (character: string) => string
+ price: (meta: Meta) => Promise
+ productName: (character: string, meta: Meta) => string
+ fulfill: (character: string, meta: Meta) => Promise
+ extraFields: string[] // campos (además de character) que el form envía y se guardan en metadata
+}
+
+async function soapFulfill(command: string): Promise {
+ return (await executeSoapCommand(command)) !== null
+}
+
+async function addGold(character: string, goldAmount: number): Promise {
+ try {
+ await db(DB.characters).query('UPDATE characters SET money = money + ? WHERE name = ?', [
+ goldAmount * 10000,
+ character,
+ ])
+ return true
+ } catch {
+ return false
+ }
}
-// 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,
+ price: () => getRenamePrice(),
productName: (c) => `Renombrar personaje: ${c}`,
+ fulfill: (c) => soapFulfill(`.char rename ${c}`),
+ extraFields: [],
},
customize: {
- command: (c) => `.char customi ${c}`,
- getPrice: getCustomizePrice,
+ price: () => getCustomizePrice(),
productName: (c) => `Personalizar personaje: ${c}`,
+ fulfill: (c) => soapFulfill(`.char customi ${c}`),
+ extraFields: [],
},
'change-race': {
- command: (c) => `.char changerace ${c}`,
- getPrice: getChangeRacePrice,
+ price: () => getChangeRacePrice(),
productName: (c) => `Cambiar raza: ${c}`,
+ fulfill: (c) => soapFulfill(`.char changerace ${c}`),
+ extraFields: [],
},
'change-faction': {
- command: (c) => `.char changef ${c}`,
- getPrice: getChangeFactionPrice,
+ price: () => getChangeFactionPrice(),
productName: (c) => `Cambiar facción: ${c}`,
+ fulfill: (c) => soapFulfill(`.char changef ${c}`),
+ extraFields: [],
},
'level-up': {
- command: (c) => `.char level ${c} 80`,
- getPrice: getLevelUpPrice,
+ price: () => getLevelUpPrice(),
productName: (c) => `Subir a nivel 80: ${c}`,
+ fulfill: (c) => soapFulfill(`.char level ${c} 80`),
+ extraFields: [],
+ },
+ gold: {
+ price: (m) => goldPriceFor(Number(m.gold_amount)),
+ productName: (c, m) => `Aumentar el Oro: ${m.gold_amount} al Personaje: ${c}`,
+ fulfill: (c, m) => addGold(c, Number(m.gold_amount)),
+ extraFields: ['gold_amount'],
+ },
+ transfer: {
+ price: () => getTransferPrice(),
+ productName: (c, m) => `Transferir ${c} a la cuenta ${m.destination_account}`,
+ fulfill: (c, m) => soapFulfill(`.char changeaccount ${m.destination_account} ${c}`),
+ extraFields: ['destination_account'],
},
}
diff --git a/web-next/lib/prices.ts b/web-next/lib/prices.ts
index 641c557..aada602 100644
--- a/web-next/lib/prices.ts
+++ b/web-next/lib/prices.ts
@@ -15,3 +15,26 @@ export const getCustomizePrice = () => priceFrom('home_customizeprice', 1.0)
export const getChangeRacePrice = () => priceFrom('home_changeraceprice', 10.0)
export const getChangeFactionPrice = () => priceFrom('home_changefactionprice', 10.0)
export const getLevelUpPrice = () => priceFrom('home_levelupprice', 10.0)
+export const getTransferPrice = () => priceFrom('home_transferprice', 10.0)
+
+export interface GoldOption {
+ gold_amount: number
+ price: number
+}
+
+export async function getGoldOptions(): Promise {
+ try {
+ const [rows] = await db(DB.default).query(
+ 'SELECT gold_amount, price FROM home_goldprice ORDER BY gold_amount',
+ )
+ return rows.map((r) => ({ gold_amount: r.gold_amount, price: Number(r.price) }))
+ } catch {
+ return []
+ }
+}
+
+/** Precio de una cantidad de oro concreta (0 si no existe esa opción). */
+export async function goldPriceFor(goldAmount: number): Promise {
+ const opts = await getGoldOptions()
+ return opts.find((o) => o.gold_amount === goldAmount)?.price ?? 0
+}
diff --git a/web-next/lib/stripe.ts b/web-next/lib/stripe.ts
index 442e099..8a0bb45 100644
--- a/web-next/lib/stripe.ts
+++ b/web-next/lib/stripe.ts
@@ -25,6 +25,7 @@ export interface CheckoutParams {
successUrl: string
cancelUrl: string
currency?: string
+ metadata?: Record
}
/** Crea una sesión de pago Stripe y registra el StripeLog. Devuelve la URL de checkout. */
@@ -49,6 +50,7 @@ export async function createCheckoutSession(
mode: 'payment',
success_url: successUrl,
cancel_url: p.cancelUrl,
+ metadata: p.metadata,
})
const mode = (process.env.STRIPE_SECRET_KEY || '').startsWith('sk_live') ? 'live' : 'test'
await db(DB.default).query(
@@ -67,8 +69,18 @@ export async function createCheckoutSession(
* (evita re-entregas). Devuelve el character_name del log o null. (Port de
* require_paid_stripe.)
*/
-export async function claimPaidCheckout(sessionId: string): Promise<{ characterName: string } | null> {
- if (!(await isSessionPaid(sessionId))) return null
+export async function claimPaidCheckout(
+ sessionId: string,
+): Promise<{ characterName: string; metadata: Record } | null> {
+ if (!sessionId) return null
+ let metadata: Record = {}
+ try {
+ const s = await stripe.checkout.sessions.retrieve(sessionId)
+ if (s.payment_status !== 'paid') return null
+ metadata = (s.metadata as Record) ?? {}
+ } catch {
+ return null
+ }
const [rows] = await db(DB.default).query(
'SELECT id, character_name, fulfilled FROM home_stripelog WHERE session_id = ?',
[sessionId],
@@ -76,5 +88,5 @@ export async function claimPaidCheckout(sessionId: string): Promise<{ characterN
const log = rows[0]
if (!log || log.fulfilled) return null
await db(DB.default).query('UPDATE home_stripelog SET fulfilled = 1 WHERE id = ?', [log.id])
- return { characterName: log.character_name }
+ return { characterName: log.character_name, metadata }
}
diff --git a/web-next/messages/en.json b/web-next/messages/en.json
index 4a1d35b..a77c710 100644
--- a/web-next/messages/en.json
+++ b/web-next/messages/en.json
@@ -162,6 +162,19 @@
"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."
+ "error": "The operation could not be completed. If you were charged, contact support.",
+ "gold": {
+ "title": "Buy gold",
+ "buy": "Buy gold",
+ "selectAmount": "Select the amount",
+ "option": "{amount} gold — {price} €",
+ "success": "Gold has been added to character {name}."
+ },
+ "transfer": {
+ "title": "Transfer character",
+ "pay": "Transfer for {price} €",
+ "destination": "Destination account",
+ "success": "Character {name} has been transferred to the destination account."
+ }
}
}
diff --git a/web-next/messages/es.json b/web-next/messages/es.json
index 2076e93..68df9a9 100644
--- a/web-next/messages/es.json
+++ b/web-next/messages/es.json
@@ -162,6 +162,19 @@
"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."
+ "error": "No se pudo completar la operación. Si se te ha cobrado, contacta con soporte.",
+ "gold": {
+ "title": "Adquirir oro",
+ "buy": "Comprar oro",
+ "selectAmount": "Selecciona la cantidad",
+ "option": "{amount} oro — {price} €",
+ "success": "El oro ha sido añadido al personaje {name}."
+ },
+ "transfer": {
+ "title": "Transferir personaje",
+ "pay": "Transferir por {price} €",
+ "destination": "Cuenta de destino",
+ "success": "El personaje {name} ha sido transferido a la cuenta de destino."
+ }
}
}