diff --git a/db.ts b/db.ts index cd80490..8dcad0b 100644 --- a/db.ts +++ b/db.ts @@ -105,7 +105,7 @@ export function getPlayerScores(): Record { 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 }; } diff --git a/frontend.css b/frontend.css index 2b2e9ba..10732a7 100644 --- a/frontend.css +++ b/frontend.css @@ -736,23 +736,24 @@ body { ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } -/* ── Standings footer & branding ─────────────────────────────── */ +/* ── Site footer (inside main) ───────────────────────────────── */ -.standings__footer { - padding-top: 14px; +.site-footer { + flex-shrink: 0; + margin-top: 32px; + padding: 16px 0 4px; border-top: 1px solid var(--border); font-size: 11px; color: var(--text-muted); - line-height: 1.8; - margin-top: auto; + line-height: 1.9; } -.standings__footer a { +.site-footer a { color: var(--text-dim); text-decoration: none; } -.standings__footer a:hover { +.site-footer a:hover { color: var(--text); text-decoration: underline; } diff --git a/frontend.tsx b/frontend.tsx index f943b4c..58a45d2 100644 --- a/frontend.tsx +++ b/frontend.tsx @@ -529,20 +529,6 @@ function Standings({ {Object.keys(playerScores).length > 0 && ( )} -
-

IAs compiten respondiendo preguntas absurdas — los jueces votan, tú también puedes.

-

- por{" "} - - Cloud Host - - {" "}— La web simplificada, la nube gestionada -

-
); } @@ -720,6 +706,18 @@ function App() { )} + +
+

IAs compiten respondiendo preguntas absurdas — los jueces votan, tú también puedes.

+

+ por{" "} + + Cloud Host + + {" "}— La web simplificada, la nube gestionada ·{" "} + propón preguntas +

+
+

IAs compiten respondiendo preguntas absurdas — los jueces votan, tú también puedes.

+

+ por{" "} + + Cloud Host + + {" "}— La web simplificada, la nube gestionada +

+ + ); +} + // ── Main component ──────────────────────────────────────────────────────────── function App() { @@ -134,6 +154,7 @@ function App() { username?: string; expiresAt?: number; tier?: string; + questionsLeft?: number | null; }; if (data.found && data.status === "active" && data.token && data.expiresAt) { const newCredit: CreditInfo = { @@ -141,6 +162,7 @@ function App() { username: data.username ?? "", expiresAt: data.expiresAt, tier: data.tier ?? "", + questionsLeft: data.questionsLeft ?? null, }; localStorage.setItem(STORAGE_KEY, JSON.stringify(newCredit)); setCredit(newCredit); @@ -193,10 +215,15 @@ function App() { if (res.status === 401) { localStorage.removeItem(STORAGE_KEY); setCredit(null); - throw new Error("Tu acceso ha expirado. Compra un nuevo plan."); + throw new Error("Tu acceso ha expirado o se han agotado las preguntas."); } throw new Error(msg || `Error ${res.status}`); } + const data = await res.json() as { ok: boolean; questionsLeft: number | null }; + // Update questionsLeft in state and localStorage + const updated: CreditInfo = { ...credit, questionsLeft: data.questionsLeft }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + setCredit(updated); setText(""); setSent(true); setTimeout(() => setSent(false), 4000); @@ -238,6 +265,7 @@ function App() {
Volver al juego
+ ); @@ -263,6 +291,7 @@ function App() { ) : (
)} +
); @@ -271,7 +300,7 @@ function App() { // ── Active credit — question form ───────────────────────────────────────── if (credit) { - const days = daysLeft(credit.expiresAt); + const exhausted = credit.questionsLeft !== null && credit.questionsLeft <= 0; return (
@@ -279,17 +308,17 @@ function App() { argument.es -
- {days === 0 - ? "Expira hoy" - : `${days} día${days !== 1 ? "s" : ""} restante${days !== 1 ? "s" : ""}`} +
+ {badgeText(credit.questionsLeft)}

Hola, {credit.username}

- Acceso activo hasta el {formatDate(credit.expiresAt)}. - Envía todas las preguntas que quieras. + Acceso activo hasta el {formatDate(credit.expiresAt)}.{" "} + {exhausted + ? "Has agotado tus preguntas para este plan." + : "Envía todas las preguntas que quieras."}

{sent && ( @@ -298,31 +327,41 @@ function App() {
)} -
- -