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 ( +
+
+ + + +
+ {error &&

{error}

} +
+ ) +} 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 ( +
+
+ + setDestination(e.target.value)} required className={field} /> + +
+ {error &&

{error}

} +
+ ) +} 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." + } } }