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.

- - -
-
-
-

Escoge el personaje que quieres desbloquear

-
- - -
- -
-
-
-
-
- + {% load django_vite %} + +
+ {{ characters|json_script:"character-purchase-data" }} + {% vite_asset 'src/entries/character_purchase.tsx' %}