feat: question-count tiers, footer in main, pregunta footer

- 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>
This commit is contained in:
2026-02-27 17:13:02 +01:00
parent e772fb5cc0
commit d42e93b013
6 changed files with 214 additions and 88 deletions

62
db.ts
View File

@@ -105,7 +105,7 @@ export function getPlayerScores(): Record<string, number> {
return Object.fromEntries(rows.map(r => [r.username, r.score])); return Object.fromEntries(rows.map(r => [r.username, r.score]));
} }
// ── Credits (time-based access) ────────────────────────────────────────────── // ── Credits (question-count-based access) ───────────────────────────────────
db.exec(` db.exec(`
CREATE TABLE IF NOT EXISTS credits ( CREATE TABLE IF NOT EXISTS credits (
@@ -120,11 +120,23 @@ db.exec(`
); );
`); `);
export function createPendingCredit(username: string, orderId: string, tier: string): string { // Migrations for question-tracking columns
try {
db.exec("ALTER TABLE credits ADD COLUMN max_questions INTEGER");
} catch {
// Column already exists — no-op
}
try {
db.exec("ALTER TABLE credits ADD COLUMN questions_used INTEGER NOT NULL DEFAULT 0");
} catch {
// Column already exists — no-op
}
export function createPendingCredit(username: string, orderId: string, tier: string, maxQuestions: number | null): string {
const token = crypto.randomUUID(); const token = crypto.randomUUID();
db.prepare( db.prepare(
"INSERT INTO credits (username, token, tier, order_id) VALUES ($username, $token, $tier, $orderId)" "INSERT INTO credits (username, token, tier, order_id, max_questions) VALUES ($username, $token, $tier, $orderId, $maxQuestions)"
).run({ $username: username, $token: token, $tier: tier, $orderId: orderId }); ).run({ $username: username, $token: token, $tier: tier, $orderId: orderId, $maxQuestions: maxQuestions });
return token; return token;
} }
@@ -146,25 +158,57 @@ export function getCreditByOrder(orderId: string): {
username: string; username: string;
tier: string; tier: string;
expiresAt: number | null; expiresAt: number | null;
maxQuestions: number | null;
questionsUsed: number;
} | null { } | null {
return db return db
.query("SELECT status, token, username, tier, expires_at as expiresAt FROM credits WHERE order_id = $orderId") .query(
"SELECT status, token, username, tier, expires_at as expiresAt, max_questions as maxQuestions, questions_used as questionsUsed FROM credits WHERE order_id = $orderId"
)
.get({ $orderId: orderId }) as { .get({ $orderId: orderId }) as {
status: string; status: string;
token: string; token: string;
username: string; username: string;
tier: string; tier: string;
expiresAt: number | null; expiresAt: number | null;
maxQuestions: number | null;
questionsUsed: number;
} | null; } | null;
} }
export function validateCreditToken(token: string): { username: string; expiresAt: number } | null { /**
* Atomically validates a credit token, creates a paid question, and increments
* the usage counter. Returns null if the token is invalid, expired, or exhausted.
*/
export function consumeCreditQuestion(
token: string,
text: string,
): { username: string; questionsLeft: number | null } | null {
const row = db const row = db
.query( .query(
"SELECT username, expires_at FROM credits WHERE token = $token AND status = 'active'" "SELECT username, expires_at, max_questions, questions_used FROM credits WHERE token = $token AND status = 'active'"
) )
.get({ $token: token }) as { username: string; expires_at: number } | null; .get({ $token: token }) as {
username: string;
expires_at: number;
max_questions: number | null;
questions_used: number;
} | null;
if (!row) return null; if (!row) return null;
if (row.expires_at < Date.now()) return null; if (row.expires_at < Date.now()) return null;
return { username: row.username, expiresAt: row.expires_at }; if (row.max_questions !== null && row.questions_used >= row.max_questions) return null;
const orderId = crypto.randomUUID();
db.transaction(() => {
db.prepare(
"INSERT INTO questions (text, order_id, username, status) VALUES ($text, $orderId, $username, 'paid')"
).run({ $text: text, $orderId: orderId, $username: row.username });
db.prepare(
"UPDATE credits SET questions_used = questions_used + 1 WHERE token = $token"
).run({ $token: token });
})();
const questionsLeft =
row.max_questions === null ? null : row.max_questions - row.questions_used - 1;
return { username: row.username, questionsLeft };
} }

View File

@@ -736,23 +736,24 @@ body {
::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* ── Standings footer & branding ─────────────────────────────── */ /* ── Site footer (inside main) ───────────────────────────────── */
.standings__footer { .site-footer {
padding-top: 14px; flex-shrink: 0;
margin-top: 32px;
padding: 16px 0 4px;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
font-size: 11px; font-size: 11px;
color: var(--text-muted); color: var(--text-muted);
line-height: 1.8; line-height: 1.9;
margin-top: auto;
} }
.standings__footer a { .site-footer a {
color: var(--text-dim); color: var(--text-dim);
text-decoration: none; text-decoration: none;
} }
.standings__footer a:hover { .site-footer a:hover {
color: var(--text); color: var(--text);
text-decoration: underline; text-decoration: underline;
} }

View File

@@ -529,20 +529,6 @@ function Standings({
{Object.keys(playerScores).length > 0 && ( {Object.keys(playerScores).length > 0 && (
<PlayerLeaderboard scores={playerScores} /> <PlayerLeaderboard scores={playerScores} />
)} )}
<div className="standings__footer">
<p>IAs compiten respondiendo preguntas absurdas los jueces votan, 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>
</aside> </aside>
); );
} }
@@ -720,6 +706,18 @@ function App() {
<Dots /> <Dots />
</div> </div>
)} )}
<footer className="site-footer">
<p>IAs compiten respondiendo preguntas absurdas los jueces votan, 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 ·{" "}
<a href="/pregunta">propón preguntas</a>
</p>
</footer>
</main> </main>
<Standings <Standings

View File

@@ -226,6 +226,12 @@ body {
white-space: nowrap; white-space: nowrap;
} }
.pregunta__credit-badge--empty {
color: var(--text-muted);
background: rgba(255, 255, 255, 0.03);
border-color: var(--border);
}
/* ── Success banner ────────────────────────────────────────────── */ /* ── Success banner ────────────────────────────────────────────── */
.pregunta__success { .pregunta__success {
@@ -296,14 +302,20 @@ body {
} }
.tier-card__price { .tier-card__price {
font-size: 22px; font-size: 20px;
font-weight: 700; font-weight: 700;
color: var(--accent); color: var(--accent);
} }
.tier-card__label { .tier-card__label {
font-size: 12px; font-size: 12px;
color: var(--text-dim); font-weight: 500;
color: var(--text);
}
.tier-card__sub {
font-size: 11px;
color: var(--text-muted);
} }
/* ── Spinner ───────────────────────────────────────────────────── */ /* ── Spinner ───────────────────────────────────────────────────── */
@@ -321,3 +333,25 @@ body {
@keyframes pregunta-spin { @keyframes pregunta-spin {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
/* ── Site footer ───────────────────────────────────────────────── */
.pregunta__footer {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid var(--border);
font-size: 11px;
color: var(--text-muted);
line-height: 1.9;
text-align: center;
}
.pregunta__footer a {
color: var(--text-dim);
text-decoration: none;
}
.pregunta__footer a:hover {
color: var(--text);
text-decoration: underline;
}

View File

@@ -9,14 +9,15 @@ type CreditInfo = {
username: string; username: string;
expiresAt: number; expiresAt: number;
tier: string; tier: string;
questionsLeft: number | null; // null = unlimited
}; };
const STORAGE_KEY = "argumentes_credito"; const STORAGE_KEY = "argumentes_credito";
const TIERS = [ const TIERS = [
{ id: "dia", label: "1 día", price: "1€", days: 1 }, { id: "basico", label: "10 preguntas", sublabel: "30 días", price: "0,99€", maxQuestions: 10 },
{ id: "semana", label: "1 semana", price: "5€", days: 7 }, { id: "pro", label: "200 preguntas", sublabel: "30 días", price: "9,99€", maxQuestions: 200 },
{ id: "mes", label: "1 mes", price: "15€", days: 30 }, { id: "ilimitado", label: "Ilimitadas", sublabel: "30 días", price: "19,99€", maxQuestions: null },
] as const; ] as const;
type TierId = (typeof TIERS)[number]["id"]; type TierId = (typeof TIERS)[number]["id"];
@@ -46,8 +47,10 @@ function formatDate(ms: number): string {
}); });
} }
function daysLeft(expiresAt: number): number { function badgeText(questionsLeft: number | null): string {
return Math.max(0, Math.ceil((expiresAt - Date.now()) / (1000 * 60 * 60 * 24))); 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: { async function submitRedsysForm(data: {
@@ -74,6 +77,23 @@ async function submitRedsysForm(data: {
form.submit(); form.submit();
} }
// ── Footer ────────────────────────────────────────────────────────────────────
function SiteFooter() {
return (
<div className="pregunta__footer">
<p>IAs compiten respondiendo preguntas absurdas los jueces votan, 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 ──────────────────────────────────────────────────────────── // ── Main component ────────────────────────────────────────────────────────────
function App() { function App() {
@@ -134,6 +154,7 @@ function App() {
username?: string; username?: string;
expiresAt?: number; expiresAt?: number;
tier?: string; tier?: string;
questionsLeft?: number | null;
}; };
if (data.found && data.status === "active" && data.token && data.expiresAt) { if (data.found && data.status === "active" && data.token && data.expiresAt) {
const newCredit: CreditInfo = { const newCredit: CreditInfo = {
@@ -141,6 +162,7 @@ function App() {
username: data.username ?? "", username: data.username ?? "",
expiresAt: data.expiresAt, expiresAt: data.expiresAt,
tier: data.tier ?? "", tier: data.tier ?? "",
questionsLeft: data.questionsLeft ?? null,
}; };
localStorage.setItem(STORAGE_KEY, JSON.stringify(newCredit)); localStorage.setItem(STORAGE_KEY, JSON.stringify(newCredit));
setCredit(newCredit); setCredit(newCredit);
@@ -193,10 +215,15 @@ function App() {
if (res.status === 401) { if (res.status === 401) {
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(STORAGE_KEY);
setCredit(null); setCredit(null);
throw new Error("Tu acceso ha expirado. Compra un nuevo plan."); throw new Error("Tu acceso ha expirado o se han agotado las preguntas.");
} }
throw new Error(msg || `Error ${res.status}`); 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(""); setText("");
setSent(true); setSent(true);
setTimeout(() => setSent(false), 4000); setTimeout(() => setSent(false), 4000);
@@ -238,6 +265,7 @@ function App() {
<div className="pregunta__links"> <div className="pregunta__links">
<a href="/">Volver al juego</a> <a href="/">Volver al juego</a>
</div> </div>
<SiteFooter />
</div> </div>
</div> </div>
); );
@@ -263,6 +291,7 @@ function App() {
) : ( ) : (
<div className="pregunta__spinner" /> <div className="pregunta__spinner" />
)} )}
<SiteFooter />
</div> </div>
</div> </div>
); );
@@ -271,7 +300,7 @@ function App() {
// ── Active credit — question form ───────────────────────────────────────── // ── Active credit — question form ─────────────────────────────────────────
if (credit) { if (credit) {
const days = daysLeft(credit.expiresAt); const exhausted = credit.questionsLeft !== null && credit.questionsLeft <= 0;
return ( return (
<div className="pregunta"> <div className="pregunta">
<div className="pregunta__panel"> <div className="pregunta__panel">
@@ -279,17 +308,17 @@ function App() {
<a href="/" className="pregunta__logo"> <a href="/" className="pregunta__logo">
<img src="/assets/logo.svg" alt="argument.es" /> <img src="/assets/logo.svg" alt="argument.es" />
</a> </a>
<div className="pregunta__credit-badge"> <div className={`pregunta__credit-badge ${exhausted ? "pregunta__credit-badge--empty" : ""}`}>
{days === 0 {badgeText(credit.questionsLeft)}
? "Expira hoy"
: `${days} día${days !== 1 ? "s" : ""} restante${days !== 1 ? "s" : ""}`}
</div> </div>
</div> </div>
<h1>Hola, {credit.username}</h1> <h1>Hola, {credit.username}</h1>
<p className="pregunta__sub"> <p className="pregunta__sub">
Acceso activo hasta el {formatDate(credit.expiresAt)}. Acceso activo hasta el {formatDate(credit.expiresAt)}.{" "}
Envía todas las preguntas que quieras. {exhausted
? "Has agotado tus preguntas para este plan."
: "Envía todas las preguntas que quieras."}
</p> </p>
{sent && ( {sent && (
@@ -298,6 +327,7 @@ function App() {
</div> </div>
)} )}
{!exhausted && (
<form onSubmit={handleSubmitQuestion} className="pregunta__form"> <form onSubmit={handleSubmitQuestion} className="pregunta__form">
<label htmlFor="pregunta-text" className="pregunta__label"> <label htmlFor="pregunta-text" className="pregunta__label">
Tu pregunta (frase de completar) Tu pregunta (frase de completar)
@@ -323,6 +353,15 @@ function App() {
{submitting ? "Enviando…" : "Enviar pregunta"} {submitting ? "Enviando…" : "Enviar pregunta"}
</button> </button>
</form> </form>
)}
{exhausted && (
<a href="/pregunta" className="pregunta__btn" onClick={() => {
localStorage.removeItem(STORAGE_KEY);
}}>
Comprar nuevo plan
</a>
)}
<div className="pregunta__links"> <div className="pregunta__links">
<a href="/">Ver el juego</a> <a href="/">Ver el juego</a>
@@ -337,6 +376,7 @@ function App() {
Cerrar sesión Cerrar sesión
</button> </button>
</div> </div>
<SiteFooter />
</div> </div>
</div> </div>
); );
@@ -355,8 +395,8 @@ function App() {
<h1>Propón preguntas al juego</h1> <h1>Propón preguntas al juego</h1>
<p className="pregunta__sub"> <p className="pregunta__sub">
Compra acceso por tiempo y envía todas las preguntas que quieras. Compra acceso por 30 días y envía preguntas ilimitadas o un paquete.
Las mejores preguntas se usan en lugar de las generadas por IA y Las mejores se usan en lugar de las generadas por IA y
aparecerás en el marcador de Jugadores. aparecerás en el marcador de Jugadores.
</p> </p>
@@ -370,6 +410,7 @@ function App() {
> >
<div className="tier-card__price">{tier.price}</div> <div className="tier-card__price">{tier.price}</div>
<div className="tier-card__label">{tier.label}</div> <div className="tier-card__label">{tier.label}</div>
<div className="tier-card__sub">{tier.sublabel}</div>
</button> </button>
))} ))}
</div> </div>
@@ -406,6 +447,7 @@ function App() {
<div className="pregunta__links"> <div className="pregunta__links">
<a href="/">Volver al juego</a> <a href="/">Volver al juego</a>
</div> </div>
<SiteFooter />
</div> </div>
</div> </div>
); );

View File

@@ -7,8 +7,8 @@ import broadcastHtml from "./broadcast.html";
import preguntaHtml from "./pregunta.html"; import preguntaHtml from "./pregunta.html";
import { import {
clearAllRounds, getRounds, getAllRounds, clearAllRounds, getRounds, getAllRounds,
createPendingQuestion, markQuestionPaid, createPaidQuestion, createPendingQuestion, markQuestionPaid,
createPendingCredit, activateCredit, getCreditByOrder, validateCreditToken, createPendingCredit, activateCredit, getCreditByOrder, consumeCreditQuestion,
getPlayerScores, getPlayerScores,
} from "./db.ts"; } from "./db.ts";
import { buildPaymentForm, verifyNotification, decodeParams, isPaymentApproved } from "./redsys.ts"; import { buildPaymentForm, verifyNotification, decodeParams, isPaymentApproved } from "./redsys.ts";
@@ -116,10 +116,10 @@ const VIEWER_VOTE_BROADCAST_DEBOUNCE_MS = parsePositiveInt(
const AUTOPAUSE_DELAY_MS = parsePositiveInt(process.env.AUTOPAUSE_DELAY_MS, 60_000); const AUTOPAUSE_DELAY_MS = parsePositiveInt(process.env.AUTOPAUSE_DELAY_MS, 60_000);
const ADMIN_COOKIE = "argumentes_admin"; const ADMIN_COOKIE = "argumentes_admin";
const CREDIT_TIERS: Record<string, { days: number; amount: number; label: string }> = { const CREDIT_TIERS: Record<string, { days: number; amount: number; label: string; maxQuestions: number | null }> = {
dia: { days: 1, amount: 100, label: "1 día" }, basico: { days: 30, amount: 99, label: "10 preguntas", maxQuestions: 10 },
semana: { days: 7, amount: 500, label: "1 semana" }, pro: { days: 30, amount: 999, label: "200 preguntas", maxQuestions: 200 },
mes: { days: 30, amount: 1500, label: "1 mes" }, ilimitado: { days: 30, amount: 1999, label: "Preguntas ilimitadas", maxQuestions: null },
}; };
const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
@@ -613,14 +613,14 @@ const server = Bun.serve<WsData>({
const tierInfo = CREDIT_TIERS[tier]; const tierInfo = CREDIT_TIERS[tier];
if (!tierInfo) { if (!tierInfo) {
return new Response("Tier inválido (dia | semana | mes)", { status: 400 }); return new Response("Tier inválido (basico | pro | ilimitado)", { status: 400 });
} }
if (!username || username.length < 1 || username.length > 30) { if (!username || username.length < 1 || username.length > 30) {
return new Response("El nombre debe tener entre 1 y 30 caracteres", { status: 400 }); return new Response("El nombre debe tener entre 1 y 30 caracteres", { status: 400 });
} }
const orderId = String(Date.now()).slice(-12); const orderId = String(Date.now()).slice(-12);
createPendingCredit(username, orderId, tier); createPendingCredit(username, orderId, tier, tierInfo.maxQuestions);
const isTest = process.env.REDSYS_TEST !== "false"; const isTest = process.env.REDSYS_TEST !== "false";
const terminal = process.env.REDSYS_TERMINAL ?? "1"; const terminal = process.env.REDSYS_TERMINAL ?? "1";
@@ -664,12 +664,20 @@ const server = Bun.serve<WsData>({
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
}); });
} }
const questionsLeft =
credit.maxQuestions === null ? null : credit.maxQuestions - credit.questionsUsed;
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
found: true, found: true,
status: credit.status, status: credit.status,
...(credit.status === "active" ...(credit.status === "active"
? { token: credit.token, username: credit.username, expiresAt: credit.expiresAt, tier: credit.tier } ? {
token: credit.token,
username: credit.username,
expiresAt: credit.expiresAt,
tier: credit.tier,
questionsLeft,
}
: {}), : {}),
}), }),
{ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } }, { headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } },
@@ -700,17 +708,16 @@ const server = Bun.serve<WsData>({
if (!token) { if (!token) {
return new Response("Token requerido", { status: 401 }); return new Response("Token requerido", { status: 401 });
} }
const credit = validateCreditToken(token);
if (!credit) {
return new Response("Crédito no válido o expirado", { status: 401 });
}
if (text.length < 10 || text.length > 200) { if (text.length < 10 || text.length > 200) {
return new Response("La pregunta debe tener entre 10 y 200 caracteres", { status: 400 }); return new Response("La pregunta debe tener entre 10 y 200 caracteres", { status: 400 });
} }
createPaidQuestion(text, credit.username); const result = consumeCreditQuestion(token, text);
log("INFO", "pregunta", "Question submitted via credit", { username: credit.username, ip }); if (!result) {
return new Response(JSON.stringify({ ok: true }), { return new Response("Crédito no válido, expirado o sin preguntas disponibles", { status: 401 });
}
log("INFO", "pregunta", "Question submitted via credit", { username: result.username, ip });
return new Response(JSON.stringify({ ok: true, questionsLeft: result.questionsLeft }), {
status: 200, status: 200,
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
}); });