feat: users answer alongside AI instead of proposing questions
- Core mechanic change: users now submit answers to the live prompt, competing alongside AI models; answers broadcast to all viewers - New pricing (no time limit, pure count-based, refillable): 0,99€ = 10 resp · 9,99€ = 300 resp · 19,99€ = 1000 resp - DB: new user_answers table; submitUserAnswer() atomically validates credit, inserts answer, decrements budget; JUGADORES leaderboard now scores by user_answers count - server: /api/respuesta/enviar endpoint; credit activation sets expires_at 10 years out (effectively no expiry); answers injected into live round state and broadcast via WebSocket - frontend: ProposeAnswer widget shows current prompt, textarea active during answering phase, tracks per-round submission state; Arena shows Respuestas del público section live Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
46
db.ts
46
db.ts
@@ -95,17 +95,30 @@ export function markQuestionUsed(id: number): void {
|
|||||||
db.prepare("UPDATE questions SET status = 'used' WHERE id = $id").run({ $id: id });
|
db.prepare("UPDATE questions SET status = 'used' WHERE id = $id").run({ $id: id });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Top 7 players by number of questions used, excluding anonymous. */
|
/** Top 7 players by number of answers submitted, excluding anonymous. */
|
||||||
export function getPlayerScores(): Record<string, number> {
|
export function getPlayerScores(): Record<string, number> {
|
||||||
const rows = db
|
const rows = db
|
||||||
.query(
|
.query(
|
||||||
"SELECT username, COUNT(*) as score FROM questions WHERE status = 'used' AND username != '' GROUP BY username ORDER BY score DESC LIMIT 7"
|
"SELECT username, COUNT(*) as score FROM user_answers WHERE username != '' GROUP BY username ORDER BY score DESC LIMIT 7"
|
||||||
)
|
)
|
||||||
.all() as { username: string; score: number }[];
|
.all() as { username: string; score: number }[];
|
||||||
return Object.fromEntries(rows.map(r => [r.username, r.score]));
|
return Object.fromEntries(rows.map(r => [r.username, r.score]));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Credits (question-count-based access) ───────────────────────────────────
|
// ── User answers (submitted during live rounds) ──────────────────────────────
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS user_answers (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
round_num INTEGER NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// ── Credits (answer-count-based access) ──────────────────────────────────────
|
||||||
|
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS credits (
|
CREATE TABLE IF NOT EXISTS credits (
|
||||||
@@ -177,38 +190,35 @@ export function getCreditByOrder(orderId: string): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Atomically validates a credit token, creates a paid question, and increments
|
* Atomically validates a credit token, records a user answer for the given
|
||||||
* the usage counter. Returns null if the token is invalid, expired, or exhausted.
|
* round, and decrements the answer budget. Returns null if the token is
|
||||||
|
* invalid or exhausted.
|
||||||
*/
|
*/
|
||||||
export function consumeCreditQuestion(
|
export function submitUserAnswer(
|
||||||
token: string,
|
token: string,
|
||||||
|
roundNum: number,
|
||||||
text: string,
|
text: string,
|
||||||
): { username: string; questionsLeft: number | null } | null {
|
): { username: string; answersLeft: number } | null {
|
||||||
const row = db
|
const row = db
|
||||||
.query(
|
.query(
|
||||||
"SELECT username, expires_at, max_questions, questions_used FROM credits WHERE token = $token AND status = 'active'"
|
"SELECT username, max_questions, questions_used FROM credits WHERE token = $token AND status = 'active'"
|
||||||
)
|
)
|
||||||
.get({ $token: token }) as {
|
.get({ $token: token }) as {
|
||||||
username: string;
|
username: string;
|
||||||
expires_at: number;
|
max_questions: number;
|
||||||
max_questions: number | null;
|
|
||||||
questions_used: number;
|
questions_used: number;
|
||||||
} | null;
|
} | null;
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
if (row.expires_at < Date.now()) return null;
|
if (row.questions_used >= row.max_questions) return null;
|
||||||
if (row.max_questions !== null && row.questions_used >= row.max_questions) return null;
|
|
||||||
|
|
||||||
const orderId = crypto.randomUUID();
|
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"INSERT INTO questions (text, order_id, username, status) VALUES ($text, $orderId, $username, 'paid')"
|
"INSERT INTO user_answers (round_num, text, username, token) VALUES ($roundNum, $text, $username, $token)"
|
||||||
).run({ $text: text, $orderId: orderId, $username: row.username });
|
).run({ $roundNum: roundNum, $text: text, $username: row.username, $token: token });
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"UPDATE credits SET questions_used = questions_used + 1 WHERE token = $token"
|
"UPDATE credits SET questions_used = questions_used + 1 WHERE token = $token"
|
||||||
).run({ $token: token });
|
).run({ $token: token });
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const questionsLeft =
|
return { username: row.username, answersLeft: row.max_questions - row.questions_used - 1 };
|
||||||
row.max_questions === null ? null : row.max_questions - row.questions_used - 1;
|
|
||||||
return { username: row.username, questionsLeft };
|
|
||||||
}
|
}
|
||||||
|
|||||||
48
frontend.css
48
frontend.css
@@ -769,7 +769,53 @@ body {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Propose Question widget ─────────────────────────────────── */
|
/* ── User answers (audience) ─────────────────────────────────── */
|
||||||
|
|
||||||
|
.user-answers {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-answers__label {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-answers__list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-answer {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-answer__name {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-answer__sep {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-answer__text {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Propose Answer widget ───────────────────────────────────── */
|
||||||
|
|
||||||
.propose {
|
.propose {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
130
frontend.tsx
130
frontend.tsx
@@ -33,6 +33,7 @@ type RoundState = {
|
|||||||
viewerVotesA?: number;
|
viewerVotesA?: number;
|
||||||
viewerVotesB?: number;
|
viewerVotesB?: number;
|
||||||
viewerVotingEndsAt?: number;
|
viewerVotingEndsAt?: number;
|
||||||
|
userAnswers?: { username: string; text: string }[];
|
||||||
};
|
};
|
||||||
type GameState = {
|
type GameState = {
|
||||||
lastCompleted: RoundState | null;
|
lastCompleted: RoundState | null;
|
||||||
@@ -64,15 +65,15 @@ type CreditInfo = {
|
|||||||
username: string;
|
username: string;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
tier: string;
|
tier: string;
|
||||||
questionsLeft: number | null;
|
answersLeft: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CREDIT_STORAGE_KEY = "argumentes_credito";
|
const CREDIT_STORAGE_KEY = "argumentes_credito";
|
||||||
|
|
||||||
const PROPOSE_TIERS = [
|
const PROPOSE_TIERS = [
|
||||||
{ id: "basico", label: "10 preguntas", price: "0,99€" },
|
{ id: "basico", label: "10 respuestas", price: "0,99€" },
|
||||||
{ id: "pro", label: "200 preguntas", price: "9,99€" },
|
{ id: "pro", label: "300 respuestas", price: "9,99€" },
|
||||||
{ id: "ilimitado", label: "Ilimitadas", price: "19,99€" },
|
{ id: "full", label: "1000 respuestas", price: "19,99€" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type ProposeTierId = (typeof PROPOSE_TIERS)[number]["id"];
|
type ProposeTierId = (typeof PROPOSE_TIERS)[number]["id"];
|
||||||
@@ -81,12 +82,16 @@ function loadCredit(): CreditInfo | null {
|
|||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(CREDIT_STORAGE_KEY);
|
const raw = localStorage.getItem(CREDIT_STORAGE_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const c = JSON.parse(raw) as CreditInfo;
|
const c = JSON.parse(raw) as CreditInfo & { questionsLeft?: number };
|
||||||
if (!c.token || !c.expiresAt || c.expiresAt < Date.now()) {
|
if (!c.token || !c.expiresAt || c.expiresAt < Date.now()) {
|
||||||
localStorage.removeItem(CREDIT_STORAGE_KEY);
|
localStorage.removeItem(CREDIT_STORAGE_KEY);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return c;
|
// Migrate old field name
|
||||||
|
if (c.answersLeft === undefined && c.questionsLeft !== undefined) {
|
||||||
|
c.answersLeft = c.questionsLeft;
|
||||||
|
}
|
||||||
|
return c as CreditInfo;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -429,6 +434,21 @@ function Arena({
|
|||||||
{isDone && votesA === votesB && totalVotes > 0 && (
|
{isDone && votesA === votesB && totalVotes > 0 && (
|
||||||
<div className="tie-label">Empate</div>
|
<div className="tie-label">Empate</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{round.userAnswers && round.userAnswers.length > 0 && (
|
||||||
|
<div className="user-answers">
|
||||||
|
<div className="user-answers__label">Respuestas del público</div>
|
||||||
|
<div className="user-answers__list">
|
||||||
|
{round.userAnswers.map((a, i) => (
|
||||||
|
<div key={i} className="user-answer">
|
||||||
|
<span className="user-answer__name">{a.username}</span>
|
||||||
|
<span className="user-answer__sep"> — </span>
|
||||||
|
<span className="user-answer__text">“{a.text}”</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -592,9 +612,9 @@ function Standings({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Propose Question (inline widget) ─────────────────────────────────────────
|
// ── Propose Answer (inline widget) ───────────────────────────────────────────
|
||||||
|
|
||||||
function ProposeQuestion() {
|
function ProposeAnswer({ activeRound }: { activeRound: RoundState | null }) {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const creditOkOrder = params.get("credito_ok");
|
const creditOkOrder = params.get("credito_ok");
|
||||||
const isKo = params.get("ko") === "1";
|
const isKo = params.get("ko") === "1";
|
||||||
@@ -610,7 +630,8 @@ function ProposeQuestion() {
|
|||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [sent, setSent] = useState(false);
|
const [submittedFor, setSubmittedFor] = useState<number | null>(null);
|
||||||
|
const [submittedText, setSubmittedText] = useState<string | null>(null);
|
||||||
const [koDismissed, setKoDismissed] = useState(false);
|
const [koDismissed, setKoDismissed] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -618,6 +639,15 @@ function ProposeQuestion() {
|
|||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Clear submission state when a new round starts
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeRound?.num && submittedFor !== null && activeRound.num !== submittedFor) {
|
||||||
|
setSubmittedFor(null);
|
||||||
|
setSubmittedText(null);
|
||||||
|
setText("");
|
||||||
|
}
|
||||||
|
}, [activeRound?.num]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!creditOkOrder || !loaded || credit) return;
|
if (!creditOkOrder || !loaded || credit) return;
|
||||||
setVerifying(true);
|
setVerifying(true);
|
||||||
@@ -632,13 +662,13 @@ function ProposeQuestion() {
|
|||||||
const data = await res.json() as {
|
const data = await res.json() as {
|
||||||
found: boolean; status?: string; token?: string;
|
found: boolean; status?: string; token?: string;
|
||||||
username?: string; expiresAt?: number; tier?: string;
|
username?: string; expiresAt?: number; tier?: string;
|
||||||
questionsLeft?: number | null;
|
answersLeft?: number;
|
||||||
};
|
};
|
||||||
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 = {
|
||||||
token: data.token, username: data.username ?? "",
|
token: data.token, username: data.username ?? "",
|
||||||
expiresAt: data.expiresAt, tier: data.tier ?? "",
|
expiresAt: data.expiresAt, tier: data.tier ?? "",
|
||||||
questionsLeft: data.questionsLeft ?? null,
|
answersLeft: data.answersLeft ?? 0,
|
||||||
};
|
};
|
||||||
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(newCredit));
|
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(newCredit));
|
||||||
setCredit(newCredit);
|
setCredit(newCredit);
|
||||||
@@ -672,13 +702,13 @@ function ProposeQuestion() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmitQuestion(e: React.FormEvent) {
|
async function handleSubmitAnswer(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!credit) return;
|
if (!credit || !activeRound) return;
|
||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/pregunta/enviar", {
|
const res = await fetch("/api/respuesta/enviar", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ text: text.trim(), token: credit.token }),
|
body: JSON.stringify({ text: text.trim(), token: credit.token }),
|
||||||
@@ -687,17 +717,18 @@ function ProposeQuestion() {
|
|||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
localStorage.removeItem(CREDIT_STORAGE_KEY);
|
localStorage.removeItem(CREDIT_STORAGE_KEY);
|
||||||
setCredit(null);
|
setCredit(null);
|
||||||
throw new Error("Acceso expirado o sin preguntas disponibles.");
|
throw new Error("Crédito agotado o no válido.");
|
||||||
}
|
}
|
||||||
|
if (res.status === 409) throw new Error("La ronda aún no tiene pregunta activa.");
|
||||||
throw new Error(await res.text() || `Error ${res.status}`);
|
throw new Error(await res.text() || `Error ${res.status}`);
|
||||||
}
|
}
|
||||||
const data = await res.json() as { ok: boolean; questionsLeft: number | null };
|
const data = await res.json() as { ok: boolean; answersLeft: number };
|
||||||
const updated: CreditInfo = { ...credit, questionsLeft: data.questionsLeft };
|
const updated: CreditInfo = { ...credit, answersLeft: data.answersLeft };
|
||||||
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(updated));
|
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(updated));
|
||||||
setCredit(updated);
|
setCredit(updated);
|
||||||
|
setSubmittedFor(activeRound.num);
|
||||||
|
setSubmittedText(text.trim());
|
||||||
setText("");
|
setText("");
|
||||||
setSent(true);
|
|
||||||
setTimeout(() => setSent(false), 3000);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSubmitError(err instanceof Error ? err.message : "Error al enviar");
|
setSubmitError(err instanceof Error ? err.message : "Error al enviar");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -708,7 +739,11 @@ function ProposeQuestion() {
|
|||||||
if (!loaded) return null;
|
if (!loaded) return null;
|
||||||
|
|
||||||
const tierInfo = selectedTier ? PROPOSE_TIERS.find(t => t.id === selectedTier) : null;
|
const tierInfo = selectedTier ? PROPOSE_TIERS.find(t => t.id === selectedTier) : null;
|
||||||
const exhausted = credit !== null && credit.questionsLeft !== null && credit.questionsLeft <= 0;
|
const exhausted = credit !== null && credit.answersLeft <= 0;
|
||||||
|
const hasPrompt = !!(activeRound?.prompt);
|
||||||
|
const alreadySubmitted = submittedFor === activeRound?.num;
|
||||||
|
const canAnswer = credit && !exhausted && hasPrompt && !alreadySubmitted &&
|
||||||
|
(activeRound?.phase === "answering" || activeRound?.phase === "prompting");
|
||||||
|
|
||||||
// Verifying payment
|
// Verifying payment
|
||||||
if (verifying || (creditOkOrder && !credit)) {
|
if (verifying || (creditOkOrder && !credit)) {
|
||||||
@@ -727,35 +762,40 @@ function ProposeQuestion() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Active credit — question form
|
// Active credit
|
||||||
if (credit) {
|
if (credit) {
|
||||||
const badge = credit.questionsLeft === null
|
const badge = `${credit.answersLeft} respuesta${credit.answersLeft !== 1 ? "s" : ""}`;
|
||||||
? "Ilimitadas"
|
|
||||||
: `${credit.questionsLeft} restante${credit.questionsLeft !== 1 ? "s" : ""}`;
|
|
||||||
return (
|
return (
|
||||||
<div className="propose">
|
<div className="propose">
|
||||||
<div className="propose__head">
|
<div className="propose__head">
|
||||||
<span className="propose__title">Propón una pregunta · {credit.username}</span>
|
<span className="propose__title">Responde junto a las IAs · {credit.username}</span>
|
||||||
<span className={`propose__badge ${exhausted ? "propose__badge--empty" : ""}`}>{badge}</span>
|
<span className={`propose__badge ${exhausted ? "propose__badge--empty" : ""}`}>{badge}</span>
|
||||||
</div>
|
</div>
|
||||||
{sent && <p className="propose__msg propose__msg--ok">✓ ¡Enviada! Se usará en el próximo sorteo.</p>}
|
|
||||||
{!exhausted ? (
|
{alreadySubmitted && submittedText && (
|
||||||
<form onSubmit={handleSubmitQuestion}>
|
<p className="propose__msg propose__msg--ok">
|
||||||
|
✓ Tu respuesta: “{submittedText}”
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canAnswer && (
|
||||||
|
<form onSubmit={handleSubmitAnswer}>
|
||||||
<div className="propose__row">
|
<div className="propose__row">
|
||||||
<textarea
|
<textarea
|
||||||
className="propose__textarea"
|
className="propose__textarea"
|
||||||
value={text}
|
value={text}
|
||||||
onChange={e => setText(e.target.value)}
|
onChange={e => setText(e.target.value)}
|
||||||
placeholder='"La peor cosa que puedes encontrar en ___"'
|
placeholder="Tu respuesta más graciosa…"
|
||||||
rows={2}
|
rows={2}
|
||||||
maxLength={200}
|
maxLength={150}
|
||||||
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button className="propose__btn" type="submit" disabled={submitting || text.trim().length < 10}>
|
<button className="propose__btn" type="submit" disabled={submitting || text.trim().length < 3}>
|
||||||
{submitting ? "…" : "Enviar"}
|
{submitting ? "…" : "Enviar"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="propose__hint">
|
<div className="propose__hint">
|
||||||
{text.length}/200 · mín. 10 ·{" "}
|
{text.length}/150 ·{" "}
|
||||||
<button type="button" className="propose__link-btn" onClick={() => {
|
<button type="button" className="propose__link-btn" onClick={() => {
|
||||||
localStorage.removeItem(CREDIT_STORAGE_KEY);
|
localStorage.removeItem(CREDIT_STORAGE_KEY);
|
||||||
setCredit(null);
|
setCredit(null);
|
||||||
@@ -765,31 +805,39 @@ function ProposeQuestion() {
|
|||||||
</div>
|
</div>
|
||||||
{submitError && <p className="propose__msg propose__msg--error">{submitError}</p>}
|
{submitError && <p className="propose__msg propose__msg--error">{submitError}</p>}
|
||||||
</form>
|
</form>
|
||||||
) : (
|
)}
|
||||||
|
|
||||||
|
{!canAnswer && !alreadySubmitted && !exhausted && (
|
||||||
|
<p className="propose__msg" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{!hasPrompt ? "Esperando la siguiente pregunta…" : "Ya no se aceptan respuestas para esta ronda."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{exhausted && (
|
||||||
<div className="propose__row">
|
<div className="propose__row">
|
||||||
<p className="propose__msg propose__msg--error" style={{ flex: 1 }}>Has agotado tus preguntas.</p>
|
<p className="propose__msg propose__msg--error" style={{ flex: 1 }}>
|
||||||
|
Sin respuestas. Recarga para seguir jugando.
|
||||||
|
</p>
|
||||||
<button className="propose__btn" onClick={() => {
|
<button className="propose__btn" onClick={() => {
|
||||||
localStorage.removeItem(CREDIT_STORAGE_KEY);
|
localStorage.removeItem(CREDIT_STORAGE_KEY);
|
||||||
setCredit(null);
|
setCredit(null);
|
||||||
}}>Nuevo plan</button>
|
}}>Recargar</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tier selection
|
// Tier selection (purchase)
|
||||||
return (
|
return (
|
||||||
<div className="propose">
|
<div className="propose">
|
||||||
<div className="propose__head">
|
<div className="propose__head">
|
||||||
<span className="propose__title">Propón preguntas al juego</span>
|
<span className="propose__title">Responde junto a las IAs</span>
|
||||||
</div>
|
</div>
|
||||||
{isKo && !koDismissed && (
|
{isKo && !koDismissed && (
|
||||||
<p className="propose__msg propose__msg--error">
|
<p className="propose__msg propose__msg--error">
|
||||||
El pago no se completó.{" "}
|
El pago no se completó.{" "}
|
||||||
<button type="button" className="propose__link-btn" onClick={() => setKoDismissed(true)}>
|
<button type="button" className="propose__link-btn" onClick={() => setKoDismissed(true)}>×</button>
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="propose__tiers">
|
<div className="propose__tiers">
|
||||||
@@ -1001,7 +1049,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ProposeQuestion />
|
<ProposeAnswer activeRound={state.active} />
|
||||||
|
|
||||||
<footer className="site-footer">
|
<footer className="site-footer">
|
||||||
<p>IAs compiten respondiendo preguntas absurdas — los jueces votan, tú también puedes.</p>
|
<p>IAs compiten respondiendo preguntas absurdas — los jueces votan, tú también puedes.</p>
|
||||||
|
|||||||
1
game.ts
1
game.ts
@@ -67,6 +67,7 @@ export type RoundState = {
|
|||||||
viewerVotesA?: number;
|
viewerVotesA?: number;
|
||||||
viewerVotesB?: number;
|
viewerVotesB?: number;
|
||||||
viewerVotingEndsAt?: number;
|
viewerVotingEndsAt?: number;
|
||||||
|
userAnswers?: { username: string; text: string }[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GameState = {
|
export type GameState = {
|
||||||
|
|||||||
55
server.ts
55
server.ts
@@ -7,8 +7,7 @@ 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,
|
createPendingCredit, activateCredit, getCreditByOrder, submitUserAnswer,
|
||||||
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 +115,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; maxQuestions: number | null }> = {
|
const CREDIT_TIERS: Record<string, { amount: number; label: string; maxAnswers: number }> = {
|
||||||
basico: { days: 30, amount: 99, label: "10 preguntas", maxQuestions: 10 },
|
basico: { amount: 99, label: "10 respuestas", maxAnswers: 10 },
|
||||||
pro: { days: 30, amount: 999, label: "200 preguntas", maxQuestions: 200 },
|
pro: { amount: 999, label: "300 respuestas", maxAnswers: 300 },
|
||||||
ilimitado: { days: 30, amount: 1999, label: "Preguntas ilimitadas", maxQuestions: null },
|
full: { amount: 1999, label: "1000 respuestas", maxAnswers: 1000 },
|
||||||
};
|
};
|
||||||
const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
|
const ADMIN_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
|
||||||
|
|
||||||
@@ -561,7 +560,8 @@ const server = Bun.serve<WsData>({
|
|||||||
if (credit && credit.status === "pending") {
|
if (credit && credit.status === "pending") {
|
||||||
const tierInfo = CREDIT_TIERS[credit.tier];
|
const tierInfo = CREDIT_TIERS[credit.tier];
|
||||||
if (tierInfo) {
|
if (tierInfo) {
|
||||||
const expiresAt = Date.now() + tierInfo.days * 24 * 60 * 60 * 1000;
|
// No time limit — set expiry 10 years out so existing checks pass
|
||||||
|
const expiresAt = Date.now() + 10 * 365 * 24 * 60 * 60 * 1000;
|
||||||
const activated = activateCredit(orderId, expiresAt);
|
const activated = activateCredit(orderId, expiresAt);
|
||||||
if (activated) {
|
if (activated) {
|
||||||
log("INFO", "redsys", "Credit activated", {
|
log("INFO", "redsys", "Credit activated", {
|
||||||
@@ -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 (basico | pro | ilimitado)", { status: 400 });
|
return new Response("Tier inválido (basico | pro | full)", { 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, tierInfo.maxQuestions);
|
createPendingCredit(username, orderId, tier, tierInfo.maxAnswers);
|
||||||
|
|
||||||
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";
|
||||||
@@ -638,7 +638,7 @@ const server = Bun.serve<WsData>({
|
|||||||
urlOk: `${baseUrl}/?credito_ok=${orderId}`,
|
urlOk: `${baseUrl}/?credito_ok=${orderId}`,
|
||||||
urlKo: `${baseUrl}/?ko=1`,
|
urlKo: `${baseUrl}/?ko=1`,
|
||||||
merchantUrl: `${baseUrl}/api/redsys/notificacion`,
|
merchantUrl: `${baseUrl}/api/redsys/notificacion`,
|
||||||
productDescription: `Acceso argument.es — ${tierInfo.label}`,
|
productDescription: `argument.es — ${tierInfo.label}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
log("INFO", "credito", "Credit purchase initiated", {
|
log("INFO", "credito", "Credit purchase initiated", {
|
||||||
@@ -664,8 +664,9 @@ 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 =
|
const answersLeft = credit.maxQuestions === null
|
||||||
credit.maxQuestions === null ? null : credit.maxQuestions - credit.questionsUsed;
|
? 0
|
||||||
|
: credit.maxQuestions - credit.questionsUsed;
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
found: true,
|
found: true,
|
||||||
@@ -676,7 +677,7 @@ const server = Bun.serve<WsData>({
|
|||||||
username: credit.username,
|
username: credit.username,
|
||||||
expiresAt: credit.expiresAt,
|
expiresAt: credit.expiresAt,
|
||||||
tier: credit.tier,
|
tier: credit.tier,
|
||||||
questionsLeft,
|
answersLeft,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
}),
|
}),
|
||||||
@@ -684,14 +685,14 @@ const server = Bun.serve<WsData>({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname === "/api/pregunta/enviar") {
|
if (url.pathname === "/api/respuesta/enviar") {
|
||||||
if (req.method !== "POST") {
|
if (req.method !== "POST") {
|
||||||
return new Response("Method Not Allowed", {
|
return new Response("Method Not Allowed", {
|
||||||
status: 405,
|
status: 405,
|
||||||
headers: { Allow: "POST" },
|
headers: { Allow: "POST" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (isRateLimited(`pregunta:${ip}`, 20, WINDOW_MS)) {
|
if (isRateLimited(`respuesta:${ip}`, 20, WINDOW_MS)) {
|
||||||
return new Response("Too Many Requests", { status: 429 });
|
return new Response("Too Many Requests", { status: 429 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -708,16 +709,26 @@ const server = Bun.serve<WsData>({
|
|||||||
if (!token) {
|
if (!token) {
|
||||||
return new Response("Token requerido", { status: 401 });
|
return new Response("Token requerido", { status: 401 });
|
||||||
}
|
}
|
||||||
if (text.length < 10 || text.length > 200) {
|
if (text.length < 3 || text.length > 150) {
|
||||||
return new Response("La pregunta debe tener entre 10 y 200 caracteres", { status: 400 });
|
return new Response("La respuesta debe tener entre 3 y 150 caracteres", { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = consumeCreditQuestion(token, text);
|
const round = gameState.active;
|
||||||
if (!result) {
|
if (!round || !round.prompt) {
|
||||||
return new Response("Crédito no válido, expirado o sin preguntas disponibles", { status: 401 });
|
return new Response("No hay ronda activa", { status: 409 });
|
||||||
}
|
}
|
||||||
log("INFO", "pregunta", "Question submitted via credit", { username: result.username, ip });
|
|
||||||
return new Response(JSON.stringify({ ok: true, questionsLeft: result.questionsLeft }), {
|
const result = submitUserAnswer(token, round.num, text);
|
||||||
|
if (!result) {
|
||||||
|
return new Response("Crédito no válido o sin respuestas disponibles", { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to live round state and broadcast
|
||||||
|
round.userAnswers = [...(round.userAnswers ?? []), { username: result.username, text }];
|
||||||
|
broadcast();
|
||||||
|
|
||||||
|
log("INFO", "respuesta", "User answer submitted", { username: result.username, round: round.num, ip });
|
||||||
|
return new Response(JSON.stringify({ ok: true, answersLeft: result.answersLeft }), {
|
||||||
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