Files
argument.es/frontend.tsx
Malin 3e5c080466 feat: embed propose-question widget inline in main game page
- Move /pregunta flow into a compact ProposeQuestion component
  rendered inside <main> above the footer
- Tier cards, username input, Redsys payment, polling, and question
  submission all work inline without leaving the game page
- Payment redirects now go to /?credito_ok=ORDER and /?ko=1
  so the game stays in view during the full payment cycle
- Badge shows live questions remaining; updates on each submission
- Removed /pregunta link from footer (functionality is now inline)
- .propose-* CSS: compact tier grid, textarea+button row, spinner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 17:21:35 +01:00

1033 lines
33 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;
};
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;
questionsLeft: number | null;
};
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€" },
] 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;
if (!c.token || !c.expiresAt || c.expiresAt < Date.now()) {
localStorage.removeItem(CREDIT_STORAGE_KEY);
return null;
}
return c;
} 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>
)}
</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 Question (inline widget) ─────────────────────────────────────────
function ProposeQuestion() {
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 [sent, setSent] = useState(false);
const [koDismissed, setKoDismissed] = useState(false);
useEffect(() => {
setCredit(loadCredit());
setLoaded(true);
}, []);
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;
questionsLeft?: number | null;
};
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,
};
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 handleSubmitQuestion(e: React.FormEvent) {
e.preventDefault();
if (!credit) return;
setSubmitError(null);
setSubmitting(true);
try {
const res = await fetch("/api/pregunta/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("Acceso expirado o sin preguntas disponibles.");
}
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 };
localStorage.setItem(CREDIT_STORAGE_KEY, JSON.stringify(updated));
setCredit(updated);
setText("");
setSent(true);
setTimeout(() => setSent(false), 3000);
} 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.questionsLeft !== null && credit.questionsLeft <= 0;
// 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 — question form
if (credit) {
const badge = credit.questionsLeft === null
? "Ilimitadas"
: `${credit.questionsLeft} restante${credit.questionsLeft !== 1 ? "s" : ""}`;
return (
<div className="propose">
<div className="propose__head">
<span className="propose__title">Propón una pregunta · {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}>
<div className="propose__row">
<textarea
className="propose__textarea"
value={text}
onChange={e => setText(e.target.value)}
placeholder='"La peor cosa que puedes encontrar en ___"'
rows={2}
maxLength={200}
/>
<button className="propose__btn" type="submit" disabled={submitting || text.trim().length < 10}>
{submitting ? "…" : "Enviar"}
</button>
</div>
<div className="propose__hint">
{text.length}/200 · mín. 10 ·{" "}
<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>
) : (
<div className="propose__row">
<p className="propose__msg propose__msg--error" style={{ flex: 1 }}>Has agotado tus preguntas.</p>
<button className="propose__btn" onClick={() => {
localStorage.removeItem(CREDIT_STORAGE_KEY);
setCredit(null);
}}>Nuevo plan</button>
</div>
)}
</div>
);
}
// Tier selection
return (
<div className="propose">
<div className="propose__head">
<span className="propose__title">Propón preguntas al juego</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>
)}
<ProposeQuestion />
<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 />);