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:
130
frontend.tsx
130
frontend.tsx
@@ -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">“{a.text}”</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: “{submittedText}”
|
||||
</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, tú también puedes.</p>
|
||||
|
||||
Reference in New Issue
Block a user