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:
62
db.ts
62
db.ts
@@ -105,7 +105,7 @@ export function getPlayerScores(): Record<string, number> {
|
||||
return Object.fromEntries(rows.map(r => [r.username, r.score]));
|
||||
}
|
||||
|
||||
// ── Credits (time-based access) ──────────────────────────────────────────────
|
||||
// ── Credits (question-count-based access) ───────────────────────────────────
|
||||
|
||||
db.exec(`
|
||||
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();
|
||||
db.prepare(
|
||||
"INSERT INTO credits (username, token, tier, order_id) VALUES ($username, $token, $tier, $orderId)"
|
||||
).run({ $username: username, $token: token, $tier: tier, $orderId: 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, $maxQuestions: maxQuestions });
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -146,25 +158,57 @@ export function getCreditByOrder(orderId: string): {
|
||||
username: string;
|
||||
tier: string;
|
||||
expiresAt: number | null;
|
||||
maxQuestions: number | null;
|
||||
questionsUsed: number;
|
||||
} | null {
|
||||
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 {
|
||||
status: string;
|
||||
token: string;
|
||||
username: string;
|
||||
tier: string;
|
||||
expiresAt: number | null;
|
||||
maxQuestions: number | null;
|
||||
questionsUsed: number;
|
||||
} | 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
|
||||
.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.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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user