Fase 3 (isla): panel de votación (vote_points) en React/TSX

vote_points_view ya devolvía JSON en POST. VotePanel.tsx renderiza los sitios de
votación (nombre, icono, PV) y cada botón abre el sitio en otra pestaña y hace POST
del voto -> acredita PV o muestra el cooldown que devuelve el backend. La vista pasa
vote_sites_data (serializable) por json_script. Se elimina el JS jQuery y el panel
duplicado; la sección informativa se queda en Django.

Verificado: check OK, render de la isla correcto, redirige 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:48:32 +00:00
parent a4da7f068b
commit cefa30dabc
5 changed files with 132 additions and 34 deletions
+101
View File
@@ -0,0 +1,101 @@
import { useState } from 'react'
interface VoteSite {
name: string
image_url: string
points: number
url: string
}
interface Props {
voteUrl: string
csrfToken: string
sites: VoteSite[]
}
export function VotePanel({ voteUrl, csrfToken, sites }: Props) {
const [busyUrl, setBusyUrl] = useState<string | null>(null)
const [message, setMessage] = useState<{ ok: boolean; html: string } | null>(null)
async function vote(url: string) {
if (busyUrl) return
// Abrir el sitio de votación en otra pestaña
window.open(url, '_blank', 'noopener,noreferrer')
setBusyUrl(url)
setMessage(null)
const body = new URLSearchParams()
body.set('vote', url)
body.set('csrfmiddlewaretoken', csrfToken)
try {
const resp = await fetch(voteUrl, {
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 } = await resp.json()
// El backend manda mensajes con o sin HTML; los envolvemos en verde si es éxito.
const html = data.success ? `<span class="ok-form-response">${data.message ?? ''}</span>` : (data.message ?? '')
setMessage({ ok: !!data.success, html })
} catch {
setMessage({ ok: false, html: '<span class="red-form-response">Error al registrar el voto. Inténtalo más tarde.</span>' })
} finally {
setBusyUrl(null)
}
}
return (
<>
<p>
<span>Nota:</span> Gtop100 puede demorar unos minutos en acreditar el voto.
</p>
<br />
<div className="vote-sites">
{sites.map((s) => (
<div className="inline-div" key={s.url}>
<table>
<tbody>
<tr>
<td>
<img className="img-med-icon" src={s.image_url} title={s.name} alt={s.name} />
</td>
</tr>
<tr>
<td>{s.name}</td>
</tr>
<tr>
<td>
PV: <span>{s.points}</span>
</td>
</tr>
<tr>
<td>
<button
type="button"
className="vote-button"
disabled={busyUrl === s.url}
onClick={() => vote(s.url)}
>
{busyUrl === s.url ? 'Votando…' : 'Votar'}
</button>
</td>
</tr>
</tbody>
</table>
</div>
))}
</div>
<hr />
<div className="alert-message" id="voteResponse" style={{ display: message ? 'block' : 'none' }}>
{message && <span dangerouslySetInnerHTML={{ __html: message.html }} />}
</div>
</>
)
}