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:
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
<StrictMode>
|
||||||
|
<CharacterPurchaseForm
|
||||||
|
url={el.dataset.url || ''}
|
||||||
|
csrfToken={el.dataset.csrf || ''}
|
||||||
|
characters={characters}
|
||||||
|
price={el.dataset.price || ''}
|
||||||
|
label={el.dataset.label || 'Comprar'}
|
||||||
|
field={el.dataset.field || 'character'}
|
||||||
|
buttonClass={el.dataset.buttonClass || 'rename-button'}
|
||||||
|
/>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ export default defineConfig({
|
|||||||
change_password: resolve(__dirname, 'src/entries/change_password.tsx'),
|
change_password: resolve(__dirname, 'src/entries/change_password.tsx'),
|
||||||
security_token: resolve(__dirname, 'src/entries/security_token.tsx'),
|
security_token: resolve(__dirname, 'src/entries/security_token.tsx'),
|
||||||
change_email: resolve(__dirname, 'src/entries/change_email.tsx'),
|
change_email: resolve(__dirname, 'src/entries/change_email.tsx'),
|
||||||
|
character_purchase: resolve(__dirname, 'src/entries/character_purchase.tsx'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,28 +16,17 @@
|
|||||||
<p>Se puede repetir la acción en un mismo las veces que quieras.</p>
|
<p>Se puede repetir la acción en un mismo las veces que quieras.</p>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<script src="{{ URL_PRINCIPAL }}/static/nw-themes/nw-ryu/nw-js-handlers/rename_response.js"></script>
|
{% load django_vite %}
|
||||||
<script src="https://js.stripe.com/v3/"></script>
|
<!-- Isla React (Vite): servicio de pago de personaje (genérico).
|
||||||
<form id="nw-rename-form" div class="centered">
|
POST -> session_id de Stripe -> Checkout. -->
|
||||||
<br>
|
<div id="character-purchase-app"
|
||||||
<br>
|
data-csrf="{{ csrf_token }}"
|
||||||
<p>Escoge el personaje que quieres desbloquear</p>
|
data-url="{% url 'rename-character' %}"
|
||||||
<br>
|
data-price="{{ price }}"
|
||||||
<form action="" id="nw-unstuck-form" method="POST" accept-charset="utf-8">
|
data-label="Renombrar"
|
||||||
<select name="character">
|
data-button-class="rename-button"></div>
|
||||||
<option value="" selected disabled>Seleccione un personaje</option>
|
{{ characters|json_script:"character-purchase-data" }}
|
||||||
{% for character in characters %}
|
{% vite_asset 'src/entries/character_purchase.tsx' %}
|
||||||
<option class="priest big-font" value="{{ character }}">{{ character }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
<br>
|
|
||||||
<button class="rename-button" type="submit" name="rename-button" data-id="Renombrar">Renombrar por {{ price }} €</button>
|
|
||||||
</form>
|
|
||||||
<br>
|
|
||||||
<hr>
|
|
||||||
<center><div class="alert-message" id="rename-response"></div></center>
|
|
||||||
<br>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user