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]));
|
return Object.fromEntries(rows.map(r => [r.username, r.score]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Credits (time-based access) ──────────────────────────────────────────────
|
// ── Credits (question-count-based access) ───────────────────────────────────
|
||||||
|
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS credits (
|
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();
|
const token = crypto.randomUUID();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"INSERT INTO credits (username, token, tier, order_id) VALUES ($username, $token, $tier, $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 });
|
).run({ $username: username, $token: token, $tier: tier, $orderId: orderId, $maxQuestions: maxQuestions });
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,25 +158,57 @@ export function getCreditByOrder(orderId: string): {
|
|||||||
username: string;
|
username: string;
|
||||||
tier: string;
|
tier: string;
|
||||||
expiresAt: number | null;
|
expiresAt: number | null;
|
||||||
|
maxQuestions: number | null;
|
||||||
|
questionsUsed: number;
|
||||||
} | null {
|
} | null {
|
||||||
return db
|
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 {
|
.get({ $orderId: orderId }) as {
|
||||||
status: string;
|
status: string;
|
||||||
token: string;
|
token: string;
|
||||||
username: string;
|
username: string;
|
||||||
tier: string;
|
tier: string;
|
||||||
expiresAt: number | null;
|
expiresAt: number | null;
|
||||||
|
maxQuestions: number | null;
|
||||||
|
questionsUsed: number;
|
||||||
} | null;
|
} | 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
|
const row = db
|
||||||
.query(
|
.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) return null;
|
||||||
if (row.expires_at < Date.now()) 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 };
|
||||||
}
|
}
|
||||||
|
|||||||
15
frontend.css
15
frontend.css
@@ -736,23 +736,24 @@ body {
|
|||||||
::-webkit-scrollbar-track { background: transparent; }
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||||
|
|
||||||
/* ── Standings footer & branding ─────────────────────────────── */
|
/* ── Site footer (inside main) ───────────────────────────────── */
|
||||||
|
|
||||||
.standings__footer {
|
.site-footer {
|
||||||
padding-top: 14px;
|
flex-shrink: 0;
|
||||||
|
margin-top: 32px;
|
||||||
|
padding: 16px 0 4px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
line-height: 1.8;
|
line-height: 1.9;
|
||||||
margin-top: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.standings__footer a {
|
.site-footer a {
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.standings__footer a:hover {
|
.site-footer a:hover {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|||||||
26
frontend.tsx
26
frontend.tsx
@@ -529,20 +529,6 @@ function Standings({
|
|||||||
{Object.keys(playerScores).length > 0 && (
|
{Object.keys(playerScores).length > 0 && (
|
||||||
<PlayerLeaderboard scores={playerScores} />
|
<PlayerLeaderboard scores={playerScores} />
|
||||||
)}
|
)}
|
||||||
<div className="standings__footer">
|
|
||||||
<p>IAs compiten respondiendo preguntas absurdas — los jueces votan, tú también puedes.</p>
|
|
||||||
<p>
|
|
||||||
por{" "}
|
|
||||||
<a
|
|
||||||
href="https://cloudhost.es"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Cloud Host
|
|
||||||
</a>
|
|
||||||
{" "}— La web simplificada, la nube gestionada
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -720,6 +706,18 @@ function App() {
|
|||||||
<Dots />
|
<Dots />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<footer className="site-footer">
|
||||||
|
<p>IAs compiten respondiendo preguntas absurdas — los jueces votan, tú también puedes.</p>
|
||||||
|
<p>
|
||||||
|
por{" "}
|
||||||
|
<a href="https://cloudhost.es" target="_blank" rel="noopener noreferrer">
|
||||||
|
Cloud Host
|
||||||
|
</a>
|
||||||
|
{" "}— La web simplificada, la nube gestionada ·{" "}
|
||||||
|
<a href="/pregunta">propón preguntas</a>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Standings
|
<Standings
|
||||||
|
|||||||
38
pregunta.css
38
pregunta.css
@@ -226,6 +226,12 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pregunta__credit-badge--empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-color: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Success banner ────────────────────────────────────────────── */
|
/* ── Success banner ────────────────────────────────────────────── */
|
||||||
|
|
||||||
.pregunta__success {
|
.pregunta__success {
|
||||||
@@ -296,14 +302,20 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tier-card__price {
|
.tier-card__price {
|
||||||
font-size: 22px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tier-card__label {
|
.tier-card__label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-dim);
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tier-card__sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Spinner ───────────────────────────────────────────────────── */
|
/* ── Spinner ───────────────────────────────────────────────────── */
|
||||||
@@ -321,3 +333,25 @@ body {
|
|||||||
@keyframes pregunta-spin {
|
@keyframes pregunta-spin {
|
||||||
to { transform: rotate(360deg); }
|
to { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Site footer ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.pregunta__footer {
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.9;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__footer a {
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pregunta__footer a:hover {
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|||||||
72
pregunta.tsx
72
pregunta.tsx
@@ -9,14 +9,15 @@ type CreditInfo = {
|
|||||||
username: string;
|
username: string;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
tier: string;
|
tier: string;
|
||||||
|
questionsLeft: number | null; // null = unlimited
|
||||||
};
|
};
|
||||||
|
|
||||||
const STORAGE_KEY = "argumentes_credito";
|
const STORAGE_KEY = "argumentes_credito";
|
||||||
|
|
||||||
const TIERS = [
|
const TIERS = [
|
||||||
{ id: "dia", label: "1 día", price: "1€", days: 1 },
|
{ id: "basico", label: "10 preguntas", sublabel: "30 días", price: "0,99€", maxQuestions: 10 },
|
||||||
{ id: "semana", label: "1 semana", price: "5€", days: 7 },
|
{ id: "pro", label: "200 preguntas", sublabel: "30 días", price: "9,99€", maxQuestions: 200 },
|
||||||
{ id: "mes", label: "1 mes", price: "15€", days: 30 },
|
{ id: "ilimitado", label: "Ilimitadas", sublabel: "30 días", price: "19,99€", maxQuestions: null },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type TierId = (typeof TIERS)[number]["id"];
|
type TierId = (typeof TIERS)[number]["id"];
|
||||||
@@ -46,8 +47,10 @@ function formatDate(ms: number): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function daysLeft(expiresAt: number): number {
|
function badgeText(questionsLeft: number | null): string {
|
||||||
return Math.max(0, Math.ceil((expiresAt - Date.now()) / (1000 * 60 * 60 * 24)));
|
if (questionsLeft === null) return "Preguntas ilimitadas";
|
||||||
|
if (questionsLeft === 0) return "Sin preguntas restantes";
|
||||||
|
return `${questionsLeft} pregunta${questionsLeft !== 1 ? "s" : ""} restante${questionsLeft !== 1 ? "s" : ""}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitRedsysForm(data: {
|
async function submitRedsysForm(data: {
|
||||||
@@ -74,6 +77,23 @@ async function submitRedsysForm(data: {
|
|||||||
form.submit();
|
form.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Footer ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SiteFooter() {
|
||||||
|
return (
|
||||||
|
<div className="pregunta__footer">
|
||||||
|
<p>IAs compiten respondiendo preguntas absurdas — los jueces votan, tú también puedes.</p>
|
||||||
|
<p>
|
||||||
|
por{" "}
|
||||||
|
<a href="https://cloudhost.es" target="_blank" rel="noopener noreferrer">
|
||||||
|
Cloud Host
|
||||||
|
</a>
|
||||||
|
{" "}— La web simplificada, la nube gestionada
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Main component ────────────────────────────────────────────────────────────
|
// ── Main component ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -134,6 +154,7 @@ function App() {
|
|||||||
username?: string;
|
username?: string;
|
||||||
expiresAt?: number;
|
expiresAt?: number;
|
||||||
tier?: string;
|
tier?: string;
|
||||||
|
questionsLeft?: number | null;
|
||||||
};
|
};
|
||||||
if (data.found && data.status === "active" && data.token && data.expiresAt) {
|
if (data.found && data.status === "active" && data.token && data.expiresAt) {
|
||||||
const newCredit: CreditInfo = {
|
const newCredit: CreditInfo = {
|
||||||
@@ -141,6 +162,7 @@ function App() {
|
|||||||
username: data.username ?? "",
|
username: data.username ?? "",
|
||||||
expiresAt: data.expiresAt,
|
expiresAt: data.expiresAt,
|
||||||
tier: data.tier ?? "",
|
tier: data.tier ?? "",
|
||||||
|
questionsLeft: data.questionsLeft ?? null,
|
||||||
};
|
};
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(newCredit));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(newCredit));
|
||||||
setCredit(newCredit);
|
setCredit(newCredit);
|
||||||
@@ -193,10 +215,15 @@ function App() {
|
|||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
setCredit(null);
|
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}`);
|
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("");
|
setText("");
|
||||||
setSent(true);
|
setSent(true);
|
||||||
setTimeout(() => setSent(false), 4000);
|
setTimeout(() => setSent(false), 4000);
|
||||||
@@ -238,6 +265,7 @@ function App() {
|
|||||||
<div className="pregunta__links">
|
<div className="pregunta__links">
|
||||||
<a href="/">Volver al juego</a>
|
<a href="/">Volver al juego</a>
|
||||||
</div>
|
</div>
|
||||||
|
<SiteFooter />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -263,6 +291,7 @@ function App() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="pregunta__spinner" />
|
<div className="pregunta__spinner" />
|
||||||
)}
|
)}
|
||||||
|
<SiteFooter />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -271,7 +300,7 @@ function App() {
|
|||||||
// ── Active credit — question form ─────────────────────────────────────────
|
// ── Active credit — question form ─────────────────────────────────────────
|
||||||
|
|
||||||
if (credit) {
|
if (credit) {
|
||||||
const days = daysLeft(credit.expiresAt);
|
const exhausted = credit.questionsLeft !== null && credit.questionsLeft <= 0;
|
||||||
return (
|
return (
|
||||||
<div className="pregunta">
|
<div className="pregunta">
|
||||||
<div className="pregunta__panel">
|
<div className="pregunta__panel">
|
||||||
@@ -279,17 +308,17 @@ function App() {
|
|||||||
<a href="/" className="pregunta__logo">
|
<a href="/" className="pregunta__logo">
|
||||||
<img src="/assets/logo.svg" alt="argument.es" />
|
<img src="/assets/logo.svg" alt="argument.es" />
|
||||||
</a>
|
</a>
|
||||||
<div className="pregunta__credit-badge">
|
<div className={`pregunta__credit-badge ${exhausted ? "pregunta__credit-badge--empty" : ""}`}>
|
||||||
{days === 0
|
{badgeText(credit.questionsLeft)}
|
||||||
? "Expira hoy"
|
|
||||||
: `${days} día${days !== 1 ? "s" : ""} restante${days !== 1 ? "s" : ""}`}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1>Hola, {credit.username}</h1>
|
<h1>Hola, {credit.username}</h1>
|
||||||
<p className="pregunta__sub">
|
<p className="pregunta__sub">
|
||||||
Acceso activo hasta el {formatDate(credit.expiresAt)}.
|
Acceso activo hasta el {formatDate(credit.expiresAt)}.{" "}
|
||||||
Envía todas las preguntas que quieras.
|
{exhausted
|
||||||
|
? "Has agotado tus preguntas para este plan."
|
||||||
|
: "Envía todas las preguntas que quieras."}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{sent && (
|
{sent && (
|
||||||
@@ -298,6 +327,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!exhausted && (
|
||||||
<form onSubmit={handleSubmitQuestion} className="pregunta__form">
|
<form onSubmit={handleSubmitQuestion} className="pregunta__form">
|
||||||
<label htmlFor="pregunta-text" className="pregunta__label">
|
<label htmlFor="pregunta-text" className="pregunta__label">
|
||||||
Tu pregunta (frase de completar)
|
Tu pregunta (frase de completar)
|
||||||
@@ -323,6 +353,15 @@ function App() {
|
|||||||
{submitting ? "Enviando…" : "Enviar pregunta"}
|
{submitting ? "Enviando…" : "Enviar pregunta"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{exhausted && (
|
||||||
|
<a href="/pregunta" className="pregunta__btn" onClick={() => {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}}>
|
||||||
|
Comprar nuevo plan
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="pregunta__links">
|
<div className="pregunta__links">
|
||||||
<a href="/">Ver el juego</a>
|
<a href="/">Ver el juego</a>
|
||||||
@@ -337,6 +376,7 @@ function App() {
|
|||||||
Cerrar sesión
|
Cerrar sesión
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<SiteFooter />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -355,8 +395,8 @@ function App() {
|
|||||||
|
|
||||||
<h1>Propón preguntas al juego</h1>
|
<h1>Propón preguntas al juego</h1>
|
||||||
<p className="pregunta__sub">
|
<p className="pregunta__sub">
|
||||||
Compra acceso por tiempo y envía todas las preguntas que quieras.
|
Compra acceso por 30 días y envía preguntas ilimitadas o un paquete.
|
||||||
Las mejores preguntas se usan en lugar de las generadas por IA y
|
Las mejores se usan en lugar de las generadas por IA y
|
||||||
aparecerás en el marcador de Jugadores.
|
aparecerás en el marcador de Jugadores.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -370,6 +410,7 @@ function App() {
|
|||||||
>
|
>
|
||||||
<div className="tier-card__price">{tier.price}</div>
|
<div className="tier-card__price">{tier.price}</div>
|
||||||
<div className="tier-card__label">{tier.label}</div>
|
<div className="tier-card__label">{tier.label}</div>
|
||||||
|
<div className="tier-card__sub">{tier.sublabel}</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -406,6 +447,7 @@ function App() {
|
|||||||
<div className="pregunta__links">
|
<div className="pregunta__links">
|
||||||
<a href="/">Volver al juego</a>
|
<a href="/">Volver al juego</a>
|
||||||
</div>
|
</div>
|
||||||
|
<SiteFooter />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
39
server.ts
39
server.ts
@@ -7,8 +7,8 @@ import broadcastHtml from "./broadcast.html";
|
|||||||
import preguntaHtml from "./pregunta.html";
|
import preguntaHtml from "./pregunta.html";
|
||||||
import {
|
import {
|
||||||
clearAllRounds, getRounds, getAllRounds,
|
clearAllRounds, getRounds, getAllRounds,
|
||||||
createPendingQuestion, markQuestionPaid, createPaidQuestion,
|
createPendingQuestion, markQuestionPaid,
|
||||||
createPendingCredit, activateCredit, getCreditByOrder, validateCreditToken,
|
createPendingCredit, activateCredit, getCreditByOrder, consumeCreditQuestion,
|
||||||
getPlayerScores,
|
getPlayerScores,
|
||||||
} from "./db.ts";
|
} from "./db.ts";
|
||||||
import { buildPaymentForm, verifyNotification, decodeParams, isPaymentApproved } from "./redsys.ts";
|
import { buildPaymentForm, verifyNotification, decodeParams, isPaymentApproved } from "./redsys.ts";
|
||||||
@@ -116,10 +116,10 @@ const VIEWER_VOTE_BROADCAST_DEBOUNCE_MS = parsePositiveInt(
|
|||||||
const AUTOPAUSE_DELAY_MS = parsePositiveInt(process.env.AUTOPAUSE_DELAY_MS, 60_000);
|
const AUTOPAUSE_DELAY_MS = parsePositiveInt(process.env.AUTOPAUSE_DELAY_MS, 60_000);
|
||||||
const ADMIN_COOKIE = "argumentes_admin";
|
const ADMIN_COOKIE = "argumentes_admin";
|
||||||
|
|
||||||
const CREDIT_TIERS: Record<string, { days: number; amount: number; label: string }> = {
|
const CREDIT_TIERS: Record<string, { days: number; amount: number; label: string; maxQuestions: number | null }> = {
|
||||||
dia: { days: 1, amount: 100, label: "1 día" },
|
basico: { days: 30, amount: 99, label: "10 preguntas", maxQuestions: 10 },
|
||||||
semana: { days: 7, amount: 500, label: "1 semana" },
|
pro: { days: 30, amount: 999, label: "200 preguntas", maxQuestions: 200 },
|
||||||
mes: { days: 30, amount: 1500, label: "1 mes" },
|
ilimitado: { days: 30, amount: 1999, label: "Preguntas ilimitadas", maxQuestions: null },
|
||||||
};
|
};
|
||||||
const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
|
const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
|
||||||
|
|
||||||
@@ -613,14 +613,14 @@ const server = Bun.serve<WsData>({
|
|||||||
|
|
||||||
const tierInfo = CREDIT_TIERS[tier];
|
const tierInfo = CREDIT_TIERS[tier];
|
||||||
if (!tierInfo) {
|
if (!tierInfo) {
|
||||||
return new Response("Tier inválido (dia | semana | mes)", { status: 400 });
|
return new Response("Tier inválido (basico | pro | ilimitado)", { status: 400 });
|
||||||
}
|
}
|
||||||
if (!username || username.length < 1 || username.length > 30) {
|
if (!username || username.length < 1 || username.length > 30) {
|
||||||
return new Response("El nombre debe tener entre 1 y 30 caracteres", { status: 400 });
|
return new Response("El nombre debe tener entre 1 y 30 caracteres", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderId = String(Date.now()).slice(-12);
|
const orderId = String(Date.now()).slice(-12);
|
||||||
createPendingCredit(username, orderId, tier);
|
createPendingCredit(username, orderId, tier, tierInfo.maxQuestions);
|
||||||
|
|
||||||
const isTest = process.env.REDSYS_TEST !== "false";
|
const isTest = process.env.REDSYS_TEST !== "false";
|
||||||
const terminal = process.env.REDSYS_TERMINAL ?? "1";
|
const terminal = process.env.REDSYS_TERMINAL ?? "1";
|
||||||
@@ -664,12 +664,20 @@ const server = Bun.serve<WsData>({
|
|||||||
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
|
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const questionsLeft =
|
||||||
|
credit.maxQuestions === null ? null : credit.maxQuestions - credit.questionsUsed;
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
found: true,
|
found: true,
|
||||||
status: credit.status,
|
status: credit.status,
|
||||||
...(credit.status === "active"
|
...(credit.status === "active"
|
||||||
? { token: credit.token, username: credit.username, expiresAt: credit.expiresAt, tier: credit.tier }
|
? {
|
||||||
|
token: credit.token,
|
||||||
|
username: credit.username,
|
||||||
|
expiresAt: credit.expiresAt,
|
||||||
|
tier: credit.tier,
|
||||||
|
questionsLeft,
|
||||||
|
}
|
||||||
: {}),
|
: {}),
|
||||||
}),
|
}),
|
||||||
{ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } },
|
{ headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } },
|
||||||
@@ -700,17 +708,16 @@ const server = Bun.serve<WsData>({
|
|||||||
if (!token) {
|
if (!token) {
|
||||||
return new Response("Token requerido", { status: 401 });
|
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) {
|
if (text.length < 10 || text.length > 200) {
|
||||||
return new Response("La pregunta debe tener entre 10 y 200 caracteres", { status: 400 });
|
return new Response("La pregunta debe tener entre 10 y 200 caracteres", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
createPaidQuestion(text, credit.username);
|
const result = consumeCreditQuestion(token, text);
|
||||||
log("INFO", "pregunta", "Question submitted via credit", { username: credit.username, ip });
|
if (!result) {
|
||||||
return new Response(JSON.stringify({ ok: true }), {
|
return new Response("Crédito no válido, expirado o sin preguntas disponibles", { status: 401 });
|
||||||
|
}
|
||||||
|
log("INFO", "pregunta", "Question submitted via credit", { username: result.username, ip });
|
||||||
|
return new Response(JSON.stringify({ ok: true, questionsLeft: result.questionsLeft }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
|
headers: { "Content-Type": "application/json", "Cache-Control": "no-store" },
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user