feat: embed propose-question widget inline in main game page

- Move /pregunta flow into a compact ProposeQuestion component
  rendered inside <main> above the footer
- Tier cards, username input, Redsys payment, polling, and question
  submission all work inline without leaving the game page
- Payment redirects now go to /?credito_ok=ORDER and /?ko=1
  so the game stays in view during the full payment cycle
- Badge shows live questions remaining; updates on each submission
- Removed /pregunta link from footer (functionality is now inline)
- .propose-* CSS: compact tier grid, textarea+button row, spinner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 17:21:35 +01:00
parent d42e93b013
commit 3e5c080466
3 changed files with 479 additions and 4 deletions

View File

@@ -57,6 +57,65 @@ type ViewerCountMessage = {
};
type ServerMessage = StateMessage | ViewerCountMessage;
// ── Credit / Propose ─────────────────────────────────────────────────────────
type CreditInfo = {
token: string;
username: string;
expiresAt: number;
tier: string;
questionsLeft: number | null;
};
const CREDIT_STORAGE_KEY = "argumentes_credito";
const PROPOSE_TIERS = [
{ id: "basico", label: "10 preguntas", price: "0,99€" },
{ id: "pro", label: "200 preguntas", price: "9,99€" },
{ id: "ilimitado", label: "Ilimitadas", price: "19,99€" },
] as const;
type ProposeTierId = (typeof PROPOSE_TIERS)[number]["id"];
function loadCredit(): CreditInfo | null {
try {
const raw = localStorage.getItem(CREDIT_STORAGE_KEY);
if (!raw) return null;
const c = JSON.parse(raw) as CreditInfo;
if (!c.token || !c.expiresAt || c.expiresAt < Date.now()) {
localStorage.removeItem(CREDIT_STORAGE_KEY);
return null;
}
return c;
} catch {
return null;
}
}
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();
}
// ── Model colors & logos ─────────────────────────────────────────────────────
const MODEL_COLORS: Record<string, string> = {
@@ -533,6 +592,241 @@ function Standings({
);
}
// ── Propose Question (inline widget) ─────────────────────────────────────────
function ProposeQuestion() {
const params = new URLSearchParams(window.location.search);
const creditOkOrder = params.get("credito_ok");
const isKo = params.get("ko") === "1";
const [credit, setCredit] = useState<CreditInfo | null>(null);
const [loaded, setLoaded] = useState(false);
const [verifying, setVerifying] = useState(false);
const [verifyError, setVerifyError] = useState(false);
const [selectedTier, setSelectedTier] = useState<ProposeTierId | null>(null);
const [username, setUsername] = useState("");
const [buying, setBuying] = useState(false);
const [buyError, setBuyError] = useState<string | null>(null);
const [text, setText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [sent, setSent] = useState(false);
const [koDismissed, setKoDismissed] = useState(false);
useEffect(() => {
setCredit(loadCredit());
setLoaded(true);
}, []);
useEffect(() => {
if (!creditOkOrder || !loaded || credit) return;
setVerifying(true);
let attempts = 0;
async function poll() {
if (attempts >= 15) { 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;
questionsLeft?: number | null;
};
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 ?? "",
questionsLeft: data.questionsLeft ?? null,
};
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(newCredit));
setCredit(newCredit);
setVerifying(false);
history.replaceState(null, "", "/");
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) {
if (res.status === 401) {
localStorage.removeItem(CREDIT_STORAGE_KEY);
setCredit(null);
throw new Error("Acceso expirado o sin preguntas disponibles.");
}
throw new Error(await res.text() || `Error ${res.status}`);
}
const data = await res.json() as { ok: boolean; questionsLeft: number | null };
const updated: CreditInfo = { ...credit, questionsLeft: data.questionsLeft };
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(updated));
setCredit(updated);
setText("");
setSent(true);
setTimeout(() => setSent(false), 3000);
} catch (err) {
setSubmitError(err instanceof Error ? err.message : "Error al enviar");
} finally {
setSubmitting(false);
}
}
if (!loaded) return null;
const tierInfo = selectedTier ? PROPOSE_TIERS.find(t => t.id === selectedTier) : null;
const exhausted = credit !== null && credit.questionsLeft !== null && credit.questionsLeft <= 0;
// Verifying payment
if (verifying || (creditOkOrder && !credit)) {
return (
<div className="propose">
<div className="propose__head">
<span className="propose__title">Verificando pago</span>
{!verifyError && <div className="propose__spinner" />}
</div>
{verifyError && (
<p className="propose__msg propose__msg--error">
No se pudo confirmar. Recarga si el pago se completó.
</p>
)}
</div>
);
}
// Active credit — question form
if (credit) {
const badge = credit.questionsLeft === null
? "Ilimitadas"
: `${credit.questionsLeft} restante${credit.questionsLeft !== 1 ? "s" : ""}`;
return (
<div className="propose">
<div className="propose__head">
<span className="propose__title">Propón una pregunta · {credit.username}</span>
<span className={`propose__badge ${exhausted ? "propose__badge--empty" : ""}`}>{badge}</span>
</div>
{sent && <p className="propose__msg propose__msg--ok"> ¡Enviada! Se usará en el próximo sorteo.</p>}
{!exhausted ? (
<form onSubmit={handleSubmitQuestion}>
<div className="propose__row">
<textarea
className="propose__textarea"
value={text}
onChange={e => setText(e.target.value)}
placeholder='"La peor cosa que puedes encontrar en ___"'
rows={2}
maxLength={200}
/>
<button className="propose__btn" type="submit" disabled={submitting || text.trim().length < 10}>
{submitting ? "…" : "Enviar"}
</button>
</div>
<div className="propose__hint">
{text.length}/200 · mín. 10 ·{" "}
<button type="button" className="propose__link-btn" onClick={() => {
localStorage.removeItem(CREDIT_STORAGE_KEY);
setCredit(null);
}}>
cerrar sesión
</button>
</div>
{submitError && <p className="propose__msg propose__msg--error">{submitError}</p>}
</form>
) : (
<div className="propose__row">
<p className="propose__msg propose__msg--error" style={{ flex: 1 }}>Has agotado tus preguntas.</p>
<button className="propose__btn" onClick={() => {
localStorage.removeItem(CREDIT_STORAGE_KEY);
setCredit(null);
}}>Nuevo plan</button>
</div>
)}
</div>
);
}
// Tier selection
return (
<div className="propose">
<div className="propose__head">
<span className="propose__title">Propón preguntas al juego</span>
</div>
{isKo && !koDismissed && (
<p className="propose__msg propose__msg--error">
El pago no se completó.{" "}
<button type="button" className="propose__link-btn" onClick={() => setKoDismissed(true)}>
×
</button>
</p>
)}
<div className="propose__tiers">
{PROPOSE_TIERS.map(tier => (
<button
key={tier.id}
type="button"
className={`propose__tier ${selectedTier === tier.id ? "propose__tier--selected" : ""}`}
onClick={() => setSelectedTier(tier.id)}
>
<span className="propose__tier__price">{tier.price}</span>
<span className="propose__tier__label">{tier.label}</span>
</button>
))}
</div>
{selectedTier && (
<form onSubmit={handleBuyCredit} className="propose__row propose__row--mt">
<input
type="text"
className="propose__input"
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="Tu nombre en el marcador"
maxLength={30}
required
autoFocus
/>
<button type="submit" className="propose__btn" disabled={buying || !username.trim()}>
{buying ? "…" : `Pagar ${tierInfo?.price}`}
</button>
</form>
)}
{buyError && <p className="propose__msg propose__msg--error">{buyError}</p>}
</div>
);
}
// ── Connecting ───────────────────────────────────────────────────────────────
function ConnectingScreen() {
@@ -707,6 +1001,8 @@ function App() {
</div>
)}
<ProposeQuestion />
<footer className="site-footer">
<p>IAs compiten respondiendo preguntas absurdas los jueces votan, también puedes.</p>
<p>
@@ -714,8 +1010,7 @@ function App() {
<a href="https://cloudhost.es" target="_blank" rel="noopener noreferrer">
Cloud Host
</a>
{" "} La web simplificada, la nube gestionada ·{" "}
<a href="/pregunta">propón preguntas</a>
{" "} La web simplificada, la nube gestionada
</p>
</footer>
</main>