diff --git a/frontend/src/components/CharacterPurchaseForm.tsx b/frontend/src/components/CharacterPurchaseForm.tsx
new file mode 100644
index 0000000..3c62d58
--- /dev/null
+++ b/frontend/src/components/CharacterPurchaseForm.tsx
@@ -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: 'Error al iniciar el pago. Inténtalo más tarde.',
+ })
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
+
Escoge el personaje
+
+
+
+
+
+
+ {message && }
+
+
+
+
+ )
+}
diff --git a/frontend/src/entries/character_purchase.tsx b/frontend/src/entries/character_purchase.tsx
new file mode 100644
index 0000000..cc64cf5
--- /dev/null
+++ b/frontend/src/entries/character_purchase.tsx
@@ -0,0 +1,24 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { CharacterPurchaseForm } from '../components/CharacterPurchaseForm'
+
+// Entry genérico para todos los servicios de personaje de pago. La plantilla
+// configura el formulario con data-* y una lista de personajes en json_script.
+const el = document.getElementById('character-purchase-app')
+if (el) {
+ const dataEl = document.getElementById('character-purchase-data')
+ const characters: string[] = dataEl ? JSON.parse(dataEl.textContent || '[]') : []
+ createRoot(el).render(
+
+
+ ,
+ )
+}
diff --git a/frontend/src/stripe.ts b/frontend/src/stripe.ts
new file mode 100644
index 0000000..b200ce0
--- /dev/null
+++ b/frontend/src/stripe.ts
@@ -0,0 +1,47 @@
+interface StripeInstance {
+ redirectToCheckout: (opts: { sessionId: string }) => Promise<{ error?: { message: string } }>
+}
+
+declare global {
+ interface Window {
+ Stripe?: (key: string) => StripeInstance
+ }
+}
+
+const SCRIPT_ID = 'stripe-js'
+
+/**
+ * Carga Stripe.js (una vez) y redirige al Checkout con el session_id dado.
+ * Rechaza si Stripe no carga o devuelve error.
+ */
+export function redirectToStripeCheckout(publicKey: string, sessionId: string): Promise {
+ return new Promise((resolve, reject) => {
+ function go() {
+ if (!window.Stripe) return reject(new Error('Stripe no está disponible'))
+ const stripe = window.Stripe(publicKey)
+ stripe
+ .redirectToCheckout({ sessionId })
+ .then((r) => (r.error ? reject(new Error(r.error.message)) : resolve()))
+ .catch(reject)
+ }
+
+ if (window.Stripe) return go()
+
+ if (!document.getElementById(SCRIPT_ID)) {
+ const s = document.createElement('script')
+ s.id = SCRIPT_ID
+ s.src = 'https://js.stripe.com/v3/'
+ document.head.appendChild(s)
+ }
+ let waited = 0
+ const iv = window.setInterval(() => {
+ if (window.Stripe) {
+ window.clearInterval(iv)
+ go()
+ } else if ((waited += 150) > 8000) {
+ window.clearInterval(iv)
+ reject(new Error('No se pudo cargar Stripe.'))
+ }
+ }, 150)
+ })
+}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 7957025..73bf2e3 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -24,6 +24,7 @@ export default defineConfig({
change_password: resolve(__dirname, 'src/entries/change_password.tsx'),
security_token: resolve(__dirname, 'src/entries/security_token.tsx'),
change_email: resolve(__dirname, 'src/entries/change_email.tsx'),
+ character_purchase: resolve(__dirname, 'src/entries/character_purchase.tsx'),
},
},
},
diff --git a/home/templates/partials/rename_character.html b/home/templates/partials/rename_character.html
index f48b17c..a6cc462 100644
--- a/home/templates/partials/rename_character.html
+++ b/home/templates/partials/rename_character.html
@@ -16,28 +16,17 @@
Se puede repetir la acción en un mismo las veces que quieras.
-
-
-
-
-
-
-
-
+ {% load django_vite %}
+
+
+ {{ characters|json_script:"character-purchase-data" }}
+ {% vite_asset 'src/entries/character_purchase.tsx' %}