Fase 3: migra gold, transfer, revive y unstuck a islas

- revive/unstuck (acciones SOAP gratuitas): reutilizan CharacterPurchaseForm; se
  hace el precio opcional (botón sin "por X €" cuando no hay precio). El componente
  ya maneja la respuesta sin session_id mostrando el mensaje.
- gold: variante GoldForm (personaje + cantidad de oro -> Stripe). La vista pasa
  ahora character_names y gold_options (serializables) por json_script.
- transfer: variante TransferForm (personaje + cuenta destino + token -> Stripe).
  La vista pasa character_names por json_script.

Verificado: check OK, plantillas renderizan las islas, las 4 páginas redirigen a
login sin sesión.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-12 21:42:35 +00:00
parent 823b8d0014
commit 6be04e4e37
11 changed files with 270 additions and 66 deletions
@@ -94,7 +94,7 @@ export function CharacterPurchaseForm({
</select>
<br />
<button className={buttonClass} type="submit" disabled={busy || !character}>
{busy ? 'Procesando…' : `${label} por ${price}`}
{busy ? 'Procesando…' : price ? `${label} por ${price}` : label}
</button>
</form>
<br />
+90
View File
@@ -0,0 +1,90 @@
import { useState } from 'react'
import { redirectToStripeCheckout } from '../stripe'
interface GoldOption {
gold_amount: number
price: string
}
interface Props {
url: string
csrfToken: string
characters: string[]
options: GoldOption[]
}
export function GoldForm({ url, csrfToken, characters, options }: Props) {
const [character, setCharacter] = useState('')
const [amount, setAmount] = 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('character', character)
body.set('gold_amount', amount)
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
}
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">
<form onSubmit={handleSubmit} acceptCharset="utf-8">
<select name="character" value={character} onChange={(e) => setCharacter(e.target.value)} required>
<option value="" disabled>Seleccione un personaje</option>
{characters.map((c) => (
<option value={c} key={c}>{c}</option>
))}
</select>
<br />
<select name="gold_amount" value={amount} onChange={(e) => setAmount(e.target.value)} required>
<option value="" disabled>Seleccione cantidad de oro</option>
{options.map((o) => (
<option value={o.gold_amount} key={o.gold_amount}>
{o.gold_amount} oro - {o.price} EUR
</option>
))}
</select>
<br />
<button type="submit" className="gold-button" disabled={busy || !character || !amount}>
{busy ? 'Procesando…' : 'Enviar oro'}
</button>
</form>
<hr />
<center>
<div className="alert-message" style={{ display: message ? 'block' : 'none' }}>
{message && <span dangerouslySetInnerHTML={{ __html: message.html }} />}
</div>
</center>
</div>
)
}
+96
View File
@@ -0,0 +1,96 @@
import { useState } from 'react'
import { redirectToStripeCheckout } from '../stripe'
interface Props {
url: string
csrfToken: string
characters: string[]
price: string
}
export function TransferForm({ url, csrfToken, characters, price }: Props) {
const [character, setCharacter] = useState('')
const [destination, setDestination] = useState('')
const [token, setToken] = 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('character', character)
body.set('destination_account', destination.trim())
body.set('security_token', token.trim())
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
}
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">
<form onSubmit={handleSubmit} acceptCharset="utf-8">
<select name="character" value={character} onChange={(e) => setCharacter(e.target.value)} required>
<option value="" disabled>Seleccione un personaje</option>
{characters.map((c) => (
<option value={c} key={c}>{c}</option>
))}
</select>
<br />
<input
type="text"
name="destination_account"
placeholder="Cuenta destino"
required
value={destination}
onChange={(e) => setDestination(e.target.value)}
/>
<br />
<input
type="password"
name="security_token"
placeholder="Token de seguridad"
required
value={token}
onChange={(e) => setToken(e.target.value)}
/>
<br />
<button type="submit" className="transfer-button" disabled={busy || !character || !destination || !token}>
{busy ? 'Procesando…' : `Transferir por ${price}`}
</button>
</form>
<hr />
<center>
<div id="transfer-character-response" className="alert-message" style={{ display: message ? 'block' : 'none' }}>
{message && <span dangerouslySetInnerHTML={{ __html: message.html }} />}
</div>
</center>
</div>
)
}
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { GoldForm } from '../components/GoldForm'
const el = document.getElementById('gold-app')
if (el) {
const cEl = document.getElementById('gold-characters-data')
const oEl = document.getElementById('gold-options-data')
const characters: string[] = cEl ? JSON.parse(cEl.textContent || '[]') : []
const options = oEl ? JSON.parse(oEl.textContent || '[]') : []
createRoot(el).render(
<StrictMode>
<GoldForm url={el.dataset.url || ''} csrfToken={el.dataset.csrf || ''} characters={characters} options={options} />
</StrictMode>,
)
}
@@ -0,0 +1,19 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { TransferForm } from '../components/TransferForm'
const el = document.getElementById('transfer-app')
if (el) {
const cEl = document.getElementById('transfer-characters-data')
const characters: string[] = cEl ? JSON.parse(cEl.textContent || '[]') : []
createRoot(el).render(
<StrictMode>
<TransferForm
url={el.dataset.url || ''}
csrfToken={el.dataset.csrf || ''}
characters={characters}
price={el.dataset.price || ''}
/>
</StrictMode>,
)
}
+2
View File
@@ -25,6 +25,8 @@ export default defineConfig({
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'),
gold_character: resolve(__dirname, 'src/entries/gold_character.tsx'),
transfer_character: resolve(__dirname, 'src/entries/transfer_character.tsx'),
},
},
},
+8 -18
View File
@@ -9,24 +9,14 @@
</div>
<div class="body-box-content justified">
<p>Selecciona un personaje y la cantidad de oro:</p>
<script src="{{ URL_PRINCIPAL }}/static/nw-themes/nw-ryu/nw-js-handlers/gold_response.js"></script>
<script src="https://js.stripe.com/v3/"></script>
<form id="nw-gold-form">
<select name="character" required>
<option value="" selected disabled>Seleccione un personaje</option>
{% for character, data in characters.items %}
<option value="{{ character }}">{{ character }}</option>
{% endfor %}
</select>
<select name="gold_amount" required>
<option value="" selected disabled>Seleccione cantidad de oro</option>
{% for price in gold_prices %}
<option value="{{ price.gold_amount }}">{{ price.gold_amount }} oro - {{ price.price }} EUR</option>
{% endfor %}
</select>
<button type="button" class="gold-button">Enviar oro</button>
</form>
<div id="gold-response"></div>
{% load django_vite %}
<!-- Isla React (Vite): enviar oro (personaje + cantidad -> Stripe). -->
<div id="gold-app"
data-csrf="{{ csrf_token }}"
data-url="{% url 'gold-character' %}"></div>
{{ character_names|json_script:"gold-characters-data" }}
{{ gold_options|json_script:"gold-options-data" }}
{% vite_asset 'src/entries/gold_character.tsx' %}
</div>
</div>
</div>
+10 -16
View File
@@ -15,24 +15,18 @@
{% if characters %}
<p>Escoge el personaje que quieres revivir:</p>
<br>
<script src="{{ URL_PRINCIPAL }}/static/nw-themes/nw-ryu/nw-js-handlers/revive_response.js"></script>
<form id="nw-revive-form" action="" method="POST" accept-charset="utf-8">
{% csrf_token %}
<select name="character">
<option disabled selected value="">Seleccione un personaje</option>
{% for character in characters %}
<option value="{{ character }}">{{ character }}</option>
{% endfor %}
</select>
<br>
<button class="revive-button" type="submit" name="revive" data-id="Revivir">Revivir</button>
</form>
<br>
{% load django_vite %}
<!-- Isla React (Vite): acción de personaje (SOAP, sin pago). -->
<div id="character-purchase-app"
data-csrf="{{ csrf_token }}"
data-url="{% url 'revive-character' %}"
data-label="Revivir"
data-button-class="revive-button"></div>
{{ characters|json_script:"character-purchase-data" }}
{% vite_asset 'src/entries/character_purchase.tsx' %}
{% else %}
<p>No hay personajes disponibles para revivir.</p>
<p>No tienes personajes para revivir.</p>
{% endif %}
<hr>
<div class="alert-message" id="revive-response"></div>
</div>
</div>
</div>
@@ -8,21 +8,14 @@
<a class="back-to-account" href="{% url 'my-account' %}">Regresar a mi cuenta</a>
</div>
<div class="body-box-content justified">
<script src="{{ URL_PRINCIPAL }}/static/nw-themes/nw-ryu/nw-js-handlers/transfer_character_response.js"></script>
<script src="https://js.stripe.com/v3/"></script>
<form id="nw-transfer-character-form">
<select name="character" required>
<option value="" selected disabled>Seleccione un personaje</option>
{% for character in characters %}
<option value="{{ character.0 }}">{{ character.0 }}</option>
{% endfor %}
</select>
<input type="text" name="destination_account" placeholder="Cuenta destino" required>
<input type="password" name="security_token" placeholder="Token de seguridad" required>
<button type="button" class="transfer-button">Transferir por {{ price }} €</button>
</form>
<hr>
<center><div id="transfer-character-response"></div></center>
{% load django_vite %}
<!-- Isla React (Vite): transferir personaje (destino + token -> Stripe). -->
<div id="transfer-app"
data-csrf="{{ csrf_token }}"
data-url="{% url 'transfer-character' %}"
data-price="{{ price }}"></div>
{{ character_names|json_script:"transfer-characters-data" }}
{% vite_asset 'src/entries/transfer_character.tsx' %}
</div>
</div>
</div>
+9 -14
View File
@@ -22,20 +22,15 @@
</fieldset>
<div class="centered">
<p>Escoge el personaje que quieres desbloquear</p>
<script src="{{ URL_PRINCIPAL }}/static/nw-themes/nw-ryu/nw-js-handlers/unstuck_response.js"></script>
<form id="nw-unstuck-form" method="POST" accept-charset="utf-8">
{% csrf_token %}
<select name="character" required>
<option disabled selected value="">Selecciona un personaje</option>
{% for character in characters %}
<option value="{{ character }}">{{ character }}</option>
{% endfor %}
</select>
<br>
<button class="unstuck-button" type="submit" name="unstuck" data-id="Desbloquear">Desbloquear</button>
</form>
<hr>
<div class="alert-message" id="unstuck-response"></div>
{% load django_vite %}
<!-- Isla React (Vite): acción de personaje (SOAP, sin pago). -->
<div id="character-purchase-app"
data-csrf="{{ csrf_token }}"
data-url="{% url 'unstuck-character' %}"
data-label="Desbloquear"
data-button-class="unstuck-button"></div>
{{ characters|json_script:"character-purchase-data" }}
{% vite_asset 'src/entries/character_purchase.tsx' %}
</div>
</div>
</div>
+11 -2
View File
@@ -702,7 +702,12 @@ def gold_character_view(request):
else:
return JsonResponse({'success': False, 'message': f'<span class="red-form-response">Error al iniciar el pago: {stripe_response["error"]}</span>'})
return render(request, 'account/gold_character.html', {'characters': characters, 'gold_prices': gold_prices})
return render(request, 'account/gold_character.html', {
'characters': characters,
'gold_prices': gold_prices,
'character_names': list(characters.keys()),
'gold_options': [{'gold_amount': p.gold_amount, 'price': str(p.price)} for p in gold_prices],
})
@require_paid_stripe()
def gold_success_view(request):
# Procesar el pago exitoso
@@ -843,7 +848,11 @@ def transfer_character_view(request):
else:
return JsonResponse({'success': False, 'message': f'<span class="red-form-response">Error al iniciar el pago: {stripe_response["error"]}</span>'})
return render(request, 'account/transfer_character.html', {'characters': characters, 'price': price})
return render(request, 'account/transfer_character.html', {
'characters': characters,
'price': price,
'character_names': [c[0] for c in characters],
})
@require_paid_stripe()
def transfer_success_view(request):
transfer_data = request.session.get('transfer_data')