Files
argument.es/frontend.tsx
Malin f9a8e2544f 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>
2026-03-05 12:09:53 +01:00

1081 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef } from "react";
import { createRoot } from "react-dom/client";
import "./frontend.css";
// ── Types ────────────────────────────────────────────────────────────────────
type Model = { id: string; name: string };
type TaskInfo = {
model: Model;
startedAt: number;
finishedAt?: number;
result?: string;
error?: string;
};
type VoteInfo = {
voter: Model;
startedAt: number;
finishedAt?: number;
votedFor?: Model;
error?: boolean;
};
type RoundState = {
num: number;
phase: "prompting" | "answering" | "voting" | "done";
prompter: Model;
promptTask: TaskInfo;
prompt?: string;
contestants: [Model, Model];
answerTasks: [TaskInfo, TaskInfo];
votes: VoteInfo[];
scoreA?: number;
scoreB?: number;
viewerVotesA?: number;
viewerVotesB?: number;
viewerVotingEndsAt?: number;
userAnswers?: { username: string; text: string }[];
};
type GameState = {
lastCompleted: RoundState | null;
active: RoundState | null;
scores: Record<string, number>;
viewerScores: Record<string, number>;
done: boolean;
isPaused: boolean;
autoPaused?: boolean;
generation: number;
};
type StateMessage = {
type: "state";
data: GameState;
totalRounds: number;
viewerCount: number;
version?: string;
};
type ViewerCountMessage = {
type: "viewerCount";
viewerCount: number;
};
type ServerMessage = StateMessage | ViewerCountMessage;
// ── Credit / Propose ─────────────────────────────────────────────────────────
type CreditInfo = {
token: string;
username: string;
expiresAt: number;
tier: string;
answersLeft: number;
};
const CREDIT_STORAGE_KEY = "argumentes_credito";
const PROPOSE_TIERS = [
{ 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"];
function loadCredit(): CreditInfo | null {
try {
const raw = localStorage.getItem(CREDIT_STORAGE_KEY);
if (!raw) return null;
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;
}
// Migrate old field name
if (c.answersLeft === undefined && c.questionsLeft !== undefined) {
c.answersLeft = c.questionsLeft;
}
return c as CreditInfo;
} catch {
return null;
}
}
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();
}
// ── Model colors & logos ─────────────────────────────────────────────────────
const MODEL_COLORS: Record<string, string> = {
"Gemini 3.1 Pro": "#4285F4",
"Kimi K2": "#00E599",
"DeepSeek 3.2": "#4D6BFE",
"GLM-5": "#1F63EC",
"GPT-5.2": "#10A37F",
"Opus 4.6": "#D97757",
"Sonnet 4.6": "#D97757",
"Grok 4.1": "#FFFFFF",
"MiniMax 2.5": "#FF3B30",
};
function getColor(name: string): string {
return MODEL_COLORS[name] ?? "#A1A1A1";
}
function getLogo(name: string): string | null {
if (name.includes("Gemini")) return "/assets/logos/gemini.svg";
if (name.includes("Kimi")) return "/assets/logos/kimi.svg";
if (name.includes("DeepSeek")) return "/assets/logos/deepseek.svg";
if (name.includes("GLM")) return "/assets/logos/glm.svg";
if (name.includes("GPT")) return "/assets/logos/openai.svg";
if (name.includes("Opus") || name.includes("Sonnet"))
return "/assets/logos/claude.svg";
if (name.includes("Grok")) return "/assets/logos/grok.svg";
if (name.includes("MiniMax")) return "/assets/logos/minimax.svg";
return null;
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function Dots() {
return (
<span className="dots">
<span>.</span>
<span>.</span>
<span>.</span>
</span>
);
}
function ModelTag({ model, small }: { model: Model; small?: boolean }) {
const logo = getLogo(model.name);
const color = getColor(model.name);
return (
<span
className={`model-tag ${small ? "model-tag--sm" : ""}`}
style={{ color }}
>
{logo && <img src={logo} alt="" className="model-tag__logo" />}
{model.name}
</span>
);
}
// ── Prompt ───────────────────────────────────────────────────────────────────
function PromptCard({ round }: { round: RoundState }) {
if (round.phase === "prompting" && !round.prompt) {
return (
<div className="prompt">
<div className="prompt__by">
<ModelTag model={round.prompter} small /> está escribiendo una pregunta
<Dots />
</div>
<div className="prompt__text prompt__text--loading">
<Dots />
</div>
</div>
);
}
if (round.promptTask.error) {
return (
<div className="prompt">
<div className="prompt__text prompt__text--error">
Error al generar la pregunta
</div>
</div>
);
}
return (
<div className="prompt">
<div className="prompt__by">
Pregunta de <ModelTag model={round.prompter} small />
</div>
<div className="prompt__text">{round.prompt}</div>
</div>
);
}
// ── Contestant ───────────────────────────────────────────────────────────────
function ContestantCard({
task,
voteCount,
totalVotes,
isWinner,
showVotes,
voters,
viewerVotes,
totalViewerVotes,
}: {
task: TaskInfo;
voteCount: number;
totalVotes: number;
isWinner: boolean;
showVotes: boolean;
voters: VoteInfo[];
viewerVotes?: number;
totalViewerVotes?: number;
}) {
const color = getColor(task.model.name);
const pct = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0;
const showViewerVotes = showVotes && totalViewerVotes !== undefined && totalViewerVotes > 0;
const viewerPct = showViewerVotes && totalViewerVotes > 0
? Math.round(((viewerVotes ?? 0) / totalViewerVotes) * 100)
: 0;
return (
<div
className={`contestant ${isWinner ? "contestant--winner" : ""}`}
style={{ "--accent": color } as React.CSSProperties}
>
<div className="contestant__head">
<ModelTag model={task.model} />
{isWinner && <span className="win-tag">GANA</span>}
</div>
<div className="contestant__body">
{!task.finishedAt ? (
<p className="answer answer--loading">
<Dots />
</p>
) : task.error ? (
<p className="answer answer--error">{task.error}</p>
) : (
<p className="answer">&ldquo;{task.result}&rdquo;</p>
)}
</div>
{showVotes && (
<div className="contestant__foot">
<div className="vote-bar">
<div
className="vote-bar__fill"
style={{ width: `${pct}%`, background: color }}
/>
</div>
<div className="vote-meta">
<span className="vote-meta__count" style={{ color }}>
{voteCount}
</span>
<span className="vote-meta__label">
voto{voteCount !== 1 ? "s" : ""}
</span>
<span className="vote-meta__dots">
{voters.map((v, i) => {
const logo = getLogo(v.voter.name);
return logo ? (
<img
key={i}
src={logo}
alt={v.voter.name}
title={v.voter.name}
className="voter-dot"
/>
) : (
<span
key={i}
className="voter-dot voter-dot--letter"
style={{ color: getColor(v.voter.name) }}
title={v.voter.name}
>
{v.voter.name[0]}
</span>
);
})}
</span>
</div>
{showViewerVotes && (
<>
<div className="vote-bar viewer-vote-bar">
<div
className="vote-bar__fill viewer-vote-bar__fill"
style={{ width: `${viewerPct}%` }}
/>
</div>
<div className="vote-meta viewer-vote-meta">
<span className="vote-meta__count viewer-vote-meta__count">
{viewerVotes ?? 0}
</span>
<span className="vote-meta__label">
voto{(viewerVotes ?? 0) !== 1 ? "s" : ""} del público
</span>
<span className="viewer-vote-meta__icon">👥</span>
</div>
</>
)}
</div>
)}
</div>
);
}
// ── Arena ─────────────────────────────────────────────────────────────────────
function Arena({
round,
total,
viewerVotingSecondsLeft,
myVote,
onVote,
}: {
round: RoundState;
total: number | null;
viewerVotingSecondsLeft: number;
myVote: "A" | "B" | null;
onVote: (side: "A" | "B") => void;
}) {
const [contA, contB] = round.contestants;
const showVotes = round.phase === "voting" || round.phase === "done";
const isDone = round.phase === "done";
let votesA = 0,
votesB = 0;
for (const v of round.votes) {
if (v.votedFor?.name === contA.name) votesA++;
else if (v.votedFor?.name === contB.name) votesB++;
}
const totalVotes = votesA + votesB;
const votersA = round.votes.filter((v) => v.votedFor?.name === contA.name);
const votersB = round.votes.filter((v) => v.votedFor?.name === contB.name);
const totalViewerVotes = (round.viewerVotesA ?? 0) + (round.viewerVotesB ?? 0);
const showCountdown = round.phase === "voting" && viewerVotingSecondsLeft > 0;
const phaseText =
round.phase === "prompting"
? "Generando pregunta"
: round.phase === "answering"
? "Respondiendo"
: round.phase === "voting"
? "Votando los jueces"
: "Completado";
return (
<div className="arena">
<div className="arena__meta">
<span className="arena__round">
Round {round.num}
{total ? <span className="dim">/{total}</span> : null}
</span>
<span className="arena__phase">
{phaseText}
{showCountdown && (
<span className="vote-countdown">{viewerVotingSecondsLeft}s</span>
)}
</span>
</div>
{showCountdown && (
<div className="vote-panel">
<span className="vote-panel__label">¿Cuál es más gracioso?</span>
<div className="vote-panel__buttons">
<button
className={`vote-btn ${myVote === "A" ? "vote-btn--active" : ""}`}
onClick={() => onVote("A")}
>
<ModelTag model={contA} />
</button>
<button
className={`vote-btn ${myVote === "B" ? "vote-btn--active" : ""}`}
onClick={() => onVote("B")}
>
<ModelTag model={contB} />
</button>
</div>
</div>
)}
<PromptCard round={round} />
{round.phase !== "prompting" && (
<div className="showdown">
<ContestantCard
task={round.answerTasks[0]}
voteCount={votesA}
totalVotes={totalVotes}
isWinner={isDone && votesA > votesB}
showVotes={showVotes}
voters={votersA}
viewerVotes={round.viewerVotesA}
totalViewerVotes={totalViewerVotes}
/>
<ContestantCard
task={round.answerTasks[1]}
voteCount={votesB}
totalVotes={totalVotes}
isWinner={isDone && votesB > votesA}
showVotes={showVotes}
voters={votersB}
viewerVotes={round.viewerVotesB}
totalViewerVotes={totalViewerVotes}
/>
</div>
)}
{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>
);
}
// ── Game Over ────────────────────────────────────────────────────────────────
function GameOver({ scores }: { scores: Record<string, number> }) {
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
const champion = sorted[0];
return (
<div className="game-over">
<div className="game-over__label">Fin del Juego</div>
{champion && champion[1] > 0 && (
<div className="game-over__winner">
<span className="game-over__crown">👑</span>
<span
className="game-over__name"
style={{ color: getColor(champion[0]) }}
>
{getLogo(champion[0]) && <img src={getLogo(champion[0])!} alt="" />}
{champion[0]}
</span>
<span className="game-over__sub">es la IA más graciosa</span>
</div>
)}
</div>
);
}
// ── Standings ────────────────────────────────────────────────────────────────
function LeaderboardSection({
label,
scores,
competing,
}: {
label: string;
scores: Record<string, number>;
competing: Set<string>;
}) {
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
const maxScore = sorted[0]?.[1] || 1;
return (
<div className="lb-section">
<div className="lb-section__head">
<span className="lb-section__label">{label}</span>
</div>
<div className="lb-section__list">
{sorted.map(([name, score], i) => {
const pct = maxScore > 0 ? Math.round((score / maxScore) * 100) : 0;
const color = getColor(name);
const active = competing.has(name);
return (
<div
key={name}
className={`lb-entry ${active ? "lb-entry--active" : ""}`}
>
<div className="lb-entry__top">
<span className="lb-entry__rank">
{i === 0 && score > 0 ? "👑" : i + 1}
</span>
<ModelTag model={{ id: name, name }} small />
<span className="lb-entry__score">{score}</span>
</div>
<div className="lb-entry__bar">
<div
className="lb-entry__fill"
style={{ width: `${pct}%`, background: color }}
/>
</div>
</div>
);
})}
</div>
</div>
);
}
function PlayerLeaderboard({ scores }: { scores: Record<string, number> }) {
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]).slice(0, 7);
const maxScore = sorted[0]?.[1] || 1;
return (
<div className="lb-section">
<div className="lb-section__head">
<span className="lb-section__label">Jugadores</span>
<a href="/pregunta" className="standings__link">Jugar</a>
</div>
<div className="lb-section__list">
{sorted.map(([name, score], i) => {
const pct = Math.round((score / maxScore) * 100);
return (
<div key={name} className="lb-entry lb-entry--active">
<div className="lb-entry__top">
<span className="lb-entry__rank">
{i === 0 && score > 0 ? "🏆" : i + 1}
</span>
<span className="lb-entry__name">{name}</span>
<span className="lb-entry__score">{score}</span>
</div>
<div className="lb-entry__bar">
<div
className="lb-entry__fill"
style={{ width: `${pct}%`, background: "var(--accent)" }}
/>
</div>
</div>
);
})}
</div>
</div>
);
}
function Standings({
scores,
viewerScores,
playerScores,
activeRound,
}: {
scores: Record<string, number>;
viewerScores: Record<string, number>;
playerScores: Record<string, number>;
activeRound: RoundState | null;
}) {
const competing = activeRound
? new Set([
activeRound.contestants[0].name,
activeRound.contestants[1].name,
])
: new Set<string>();
return (
<aside className="standings">
<div className="standings__head">
<span className="standings__title">Clasificación</span>
<div className="standings__links">
<a href="/history" className="standings__link">
Historial
</a>
<a href="https://argument.es" target="_blank" rel="noopener noreferrer" className="standings__link">
Web
</a>
</div>
</div>
<LeaderboardSection
label="Jueces IA"
scores={scores}
competing={competing}
/>
<LeaderboardSection
label="Público"
scores={viewerScores}
competing={competing}
/>
{Object.keys(playerScores).length > 0 && (
<PlayerLeaderboard scores={playerScores} />
)}
</aside>
);
}
// ── Propose Answer (inline widget) ───────────────────────────────────────────
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";
const [credit, setCredit] = useState<CreditInfo | null>(null);
const [loaded, setLoaded] = useState(false);
const [verifying, setVerifying] = useState(false);
const [verifyError, setVerifyError] = useState(false);
const [selectedTier, setSelectedTier] = useState<ProposeTierId | null>(null);
const [username, setUsername] = useState("");
const [buying, setBuying] = useState(false);
const [buyError, setBuyError] = useState<string | null>(null);
const [text, setText] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [submittedFor, setSubmittedFor] = useState<number | null>(null);
const [submittedText, setSubmittedText] = useState<string | null>(null);
const [koDismissed, setKoDismissed] = useState(false);
useEffect(() => {
setCredit(loadCredit());
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);
let attempts = 0;
async function poll() {
if (attempts >= 15) { 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;
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 ?? "",
answersLeft: data.answersLeft ?? 0,
};
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(newCredit));
setCredit(newCredit);
setVerifying(false);
history.replaceState(null, "", "/");
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 handleSubmitAnswer(e: React.FormEvent) {
e.preventDefault();
if (!credit || !activeRound) return;
setSubmitError(null);
setSubmitting(true);
try {
const res = await fetch("/api/respuesta/enviar", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: text.trim(), token: credit.token }),
});
if (!res.ok) {
if (res.status === 401) {
localStorage.removeItem(CREDIT_STORAGE_KEY);
setCredit(null);
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; 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("");
} catch (err) {
setSubmitError(err instanceof Error ? err.message : "Error al enviar");
} finally {
setSubmitting(false);
}
}
if (!loaded) return null;
const tierInfo = selectedTier ? PROPOSE_TIERS.find(t => t.id === selectedTier) : null;
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)) {
return (
<div className="propose">
<div className="propose__head">
<span className="propose__title">Verificando pago</span>
{!verifyError && <div className="propose__spinner" />}
</div>
{verifyError && (
<p className="propose__msg propose__msg--error">
No se pudo confirmar. Recarga si el pago se completó.
</p>
)}
</div>
);
}
// Active credit
if (credit) {
const badge = `${credit.answersLeft} respuesta${credit.answersLeft !== 1 ? "s" : ""}`;
return (
<div className="propose">
<div className="propose__head">
<span className="propose__title">Responde junto a las IAs · {credit.username}</span>
<span className={`propose__badge ${exhausted ? "propose__badge--empty" : ""}`}>{badge}</span>
</div>
{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="Tu respuesta más graciosa…"
rows={2}
maxLength={150}
autoFocus
/>
<button className="propose__btn" type="submit" disabled={submitting || text.trim().length < 3}>
{submitting ? "…" : "Enviar"}
</button>
</div>
<div className="propose__hint">
{text.length}/150 ·{" "}
<button type="button" className="propose__link-btn" onClick={() => {
localStorage.removeItem(CREDIT_STORAGE_KEY);
setCredit(null);
}}>
cerrar sesión
</button>
</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 }}>
Sin respuestas. Recarga para seguir jugando.
</p>
<button className="propose__btn" onClick={() => {
localStorage.removeItem(CREDIT_STORAGE_KEY);
setCredit(null);
}}>Recargar</button>
</div>
)}
</div>
);
}
// Tier selection (purchase)
return (
<div className="propose">
<div className="propose__head">
<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>
</p>
)}
<div className="propose__tiers">
{PROPOSE_TIERS.map(tier => (
<button
key={tier.id}
type="button"
className={`propose__tier ${selectedTier === tier.id ? "propose__tier--selected" : ""}`}
onClick={() => setSelectedTier(tier.id)}
>
<span className="propose__tier__price">{tier.price}</span>
<span className="propose__tier__label">{tier.label}</span>
</button>
))}
</div>
{selectedTier && (
<form onSubmit={handleBuyCredit} className="propose__row propose__row--mt">
<input
type="text"
className="propose__input"
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="Tu nombre en el marcador"
maxLength={30}
required
autoFocus
/>
<button type="submit" className="propose__btn" disabled={buying || !username.trim()}>
{buying ? "…" : `Pagar ${tierInfo?.price}`}
</button>
</form>
)}
{buyError && <p className="propose__msg propose__msg--error">{buyError}</p>}
</div>
);
}
// ── Connecting ───────────────────────────────────────────────────────────────
function ConnectingScreen() {
return (
<div className="connecting">
<div className="connecting__logo">
<img src="/assets/logo.svg" alt="argument.es" />
</div>
<div className="connecting__sub">
Conectando
<Dots />
</div>
</div>
);
}
// ── App ──────────────────────────────────────────────────────────────────────
function App() {
const [state, setState] = useState<GameState | null>(null);
const [totalRounds, setTotalRounds] = useState<number | null>(null);
const [viewerCount, setViewerCount] = useState(0);
const [connected, setConnected] = useState(false);
const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0);
const [myVote, setMyVote] = useState<"A" | "B" | null>(null);
const lastVotedRoundRef = useRef<number | null>(null);
const [playerScores, setPlayerScores] = useState<Record<string, number>>({});
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(() => {
const endsAt = state?.active?.viewerVotingEndsAt;
if (!endsAt || state?.active?.phase !== "voting") {
setViewerVotingSecondsLeft(0);
return;
}
function tick() {
const remaining = Math.max(0, Math.ceil((endsAt! - Date.now()) / 1000));
setViewerVotingSecondsLeft(remaining);
}
tick();
const interval = setInterval(tick, 1000);
return () => clearInterval(interval);
}, [state?.active?.viewerVotingEndsAt, state?.active?.phase]);
// Reset my vote when a new round starts
useEffect(() => {
const roundNum = state?.active?.num ?? null;
if (roundNum !== null && roundNum !== lastVotedRoundRef.current) {
setMyVote(null);
lastVotedRoundRef.current = roundNum;
}
}, [state?.active?.num]);
async function handleVote(side: "A" | "B") {
setMyVote(side);
try {
await fetch("/api/vote", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ side }),
});
} catch {
// ignore network errors
}
}
useEffect(() => {
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${wsProtocol}//${window.location.host}/ws`;
let ws: WebSocket;
let reconnectTimer: ReturnType<typeof setTimeout>;
let knownVersion: string | null = null;
function connect() {
ws = new WebSocket(wsUrl);
ws.onopen = () => setConnected(true);
ws.onclose = () => {
setConnected(false);
reconnectTimer = setTimeout(connect, 2000);
};
ws.onmessage = (e) => {
const msg: ServerMessage = JSON.parse(e.data);
if (msg.type === "state") {
if (msg.version) {
if (!knownVersion) knownVersion = msg.version;
else if (knownVersion !== msg.version) return location.reload();
}
setState(msg.data);
setTotalRounds(msg.totalRounds);
setViewerCount(msg.viewerCount);
} else if (msg.type === "viewerCount") {
setViewerCount(msg.viewerCount);
}
};
}
connect();
return () => {
clearTimeout(reconnectTimer);
ws?.close();
};
}, []);
if (!connected || !state) return <ConnectingScreen />;
const isNextPrompting =
state.active?.phase === "prompting" && !state.active.prompt;
const displayRound =
isNextPrompting && state.lastCompleted ? state.lastCompleted : state.active;
return (
<div className="app">
<div className="layout">
<main className="main">
<header className="header">
<a href="/" className="logo">
<img src="/assets/logo.svg" alt="argument.es" />
</a>
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
{state.isPaused && (
<div
className="viewer-pill"
style={{ color: "var(--text-muted)", borderColor: "var(--border)" }}
>
{state.autoPaused ? "Esperando espectadores…" : "En pausa"}
</div>
)}
<div className="viewer-pill" aria-live="polite">
<span className="viewer-pill__dot" />
{viewerCount} espectador{viewerCount === 1 ? "" : "es"} conectado{viewerCount === 1 ? "" : "s"}
</div>
</div>
</header>
{state.done ? (
<GameOver scores={state.scores} />
) : displayRound ? (
<Arena
round={displayRound}
total={totalRounds}
viewerVotingSecondsLeft={viewerVotingSecondsLeft}
myVote={myVote}
onVote={handleVote}
/>
) : (
<div className="waiting">
Iniciando
<Dots />
</div>
)}
{isNextPrompting && state.lastCompleted && (
<div className="next-toast">
<ModelTag model={state.active!.prompter} small /> está escribiendo
la siguiente pregunta
<Dots />
</div>
)}
<ProposeAnswer activeRound={state.active} />
<footer className="site-footer">
<p>IAs compiten respondiendo preguntas absurdas los jueces votan, 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>
</footer>
</main>
<Standings
scores={state.scores}
viewerScores={state.viewerScores ?? {}}
playerScores={playerScores}
activeRound={state.active}
/>
</div>
</div>
);
}
// ── Mount ────────────────────────────────────────────────────────────────────
const root = createRoot(document.getElementById("root")!);
root.render(<App />);