- Pricing: 0,99€/10q · 9,99€/200q · 19,99€/unlimited (all 30 days) - DB: max_questions + questions_used columns on credits table; consumeCreditQuestion() atomically validates, creates question, and decrements quota in a single transaction - Server: updated CREDIT_TIERS, /api/credito/estado returns questionsLeft, /api/pregunta/enviar returns updated questionsLeft after each submission - pregunta.tsx: badge shows live question count; submit disabled when exhausted; questionsLeft synced to localStorage after each submission; Cloud Host footer added - footer: moved from Standings sidebar into <main> (scrolls with game content); also added to all pregunta page states Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
458 lines
15 KiB
TypeScript
458 lines
15 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;
|
|
questionsLeft: number | null; // null = unlimited
|
|
};
|
|
|
|
const STORAGE_KEY = "argumentes_credito";
|
|
|
|
const TIERS = [
|
|
{ id: "basico", label: "10 preguntas", sublabel: "30 días", price: "0,99€", maxQuestions: 10 },
|
|
{ id: "pro", label: "200 preguntas", sublabel: "30 días", price: "9,99€", maxQuestions: 200 },
|
|
{ id: "ilimitado", label: "Ilimitadas", sublabel: "30 días", price: "19,99€", maxQuestions: null },
|
|
] 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 badgeText(questionsLeft: number | null): string {
|
|
if (questionsLeft === null) return "Preguntas ilimitadas";
|
|
if (questionsLeft === 0) return "Sin preguntas restantes";
|
|
return `${questionsLeft} pregunta${questionsLeft !== 1 ? "s" : ""} restante${questionsLeft !== 1 ? "s" : ""}`;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
// ── Footer ────────────────────────────────────────────────────────────────────
|
|
|
|
function SiteFooter() {
|
|
return (
|
|
<div className="pregunta__footer">
|
|
<p>IAs compiten respondiendo preguntas absurdas — los jueces votan, tú también puedes.</p>
|
|
<p>
|
|
por{" "}
|
|
<a href="https://cloudhost.es" target="_blank" rel="noopener noreferrer">
|
|
Cloud Host
|
|
</a>
|
|
{" "}— La web simplificada, la nube gestionada
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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;
|
|
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(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 o se han agotado las preguntas.");
|
|
}
|
|
throw new Error(msg || `Error ${res.status}`);
|
|
}
|
|
const data = await res.json() as { ok: boolean; questionsLeft: number | null };
|
|
// Update questionsLeft in state and localStorage
|
|
const updated: CreditInfo = { ...credit, questionsLeft: data.questionsLeft };
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
|
setCredit(updated);
|
|
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>
|
|
<SiteFooter />
|
|
</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" />
|
|
)}
|
|
<SiteFooter />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Active credit — question form ─────────────────────────────────────────
|
|
|
|
if (credit) {
|
|
const exhausted = credit.questionsLeft !== null && credit.questionsLeft <= 0;
|
|
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 ${exhausted ? "pregunta__credit-badge--empty" : ""}`}>
|
|
{badgeText(credit.questionsLeft)}
|
|
</div>
|
|
</div>
|
|
|
|
<h1>Hola, {credit.username}</h1>
|
|
<p className="pregunta__sub">
|
|
Acceso activo hasta el {formatDate(credit.expiresAt)}.{" "}
|
|
{exhausted
|
|
? "Has agotado tus preguntas para este plan."
|
|
: "Envía todas las preguntas que quieras."}
|
|
</p>
|
|
|
|
{sent && (
|
|
<div className="pregunta__success">
|
|
✓ ¡Pregunta enviada! Se usará en el próximo sorteo.
|
|
</div>
|
|
)}
|
|
|
|
{!exhausted && (
|
|
<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>
|
|
)}
|
|
|
|
{exhausted && (
|
|
<a href="/pregunta" className="pregunta__btn" onClick={() => {
|
|
localStorage.removeItem(STORAGE_KEY);
|
|
}}>
|
|
Comprar nuevo plan
|
|
</a>
|
|
)}
|
|
|
|
<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>
|
|
<SiteFooter />
|
|
</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 30 días y envía preguntas ilimitadas o un paquete.
|
|
Las mejores 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>
|
|
<div className="tier-card__sub">{tier.sublabel}</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>
|
|
<SiteFooter />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const root = createRoot(document.getElementById("root")!);
|
|
root.render(<App />);
|