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:
178
server.ts
178
server.ts
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user