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

View File

@@ -7,8 +7,8 @@ import broadcastHtml from "./broadcast.html";
import preguntaHtml from "./pregunta.html";
import {
clearAllRounds, getRounds, getAllRounds,
createPendingQuestion, markQuestionPaid, createPaidQuestion,
createPendingCredit, activateCredit, getCreditByOrder, validateCreditToken,
createPendingQuestion, markQuestionPaid,
createPendingCredit, activateCredit, getCreditByOrder, consumeCreditQuestion,
getPlayerScores,
} from "./db.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 ADMIN_COOKIE = "argumentes_admin";
const CREDIT_TIERS: Record<string, { days: number; amount: number; label: string }> = {
dia: { days: 1, amount: 100, label: "1 día" },
semana: { days: 7, amount: 500, label: "1 semana" },
mes: { days: 30, amount: 1500, label: "1 mes" },
const CREDIT_TIERS: Record<string, { days: number; amount: number; label: string; maxQuestions: number | null }> = {
basico: { days: 30, amount: 99, label: "10 preguntas", maxQuestions: 10 },
pro: { days: 30, amount: 999, label: "200 preguntas", maxQuestions: 200 },
ilimitado: { days: 30, amount: 1999, label: "Preguntas ilimitadas", maxQuestions: null },
};
const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
@@ -613,14 +613,14 @@ const server = Bun.serve<WsData>({
const tierInfo = CREDIT_TIERS[tier];
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) {
return new Response("El nombre debe tener entre 1 y 30 caracteres", { status: 400 });
}
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 terminal = process.env.REDSYS_TERMINAL ?? "1";
@@ -664,12 +664,20 @@ const server = Bun.serve<WsData>({
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
});
}
const questionsLeft =
credit.maxQuestions === null ? null : credit.maxQuestions - credit.questionsUsed;
return new Response(
JSON.stringify({
found: true,
status: credit.status,
...(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" } },
@@ -700,17 +708,16 @@ const server = Bun.serve<WsData>({
if (!token) {
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) {
return new Response("La pregunta debe tener entre 10 y 200 caracteres", { status: 400 });
}
createPaidQuestion(text, credit.username);
log("INFO", "pregunta", "Question submitted via credit", { username: credit.username, ip });
return new Response(JSON.stringify({ ok: true }), {
const result = consumeCreditQuestion(token, text);
if (!result) {
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,
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
});