From e772fb5cc002f607eafd65681f69dc1860696dbe Mon Sep 17 00:00:00 2001 From: Malin Date: Fri, 27 Feb 2026 14:43:25 +0100 Subject: [PATCH] feat: time-based credits, JUGADORES leaderboard, fix footer visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- db.ts | 108 ++++++++++++- frontend.css | 29 ++-- frontend.tsx | 91 +++++++++-- pregunta.css | 136 ++++++++++++++++ pregunta.tsx | 432 +++++++++++++++++++++++++++++++++++++++++---------- server.ts | 178 ++++++++++++++++++++- 6 files changed, 857 insertions(+), 117 deletions(-) diff --git a/db.ts b/db.ts index fe88607..cd80490 100644 --- a/db.ts +++ b/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 { + 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 }; +} diff --git a/frontend.css b/frontend.css index c6021b3..2b2e9ba 100644 --- a/frontend.css +++ b/frontend.css @@ -736,28 +736,38 @@ body { ::-webkit-scrollbar-track { background: transparent; } ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } -/* ── Site Footer ──────────────────────────────────────────────── */ +/* ── Standings footer & branding ─────────────────────────────── */ -.site-footer { - flex-shrink: 0; - padding: 14px 20px; +.standings__footer { + padding-top: 14px; border-top: 1px solid var(--border); - text-align: center; - font-size: 12px; + font-size: 11px; color: var(--text-muted); - line-height: 1.7; + line-height: 1.8; + margin-top: auto; } -.site-footer__link { +.standings__footer a { color: var(--text-dim); text-decoration: none; } -.site-footer__link:hover { +.standings__footer a:hover { color: var(--text); text-decoration: underline; } +/* ── Player leaderboard name ─────────────────────────────────── */ + +.lb-entry__name { + font-size: 12px; + font-weight: 500; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* ── Desktop (1024px+) ───────────────────────────────────────── */ @media (min-width: 1024px) { @@ -814,5 +824,4 @@ body { gap: 24px; } - .site-footer { display: none; } } diff --git a/frontend.tsx b/frontend.tsx index 24dca83..f943b4c 100644 --- a/frontend.tsx +++ b/frontend.tsx @@ -450,13 +450,50 @@ function LeaderboardSection({ ); } +function PlayerLeaderboard({ scores }: { scores: Record }) { + const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]).slice(0, 7); + const maxScore = sorted[0]?.[1] || 1; + return ( +
+
+ Jugadores + Jugar +
+
+ {sorted.map(([name, score], i) => { + const pct = Math.round((score / maxScore) * 100); + return ( +
+
+ + {i === 0 && score > 0 ? "🏆" : i + 1} + + {name} + {score} +
+
+
+
+
+ ); + })} +
+
+ ); +} + function Standings({ scores, viewerScores, + playerScores, activeRound, }: { scores: Record; viewerScores: Record; + playerScores: Record; activeRound: RoundState | null; }) { const competing = activeRound @@ -489,6 +526,23 @@ function Standings({ scores={viewerScores} competing={competing} /> + {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 +

+
); } @@ -519,6 +573,21 @@ function App() { const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0); const [myVote, setMyVote] = useState<"A" | "B" | null>(null); const lastVotedRoundRef = useRef(null); + const [playerScores, setPlayerScores] = useState>({}); + + useEffect(() => { + async function fetchPlayerScores() { + try { + const res = await fetch("/api/jugadores"); + if (res.ok) setPlayerScores(await res.json()); + } catch { + // ignore + } + } + fetchPlayerScores(); + const interval = setInterval(fetchPlayerScores, 60_000); + return () => clearInterval(interval); + }, []); // Countdown timer for viewer voting useEffect(() => { @@ -653,23 +722,13 @@ function App() { )} - +
-
-

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

-

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

-
); } diff --git a/pregunta.css b/pregunta.css index f063064..d4c4742 100644 --- a/pregunta.css +++ b/pregunta.css @@ -173,6 +173,7 @@ body { .pregunta__links { display: flex; gap: 16px; + align-items: center; justify-content: center; } @@ -185,3 +186,138 @@ body { .pregunta__links a:hover { color: var(--text-dim); } + +.pregunta__links-sep { + color: var(--text-muted); + font-size: 13px; +} + +.pregunta__link-btn { + background: none; + border: none; + padding: 0; + color: var(--text-muted); + font-size: 13px; + font-family: var(--sans); + cursor: pointer; +} + +.pregunta__link-btn:hover { + color: var(--text-dim); +} + +/* ── Header row (logo + credit badge) ─────────────────────────── */ + +.pregunta__header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.pregunta__credit-badge { + font-size: 12px; + font-weight: 600; + color: #4caf7d; + background: rgba(76, 175, 125, 0.12); + border: 1px solid rgba(76, 175, 125, 0.25); + border-radius: 20px; + padding: 4px 12px; + white-space: nowrap; +} + +/* ── Success banner ────────────────────────────────────────────── */ + +.pregunta__success { + background: rgba(76, 175, 125, 0.1); + border: 1px solid rgba(76, 175, 125, 0.25); + border-radius: 6px; + padding: 10px 14px; + color: #4caf7d; + font-size: 13px; + font-weight: 500; +} + +/* ── Username input ────────────────────────────────────────────── */ + +.pregunta__input { + width: 100%; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + color: var(--text); + font-family: var(--sans); + font-size: 14px; + line-height: 1.5; + transition: border-color 0.15s; +} + +.pregunta__input:focus { + outline: none; + border-color: #444; +} + +.pregunta__input::placeholder { + color: var(--text-muted); +} + +/* ── Tier cards ────────────────────────────────────────────────── */ + +.tier-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +.tier-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 18px 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + cursor: pointer; + font-family: var(--sans); + color: var(--text); + transition: border-color 0.15s, background 0.15s; +} + +.tier-card:hover { + border-color: #444; +} + +.tier-card--selected { + border-color: var(--accent); + background: rgba(217, 119, 87, 0.08); +} + +.tier-card__price { + font-size: 22px; + font-weight: 700; + color: var(--accent); +} + +.tier-card__label { + font-size: 12px; + color: var(--text-dim); +} + +/* ── Spinner ───────────────────────────────────────────────────── */ + +.pregunta__spinner { + width: 32px; + height: 32px; + border: 3px solid var(--border); + border-top-color: var(--accent); + border-radius: 50%; + animation: pregunta-spin 0.8s linear infinite; + margin: 8px auto; +} + +@keyframes pregunta-spin { + to { transform: rotate(360deg); } +} diff --git a/pregunta.tsx b/pregunta.tsx index a872bac..7226aea 100644 --- a/pregunta.tsx +++ b/pregunta.tsx @@ -1,33 +1,228 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { createRoot } from "react-dom/client"; import "./pregunta.css"; +// ── Types & constants ───────────────────────────────────────────────────────── + +type CreditInfo = { + token: string; + username: string; + expiresAt: number; + tier: string; +}; + +const STORAGE_KEY = "argumentes_credito"; + +const TIERS = [ + { id: "dia", label: "1 día", price: "1€", days: 1 }, + { id: "semana", label: "1 semana", price: "5€", days: 7 }, + { id: "mes", label: "1 mes", price: "15€", days: 30 }, +] as const; + +type TierId = (typeof TIERS)[number]["id"]; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function loadCredit(): CreditInfo | null { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return null; + const c = JSON.parse(raw) as CreditInfo; + if (!c.token || !c.expiresAt || c.expiresAt < Date.now()) { + localStorage.removeItem(STORAGE_KEY); + return null; + } + return c; + } catch { + return null; + } +} + +function formatDate(ms: number): string { + return new Date(ms).toLocaleDateString("es-ES", { + day: "numeric", + month: "long", + year: "numeric", + }); +} + +function daysLeft(expiresAt: number): number { + return Math.max(0, Math.ceil((expiresAt - Date.now()) / (1000 * 60 * 60 * 24))); +} + +async function submitRedsysForm(data: { + tpvUrl: string; + merchantParams: string; + signature: string; + signatureVersion: string; +}) { + const form = document.createElement("form"); + form.method = "POST"; + form.action = data.tpvUrl; + for (const [name, value] of Object.entries({ + Ds_SignatureVersion: data.signatureVersion, + Ds_MerchantParameters: data.merchantParams, + Ds_Signature: data.signature, + })) { + const input = document.createElement("input"); + input.type = "hidden"; + input.name = name; + input.value = value; + form.appendChild(input); + } + document.body.appendChild(form); + form.submit(); +} + +// ── Main component ──────────────────────────────────────────────────────────── + function App() { const params = new URLSearchParams(window.location.search); - const isOk = params.get("ok") === "1"; + const creditOkOrder = params.get("credito_ok"); const isKo = params.get("ko") === "1"; + // Credit state + const [credit, setCredit] = useState(null); + const [loaded, setLoaded] = useState(false); + + // Credit verification (polling after Redsys redirect) + const [verifying, setVerifying] = useState(false); + const [verifyError, setVerifyError] = useState(false); + + // Purchase flow + const [selectedTier, setSelectedTier] = useState(null); + const [username, setUsername] = useState(""); + const [buying, setBuying] = useState(false); + const [buyError, setBuyError] = useState(null); + + // Question submission const [text, setText] = useState(""); const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); + const [submitError, setSubmitError] = useState(null); + const [sent, setSent] = useState(false); - if (isOk) { + // Load credit from localStorage on mount + useEffect(() => { + setCredit(loadCredit()); + setLoaded(true); + }, []); + + // Poll for credit activation after Redsys redirect + useEffect(() => { + if (!creditOkOrder || !loaded || credit) return; + + setVerifying(true); + let attempts = 0; + const maxAttempts = 15; + + async function poll() { + if (attempts >= maxAttempts) { + setVerifying(false); + setVerifyError(true); + return; + } + attempts++; + try { + const res = await fetch( + `/api/credito/estado?order=${encodeURIComponent(creditOkOrder!)}`, + ); + if (res.ok) { + const data = (await res.json()) as { + found: boolean; + status?: string; + token?: string; + username?: string; + expiresAt?: number; + tier?: string; + }; + if (data.found && data.status === "active" && data.token && data.expiresAt) { + const newCredit: CreditInfo = { + token: data.token, + username: data.username ?? "", + expiresAt: data.expiresAt, + tier: data.tier ?? "", + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(newCredit)); + setCredit(newCredit); + setVerifying(false); + history.replaceState(null, "", "/pregunta"); + return; + } + } + } catch { + // retry + } + setTimeout(poll, 2000); + } + + poll(); + }, [creditOkOrder, loaded, credit]); + + async function handleBuyCredit(e: React.FormEvent) { + e.preventDefault(); + if (!selectedTier) return; + setBuyError(null); + setBuying(true); + try { + const res = await fetch("/api/credito/iniciar", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tier: selectedTier, username: username.trim() }), + }); + if (!res.ok) throw new Error(await res.text()); + await submitRedsysForm(await res.json()); + } catch (err) { + setBuyError(err instanceof Error ? err.message : "Error al procesar el pago"); + setBuying(false); + } + } + + async function handleSubmitQuestion(e: React.FormEvent) { + e.preventDefault(); + if (!credit) return; + setSubmitError(null); + setSubmitting(true); + try { + const res = await fetch("/api/pregunta/enviar", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text: text.trim(), token: credit.token }), + }); + if (!res.ok) { + const msg = await res.text(); + if (res.status === 401) { + localStorage.removeItem(STORAGE_KEY); + setCredit(null); + throw new Error("Tu acceso ha expirado. Compra un nuevo plan."); + } + throw new Error(msg || `Error ${res.status}`); + } + setText(""); + setSent(true); + setTimeout(() => setSent(false), 4000); + } catch (err) { + setSubmitError(err instanceof Error ? err.message : "Error al enviar"); + } finally { + setSubmitting(false); + } + } + + // ── Loading ─────────────────────────────────────────────────────────────── + + if (!loaded) { return (
argument.es -

¡Pregunta enviada!

-

- Tu pregunta se usará en el próximo sorteo entre las IAs. ¡Gracias por participar! -

- Volver al juego
); } + // ── Payment failed ──────────────────────────────────────────────────────── + if (isKo) { return (
@@ -37,7 +232,7 @@ function App() {

Pago cancelado

- El pago no se completó. Tu pregunta no ha sido guardada. + El pago no se completó. Tu acceso no ha sido activado.

Intentar de nuevo
@@ -48,90 +243,165 @@ function App() { ); } - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setError(null); - setSubmitting(true); - try { - const res = await fetch("/api/pregunta/iniciar", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ text: text.trim() }), - }); - if (!res.ok) { - const msg = await res.text(); - throw new Error(msg || `Error ${res.status}`); - } - const data = (await res.json()) as { - tpvUrl: string; - merchantParams: string; - signature: string; - signatureVersion: string; - }; + // ── Verifying payment ───────────────────────────────────────────────────── - // Build and auto-submit the Redsys payment form - const form = document.createElement("form"); - form.method = "POST"; - form.action = data.tpvUrl; - for (const [name, value] of Object.entries({ - Ds_SignatureVersion: data.signatureVersion, - Ds_MerchantParameters: data.merchantParams, - Ds_Signature: data.signature, - })) { - const input = document.createElement("input"); - input.type = "hidden"; - input.name = name; - input.value = value; - form.appendChild(input); - } - document.body.appendChild(form); - form.submit(); - } catch (err) { - setError(err instanceof Error ? err.message : "Error al procesar la solicitud"); - setSubmitting(false); - } + if (verifying || (creditOkOrder && !credit)) { + return ( +
+
+ + argument.es + +

Verificando tu pago…

+

+ {verifyError + ? "No se pudo confirmar el pago. Si se completó, espera unos segundos y recarga." + : "Esto puede tardar unos segundos."} +

+ {verifyError ? ( + Volver + ) : ( +
+ )} +
+
+ ); } + // ── Active credit — question form ───────────────────────────────────────── + + if (credit) { + const days = daysLeft(credit.expiresAt); + return ( +
+
+
+ + argument.es + +
+ {days === 0 + ? "Expira hoy" + : `${days} día${days !== 1 ? "s" : ""} restante${days !== 1 ? "s" : ""}`} +
+
+ +

Hola, {credit.username}

+

+ Acceso activo hasta el {formatDate(credit.expiresAt)}. + Envía todas las preguntas que quieras. +

+ + {sent && ( +
+ ✓ ¡Pregunta enviada! Se usará en el próximo sorteo. +
+ )} + +
+ +