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:
2026-03-05 12:09:53 +01:00
parent 3e5c080466
commit f9a8e2544f
5 changed files with 198 additions and 82 deletions

View File

@@ -33,6 +33,7 @@ type RoundState = {
viewerVotesA?: number;
viewerVotesB?: number;
viewerVotingEndsAt?: number;
userAnswers?: { username: string; text: string }[];
};
type GameState = {
lastCompleted: RoundState | null;
@@ -64,15 +65,15 @@ type CreditInfo = {
username: string;
expiresAt: number;
tier: string;
questionsLeft: number | null;
answersLeft: number;
};
const CREDIT_STORAGE_KEY = "argumentes_credito";
const PROPOSE_TIERS = [
{ id: "basico", label: "10 preguntas", price: "0,99€" },
{ id: "pro", label: "200 preguntas", price: "9,99€" },
{ id: "ilimitado", label: "Ilimitadas", price: "19,99€" },
{ id: "basico", label: "10 respuestas", price: "0,99€" },
{ id: "pro", label: "300 respuestas", price: "9,99€" },
{ id: "full", label: "1000 respuestas", price: "19,99€" },
] as const;
type ProposeTierId = (typeof PROPOSE_TIERS)[number]["id"];
@@ -81,12 +82,16 @@ function loadCredit(): CreditInfo | null {
try {
const raw = localStorage.getItem(CREDIT_STORAGE_KEY);
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()) {
localStorage.removeItem(CREDIT_STORAGE_KEY);
return null;
}
return c;
// Migrate old field name
if (c.answersLeft === undefined && c.questionsLeft !== undefined) {
c.answersLeft = c.questionsLeft;
}
return c as CreditInfo;
} catch {
return null;
}
@@ -429,6 +434,21 @@ function Arena({
{isDone && votesA === votesB && totalVotes > 0 && (
<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">&ldquo;{a.text}&rdquo;</span>
</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 creditOkOrder = params.get("credito_ok");
const isKo = params.get("ko") === "1";
@@ -610,7 +630,8 @@ function ProposeQuestion() {
const [text, setText] = useState("");
const [submitting, setSubmitting] = useState(false);
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);
useEffect(() => {
@@ -618,6 +639,15 @@ function ProposeQuestion() {
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(() => {
if (!creditOkOrder || !loaded || credit) return;
setVerifying(true);
@@ -632,13 +662,13 @@ function ProposeQuestion() {
const data = await res.json() as {
found: boolean; status?: string; token?: string;
username?: string; expiresAt?: number; tier?: string;
questionsLeft?: number | null;
answersLeft?: number;
};
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 ?? "",
questionsLeft: data.questionsLeft ?? null,
answersLeft: data.answersLeft ?? 0,
};
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(newCredit));
setCredit(newCredit);
@@ -672,13 +702,13 @@ function ProposeQuestion() {
}
}
async function handleSubmitQuestion(e: React.FormEvent) {
async function handleSubmitAnswer(e: React.FormEvent) {
e.preventDefault();
if (!credit) return;
if (!credit || !activeRound) return;
setSubmitError(null);
setSubmitting(true);
try {
const res = await fetch("/api/pregunta/enviar", {
const res = await fetch("/api/respuesta/enviar", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: text.trim(), token: credit.token }),
@@ -687,17 +717,18 @@ function ProposeQuestion() {
if (res.status === 401) {
localStorage.removeItem(CREDIT_STORAGE_KEY);
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}`);
}
const data = await res.json() as { ok: boolean; questionsLeft: number | null };
const updated: CreditInfo = { ...credit, questionsLeft: data.questionsLeft };
const data = await res.json() as { ok: boolean; answersLeft: number };
const updated: CreditInfo = { ...credit, answersLeft: data.answersLeft };
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(updated));
setCredit(updated);
setSubmittedFor(activeRound.num);
setSubmittedText(text.trim());
setText("");
setSent(true);
setTimeout(() => setSent(false), 3000);
} catch (err) {
setSubmitError(err instanceof Error ? err.message : "Error al enviar");
} finally {
@@ -708,7 +739,11 @@ function ProposeQuestion() {
if (!loaded) return 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
if (verifying || (creditOkOrder && !credit)) {
@@ -727,35 +762,40 @@ function ProposeQuestion() {
);
}
// Active credit — question form
// Active credit
if (credit) {
const badge = credit.questionsLeft === null
? "Ilimitadas"
: `${credit.questionsLeft} restante${credit.questionsLeft !== 1 ? "s" : ""}`;
const badge = `${credit.answersLeft} respuesta${credit.answersLeft !== 1 ? "s" : ""}`;
return (
<div className="propose">
<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>
</div>
{sent && <p className="propose__msg propose__msg--ok"> ¡Enviada! Se usará en el próximo sorteo.</p>}
{!exhausted ? (
<form onSubmit={handleSubmitQuestion}>
{alreadySubmitted && submittedText && (
<p className="propose__msg propose__msg--ok">
Tu respuesta: &ldquo;{submittedText}&rdquo;
</p>
)}
{canAnswer && (
<form onSubmit={handleSubmitAnswer}>
<div className="propose__row">
<textarea
className="propose__textarea"
value={text}
onChange={e => setText(e.target.value)}
placeholder='"La peor cosa que puedes encontrar en ___"'
placeholder="Tu respuesta más graciosa…"
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"}
</button>
</div>
<div className="propose__hint">
{text.length}/200 · mín. 10 ·{" "}
{text.length}/150 ·{" "}
<button type="button" className="propose__link-btn" onClick={() => {
localStorage.removeItem(CREDIT_STORAGE_KEY);
setCredit(null);
@@ -765,31 +805,39 @@ function ProposeQuestion() {
</div>
{submitError && <p className="propose__msg propose__msg--error">{submitError}</p>}
</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">
<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={() => {
localStorage.removeItem(CREDIT_STORAGE_KEY);
setCredit(null);
}}>Nuevo plan</button>
}}>Recargar</button>
</div>
)}
</div>
);
}
// Tier selection
// Tier selection (purchase)
return (
<div className="propose">
<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>
{isKo && !koDismissed && (
<p className="propose__msg propose__msg--error">
El pago no se completó.{" "}
<button type="button" className="propose__link-btn" onClick={() => setKoDismissed(true)}>
×
</button>
<button type="button" className="propose__link-btn" onClick={() => setKoDismissed(true)}>×</button>
</p>
)}
<div className="propose__tiers">
@@ -1001,7 +1049,7 @@ function App() {
</div>
)}
<ProposeQuestion />
<ProposeAnswer activeRound={state.active} />
<footer className="site-footer">
<p>IAs compiten respondiendo preguntas absurdas los jueces votan, también puedes.</p>