Fase 3 (isla reutilizable): servicios de personaje de pago; aplica a rename

Componente genérico CharacterPurchaseForm (selector de personaje + botón con precio)
+ entry character_purchase, configurable por data-* en la plantilla. Al enviar, la
vista devuelve {session_id, stripe_public_key} y se redirige al Checkout de Stripe
(helper stripe.ts que carga Stripe.js). Reutilizable por rename/customize/race/
faction/level/gold/transfer... cambiando solo los data-* y la lista de personajes
(json_script). Primer uso: rename_character.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 21:33:14 +00:00
parent b1107c0a54
commit 2258df4b09
5 changed files with 193 additions and 22 deletions
@@ -0,0 +1,110 @@
import { useState } from 'react'
import { redirectToStripeCheckout } from '../stripe'
interface Props {
url: string
csrfToken: string
characters: string[]
price: string
label: string
/** nombre del campo POST del personaje (por defecto "character") */
field?: string
/** clase CSS del botón (por defecto "rename-button") */
buttonClass?: string
}
/**
* Formulario reutilizable para los servicios de personaje de pago: selector de
* personaje + botón con precio. Al enviar, la vista devuelve un session_id de
* Stripe y redirige al Checkout. Sirve para rename/customize/race/faction/level/
* gold/transfer/... cambiando solo los data-* de la plantilla.
*/
export function CharacterPurchaseForm({
url,
csrfToken,
characters,
price,
label,
field = 'character',
buttonClass = 'rename-button',
}: Props) {
const [character, setCharacter] = useState('')
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState<{ ok: boolean; html: string } | null>(null)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (busy) return
setBusy(true)
setMessage(null)
const body = new URLSearchParams()
body.set(field, character)
body.set('csrfmiddlewaretoken', csrfToken)
try {
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'X-CSRFToken': csrfToken,
Accept: 'application/json',
},
credentials: 'same-origin',
body: body.toString(),
})
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data: {
success?: boolean
message?: string
session_id?: string
stripe_public_key?: string
} = await resp.json()
if (data.success && data.session_id && data.stripe_public_key) {
await redirectToStripeCheckout(data.stripe_public_key, data.session_id)
return // redirigiendo a Stripe
}
setMessage({ ok: !!data.success, html: data.message || '' })
setBusy(false)
} catch {
setMessage({
ok: false,
html: '<span class="red-form-response">Error al iniciar el pago. Inténtalo más tarde.</span>',
})
setBusy(false)
}
}
return (
<div className="centered">
<br />
<p>Escoge el personaje</p>
<br />
<form onSubmit={handleSubmit} acceptCharset="utf-8">
<select name={field} value={character} onChange={(e) => setCharacter(e.target.value)} required>
<option value="" disabled>
Seleccione un personaje
</option>
{characters.map((c) => (
<option className="priest big-font" value={c} key={c}>
{c}
</option>
))}
</select>
<br />
<button className={buttonClass} type="submit" disabled={busy || !character}>
{busy ? 'Procesando…' : `${label} por ${price}`}
</button>
</form>
<br />
<hr />
<center>
<div className="alert-message" style={{ display: message ? 'block' : 'none' }}>
{message && <span dangerouslySetInnerHTML={{ __html: message.html }} />}
</div>
</center>
<br />
</div>
)
}