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:
108
db.ts
108
db.ts
@@ -42,7 +42,7 @@ export function clearAllRounds() {
|
||||
db.exec("DELETE FROM sqlite_sequence WHERE name = 'rounds';");
|
||||
}
|
||||
|
||||
// ── Questions (user-submitted via Redsys) ───────────────────────────────────
|
||||
// ── Questions (user-submitted) ───────────────────────────────────────────────
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS questions (
|
||||
@@ -50,27 +50,121 @@ db.exec(`
|
||||
text TEXT NOT NULL,
|
||||
order_id TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
export function createPendingQuestion(text: string, orderId: string): number {
|
||||
const stmt = db.prepare("INSERT INTO questions (text, order_id) VALUES ($text, $orderId)");
|
||||
const result = stmt.run({ $text: text, $orderId: orderId });
|
||||
// Migration: add username column to pre-existing questions tables
|
||||
try {
|
||||
db.exec("ALTER TABLE questions ADD COLUMN username TEXT NOT NULL DEFAULT ''");
|
||||
} catch {
|
||||
// Column already exists — no-op
|
||||
}
|
||||
|
||||
export function createPendingQuestion(text: string, orderId: string, username = ""): number {
|
||||
const stmt = db.prepare(
|
||||
"INSERT INTO questions (text, order_id, username) VALUES ($text, $orderId, $username)"
|
||||
);
|
||||
const result = stmt.run({ $text: text, $orderId: orderId, $username: username });
|
||||
return result.lastInsertRowid as number;
|
||||
}
|
||||
|
||||
/** Creates a question that is immediately ready (used for credit-based submissions). */
|
||||
export function createPaidQuestion(text: string, username: string): void {
|
||||
const orderId = crypto.randomUUID();
|
||||
db.prepare(
|
||||
"INSERT INTO questions (text, order_id, username, status) VALUES ($text, $orderId, $username, 'paid')"
|
||||
).run({ $text: text, $orderId: orderId, $username: username });
|
||||
}
|
||||
|
||||
export function markQuestionPaid(orderId: string): boolean {
|
||||
const stmt = db.prepare("UPDATE questions SET status = 'paid' WHERE order_id = $orderId AND status = 'pending'");
|
||||
const result = stmt.run({ $orderId: orderId });
|
||||
const result = db
|
||||
.prepare("UPDATE questions SET status = 'paid' WHERE order_id = $orderId AND status = 'pending'")
|
||||
.run({ $orderId: orderId });
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
export function getNextPendingQuestion(): { id: number; text: string; order_id: string } | null {
|
||||
return db.query("SELECT id, text, order_id FROM questions WHERE status = 'paid' ORDER BY id ASC LIMIT 1")
|
||||
return db
|
||||
.query("SELECT id, text, order_id FROM questions WHERE status = 'paid' ORDER BY id ASC LIMIT 1")
|
||||
.get() as { id: number; text: string; order_id: string } | null;
|
||||
}
|
||||
|
||||
export function markQuestionUsed(id: number): void {
|
||||
db.prepare("UPDATE questions SET status = 'used' WHERE id = $id").run({ $id: id });
|
||||
}
|
||||
|
||||
/** Top 7 players by number of questions used, excluding anonymous. */
|
||||
export function getPlayerScores(): Record<string, number> {
|
||||
const rows = db
|
||||
.query(
|
||||
"SELECT username, COUNT(*) as score FROM questions WHERE status = 'used' AND username != '' GROUP BY username ORDER BY score DESC LIMIT 7"
|
||||
)
|
||||
.all() as { username: string; score: number }[];
|
||||
return Object.fromEntries(rows.map(r => [r.username, r.score]));
|
||||
}
|
||||
|
||||
// ── Credits (time-based access) ──────────────────────────────────────────────
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS credits (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
tier TEXT NOT NULL,
|
||||
order_id TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
expires_at INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
export function createPendingCredit(username: string, orderId: string, tier: string): string {
|
||||
const token = crypto.randomUUID();
|
||||
db.prepare(
|
||||
"INSERT INTO credits (username, token, tier, order_id) VALUES ($username, $token, $tier, $orderId)"
|
||||
).run({ $username: username, $token: token, $tier: tier, $orderId: orderId });
|
||||
return token;
|
||||
}
|
||||
|
||||
export function activateCredit(
|
||||
orderId: string,
|
||||
expiresAt: number,
|
||||
): { token: string; username: string } | null {
|
||||
db.prepare(
|
||||
"UPDATE credits SET status = 'active', expires_at = $expiresAt WHERE order_id = $orderId AND status = 'pending'"
|
||||
).run({ $expiresAt: expiresAt, $orderId: orderId });
|
||||
return db
|
||||
.query("SELECT token, username FROM credits WHERE order_id = $orderId AND status = 'active'")
|
||||
.get({ $orderId: orderId }) as { token: string; username: string } | null;
|
||||
}
|
||||
|
||||
export function getCreditByOrder(orderId: string): {
|
||||
status: string;
|
||||
token: string;
|
||||
username: string;
|
||||
tier: string;
|
||||
expiresAt: number | null;
|
||||
} | null {
|
||||
return db
|
||||
.query("SELECT status, token, username, tier, expires_at as expiresAt FROM credits WHERE order_id = $orderId")
|
||||
.get({ $orderId: orderId }) as {
|
||||
status: string;
|
||||
token: string;
|
||||
username: string;
|
||||
tier: string;
|
||||
expiresAt: number | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function validateCreditToken(token: string): { username: string; expiresAt: number } | null {
|
||||
const row = db
|
||||
.query(
|
||||
"SELECT username, expires_at FROM credits WHERE token = $token AND status = 'active'"
|
||||
)
|
||||
.get({ $token: token }) as { username: string; expires_at: number } | null;
|
||||
if (!row) return null;
|
||||
if (row.expires_at < Date.now()) return null;
|
||||
return { username: row.username, expiresAt: row.expires_at };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user