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>
This commit is contained in:
2026-02-27 14:43:25 +01:00
parent 2fac92356d
commit e772fb5cc0
6 changed files with 857 additions and 117 deletions

178
server.ts
View File

@@ -5,7 +5,12 @@ import historyHtml from "./history.html";
import adminHtml from "./admin.html";
import broadcastHtml from "./broadcast.html";
import preguntaHtml from "./pregunta.html";
import { clearAllRounds, getRounds, getAllRounds, createPendingQuestion, markQuestionPaid } from "./db.ts";
import {
clearAllRounds, getRounds, getAllRounds,
createPendingQuestion, markQuestionPaid, createPaidQuestion,
createPendingCredit, activateCredit, getCreditByOrder, validateCreditToken,
getPlayerScores,
} from "./db.ts";
import { buildPaymentForm, verifyNotification, decodeParams, isPaymentApproved } from "./redsys.ts";
import {
MODELS,
@@ -110,6 +115,12 @@ 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 ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
const requestWindows = new Map<string, number[]>();
@@ -541,8 +552,27 @@ const server = Bun.serve<WsData>({
if (isPaymentApproved(decoded)) {
const orderId = decoded["Ds_Order"] ?? "";
if (orderId) {
const marked = markQuestionPaid(orderId);
log("INFO", "redsys", "Question marked as paid", { orderId, marked });
// Try question order first, then credit order
const markedQuestion = markQuestionPaid(orderId);
if (markedQuestion) {
log("INFO", "redsys", "Question marked as paid", { orderId });
} else {
const credit = getCreditByOrder(orderId);
if (credit && credit.status === "pending") {
const tierInfo = CREDIT_TIERS[credit.tier];
if (tierInfo) {
const expiresAt = Date.now() + tierInfo.days * 24 * 60 * 60 * 1000;
const activated = activateCredit(orderId, expiresAt);
if (activated) {
log("INFO", "redsys", "Credit activated", {
orderId,
username: activated.username,
tier: credit.tier,
});
}
}
}
}
}
} else {
log("INFO", "redsys", "Payment not approved", {
@@ -553,6 +583,148 @@ const server = Bun.serve<WsData>({
return new Response("ok", { status: 200 });
}
if (url.pathname === "/api/credito/iniciar") {
if (req.method !== "POST") {
return new Response("Method Not Allowed", {
status: 405,
headers: { Allow: "POST" },
});
}
const secretKey = process.env.REDSYS_SECRET_KEY;
const merchantCode = process.env.REDSYS_MERCHANT_CODE;
if (!secretKey || !merchantCode) {
return new Response("Pagos no configurados", { status: 503 });
}
if (isRateLimited(`credito:${ip}`, 5, WINDOW_MS)) {
return new Response("Too Many Requests", { status: 429 });
}
let tier = "";
let username = "";
try {
const body = await req.json();
tier = String((body as Record<string, unknown>).tier ?? "").trim();
username = String((body as Record<string, unknown>).username ?? "").trim();
} catch {
return new Response("Invalid JSON body", { status: 400 });
}
const tierInfo = CREDIT_TIERS[tier];
if (!tierInfo) {
return new Response("Tier inválido (dia | semana | mes)", { 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);
const isTest = process.env.REDSYS_TEST !== "false";
const terminal = process.env.REDSYS_TERMINAL ?? "1";
const baseUrl =
process.env.PUBLIC_URL?.replace(/\/$/, "") ??
`${url.protocol}//${url.host}`;
const form = buildPaymentForm({
secretKey,
merchantCode,
terminal,
isTest,
orderId,
amount: tierInfo.amount,
urlOk: `${baseUrl}/pregunta?credito_ok=${orderId}`,
urlKo: `${baseUrl}/pregunta?ko=1`,
merchantUrl: `${baseUrl}/api/redsys/notificacion`,
productDescription: `Acceso argument.es — ${tierInfo.label}`,
});
log("INFO", "credito", "Credit purchase initiated", {
orderId,
tier,
ip,
username: username.slice(0, 10),
});
return new Response(JSON.stringify({ ok: true, ...form }), {
status: 200,
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
});
}
if (url.pathname === "/api/credito/estado") {
const orderId = url.searchParams.get("order") ?? "";
if (!orderId) {
return new Response("Missing order", { status: 400 });
}
const credit = getCreditByOrder(orderId);
if (!credit) {
return new Response(JSON.stringify({ found: false }), {
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
});
}
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 }
: {}),
}),
{ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } },
);
}
if (url.pathname === "/api/pregunta/enviar") {
if (req.method !== "POST") {
return new Response("Method Not Allowed", {
status: 405,
headers: { Allow: "POST" },
});
}
if (isRateLimited(`pregunta:${ip}`, 20, WINDOW_MS)) {
return new Response("Too Many Requests", { status: 429 });
}
let text = "";
let token = "";
try {
const body = await req.json();
text = String((body as Record<string, unknown>).text ?? "").trim();
token = String((body as Record<string, unknown>).token ?? "").trim();
} catch {
return new Response("Invalid JSON body", { status: 400 });
}
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 }), {
status: 200,
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
});
}
if (url.pathname === "/api/jugadores") {
return new Response(JSON.stringify(getPlayerScores()), {
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=30, stale-while-revalidate=60",
},
});
}
if (url.pathname === "/api/admin/login") {
if (req.method !== "POST") {
return new Response("Method Not Allowed", {