Files
argument.es/pregunta.tsx
Malin e772fb5cc0 feat: time-based credits, JUGADORES leaderboard, fix footer visibility
- Credits system: 1€/day, 5€/week, 15€/month time-based access via Redsys
  - credits table with token, tier, expires_at, status lifecycle
  - /api/credito/iniciar, /api/credito/estado, /api/pregunta/enviar endpoints
  - Polling-based token delivery to browser after Redsys URLOK redirect
  - localStorage token storage with expiry check on load
- JUGADORES leaderboard: top 7 players by questions used, polled every 60s
  - /api/jugadores endpoint, PlayerLeaderboard component in Standings sidebar
- Footer: moved into Standings sidebar (.standings__footer) so it's always visible
- pregunta.tsx: complete redesign with tier cards, credit badge, spinner, success state
- pregunta.css: new styles for tier cards, input, badge, spinner, success, link-btn

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 14:43:25 +01:00

416 lines
14 KiB
TypeScript

import React, { useState, useEffect } from "react";
import { createRoot } from "react-dom/client";
import "./pregunta.css";
// ── Types & constants ─────────────────────────────────────────────────────────
type CreditInfo = {
token: string;
username: string;
expiresAt: number;
tier: string;
};
const STORAGE_KEY = "argumentes_credito";
const TIERS = [
{ id: "dia", label: "1 día", price: "1€", days: 1 },
{ id: "semana", label: "1 semana", price: "5€", days: 7 },
{ id: "mes", label: "1 mes", price: "15€", days: 30 },
] as const;
type TierId = (typeof TIERS)[number]["id"];
// ── Helpers ───────────────────────────────────────────────────────────────────
function loadCredit(): CreditInfo | null {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const c = JSON.parse(raw) as CreditInfo;
if (!c.token || !c.expiresAt || c.expiresAt < Date.now()) {
localStorage.removeItem(STORAGE_KEY);
return null;
}
return c;
} catch {
return null;
}
}
function formatDate(ms: number): string {
return new Date(ms).toLocaleDateString("es-ES", {
day: "numeric",
month: "long",
year: "numeric",
});
}
function daysLeft(expiresAt: number): number {
return Math.max(0, Math.ceil((expiresAt - Date.now()) / (1000 * 60 * 60 * 24)));
}
async function submitRedsysForm(data: {
tpvUrl: string;
merchantParams: string;
signature: string;
signatureVersion: string;
}) {
const form = document.createElement("form");
form.method = "POST";
form.action = data.tpvUrl;
for (const [name, value] of Object.entries({
Ds_SignatureVersion: data.signatureVersion,
Ds_MerchantParameters: data.merchantParams,
Ds_Signature: data.signature,
})) {
const input = document.createElement("input");
input.type = "hidden";
input.name = name;
input.value = value;
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
}
// ── Main component ────────────────────────────────────────────────────────────
function App() {
const params = new URLSearchParams(window.location.search);
const creditOkOrder = params.get("credito_ok");
const isKo = params.get("ko") === "1";
// Credit state
const [credit, setCredit] = useState<CreditInfo | null>(null);
const [loaded, setLoaded] = useState(false);
// Credit verification (polling after Redsys redirect)
const [verifying, setVerifying] = useState(false);
const [verifyError, setVerifyError] = useState(false);
// Purchase flow
const [selectedTier, setSelectedTier] = useState<TierId | null>(null);
const [username, setUsername] = useState("");
const [buying, setBuying] = useState(false);
const [buyError, setBuyError] = useState<string | null>(null);
// Question submission
const [text, setText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [sent, setSent] = useState(false);
// Load credit from localStorage on mount
useEffect(() => {
setCredit(loadCredit());
setLoaded(true);
}, []);
// Poll for credit activation after Redsys redirect
useEffect(() => {
if (!creditOkOrder || !loaded || credit) return;
setVerifying(true);
let attempts = 0;
const maxAttempts = 15;
async function poll() {
if (attempts >= maxAttempts) {
setVerifying(false);
setVerifyError(true);
return;
}
attempts++;
try {
const res = await fetch(
`/api/credito/estado?order=${encodeURIComponent(creditOkOrder!)}`,
);
if (res.ok) {
const data = (await res.json()) as {
found: boolean;
status?: string;
token?: string;
username?: string;
expiresAt?: number;
tier?: string;
};
if (data.found && data.status === "active" && data.token && data.expiresAt) {
const newCredit: CreditInfo = {
token: data.token,
username: data.username ?? "",
expiresAt: data.expiresAt,
tier: data.tier ?? "",
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(newCredit));
setCredit(newCredit);
setVerifying(false);
history.replaceState(null, "", "/pregunta");
return;
}
}
} catch {
// retry
}
setTimeout(poll, 2000);
}
poll();
}, [creditOkOrder, loaded, credit]);
async function handleBuyCredit(e: React.FormEvent) {
e.preventDefault();
if (!selectedTier) return;
setBuyError(null);
setBuying(true);
try {
const res = await fetch("/api/credito/iniciar", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tier: selectedTier, username: username.trim() }),
});
if (!res.ok) throw new Error(await res.text());
await submitRedsysForm(await res.json());
} catch (err) {
setBuyError(err instanceof Error ? err.message : "Error al procesar el pago");
setBuying(false);
}
}
async function handleSubmitQuestion(e: React.FormEvent) {
e.preventDefault();
if (!credit) return;
setSubmitError(null);
setSubmitting(true);
try {
const res = await fetch("/api/pregunta/enviar", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: text.trim(), token: credit.token }),
});
if (!res.ok) {
const msg = await res.text();
if (res.status === 401) {
localStorage.removeItem(STORAGE_KEY);
setCredit(null);
throw new Error("Tu acceso ha expirado. Compra un nuevo plan.");
}
throw new Error(msg || `Error ${res.status}`);
}
setText("");
setSent(true);
setTimeout(() => setSent(false), 4000);
} catch (err) {
setSubmitError(err instanceof Error ? err.message : "Error al enviar");
} finally {
setSubmitting(false);
}
}
// ── Loading ───────────────────────────────────────────────────────────────
if (!loaded) {
return (
<div className="pregunta">
<div className="pregunta__panel">
<a href="/" className="pregunta__logo">
<img src="/assets/logo.svg" alt="argument.es" />
</a>
</div>
</div>
);
}
// ── Payment failed ────────────────────────────────────────────────────────
if (isKo) {
return (
<div className="pregunta">
<div className="pregunta__panel">
<a href="/" className="pregunta__logo">
<img src="/assets/logo.svg" alt="argument.es" />
</a>
<h1>Pago cancelado</h1>
<p className="pregunta__sub">
El pago no se completó. Tu acceso no ha sido activado.
</p>
<a href="/pregunta" className="pregunta__btn">Intentar de nuevo</a>
<div className="pregunta__links">
<a href="/">Volver al juego</a>
</div>
</div>
</div>
);
}
// ── Verifying payment ─────────────────────────────────────────────────────
if (verifying || (creditOkOrder && !credit)) {
return (
<div className="pregunta">
<div className="pregunta__panel">
<a href="/" className="pregunta__logo">
<img src="/assets/logo.svg" alt="argument.es" />
</a>
<h1>Verificando tu pago</h1>
<p className="pregunta__sub">
{verifyError
? "No se pudo confirmar el pago. Si se completó, espera unos segundos y recarga."
: "Esto puede tardar unos segundos."}
</p>
{verifyError ? (
<a href="/pregunta" className="pregunta__btn">Volver</a>
) : (
<div className="pregunta__spinner" />
)}
</div>
</div>
);
}
// ── Active credit — question form ─────────────────────────────────────────
if (credit) {
const days = daysLeft(credit.expiresAt);
return (
<div className="pregunta">
<div className="pregunta__panel">
<div className="pregunta__header-row">
<a href="/" className="pregunta__logo">
<img src="/assets/logo.svg" alt="argument.es" />
</a>
<div className="pregunta__credit-badge">
{days === 0
? "Expira hoy"
: `${days} día${days !== 1 ? "s" : ""} restante${days !== 1 ? "s" : ""}`}
</div>
</div>
<h1>Hola, {credit.username}</h1>
<p className="pregunta__sub">
Acceso activo hasta el {formatDate(credit.expiresAt)}.
Envía todas las preguntas que quieras.
</p>
{sent && (
<div className="pregunta__success">
¡Pregunta enviada! Se usará en el próximo sorteo.
</div>
)}
<form onSubmit={handleSubmitQuestion} className="pregunta__form">
<label htmlFor="pregunta-text" className="pregunta__label">
Tu pregunta (frase de completar)
</label>
<textarea
id="pregunta-text"
className="pregunta__textarea"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder='Ejemplo: "La peor cosa que puedes encontrar en ___"'
maxLength={200}
required
rows={3}
autoFocus
/>
<div className="pregunta__hint">{text.length}/200 · mínimo 10</div>
{submitError && <div className="pregunta__error">{submitError}</div>}
<button
type="submit"
className="pregunta__submit"
disabled={submitting || text.trim().length < 10}
>
{submitting ? "Enviando…" : "Enviar pregunta"}
</button>
</form>
<div className="pregunta__links">
<a href="/">Ver el juego</a>
<span className="pregunta__links-sep">·</span>
<button
className="pregunta__link-btn"
onClick={() => {
localStorage.removeItem(STORAGE_KEY);
setCredit(null);
}}
>
Cerrar sesión
</button>
</div>
</div>
</div>
);
}
// ── No credit — tier selection ────────────────────────────────────────────
const tierInfo = selectedTier ? TIERS.find((t) => t.id === selectedTier) : null;
return (
<div className="pregunta">
<div className="pregunta__panel">
<a href="/" className="pregunta__logo">
<img src="/assets/logo.svg" alt="argument.es" />
</a>
<h1>Propón preguntas al juego</h1>
<p className="pregunta__sub">
Compra acceso por tiempo y envía todas las preguntas que quieras.
Las mejores preguntas se usan en lugar de las generadas por IA y
aparecerás en el marcador de Jugadores.
</p>
<div className="tier-cards">
{TIERS.map((tier) => (
<button
key={tier.id}
type="button"
className={`tier-card ${selectedTier === tier.id ? "tier-card--selected" : ""}`}
onClick={() => setSelectedTier(tier.id)}
>
<div className="tier-card__price">{tier.price}</div>
<div className="tier-card__label">{tier.label}</div>
</button>
))}
</div>
{selectedTier && (
<form onSubmit={handleBuyCredit} className="pregunta__form">
<label htmlFor="username" className="pregunta__label">
Tu nombre en el marcador
</label>
<input
id="username"
type="text"
className="pregunta__input"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Ej: Malin"
maxLength={30}
required
autoFocus
/>
{buyError && <div className="pregunta__error">{buyError}</div>}
<button
type="submit"
className="pregunta__submit"
disabled={buying || !username.trim()}
>
{buying
? "Redirigiendo…"
: `Pagar ${tierInfo?.price}${tierInfo?.label}`}
</button>
</form>
)}
<div className="pregunta__links">
<a href="/">Volver al juego</a>
</div>
</div>
</div>
);
}
const root = createRoot(document.getElementById("root")!);
root.render(<App />);