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; viewerScores: Record; 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 = { "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 ( . . . ); } function ModelTag({ model, small }: { model: Model; small?: boolean }) { const logo = getLogo(model.name); const color = getColor(model.name); return ( {logo && } {model.name} ); } // ── Prompt ─────────────────────────────────────────────────────────────────── function PromptCard({ round }: { round: RoundState }) { if (round.phase === "prompting" && !round.prompt) { return (
está escribiendo una pregunta
); } if (round.promptTask.error) { return (
Error al generar la pregunta
); } return (
Pregunta de
{round.prompt}
); } // ── 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 (
{isWinner && GANA}
{!task.finishedAt ? (

) : task.error ? (

{task.error}

) : (

“{task.result}”

)}
{showVotes && (
{voteCount} voto{voteCount !== 1 ? "s" : ""} {voters.map((v, i) => { const logo = getLogo(v.voter.name); return logo ? ( {v.voter.name} ) : ( {v.voter.name[0]} ); })}
{showViewerVotes && ( <>
{viewerVotes ?? 0} voto{(viewerVotes ?? 0) !== 1 ? "s" : ""} del público 👥
)}
)}
); } // ── 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 (
Round {round.num} {total ? /{total} : null} {phaseText} {showCountdown && ( {viewerVotingSecondsLeft}s )}
{showCountdown && (
¿Cuál es más gracioso?
)} {round.phase !== "prompting" && (
votesB} showVotes={showVotes} voters={votersA} viewerVotes={round.viewerVotesA} totalViewerVotes={totalViewerVotes} /> votesA} showVotes={showVotes} voters={votersB} viewerVotes={round.viewerVotesB} totalViewerVotes={totalViewerVotes} />
)} {isDone && votesA === votesB && totalVotes > 0 && (
Empate
)}
); } // ── Game Over ──────────────────────────────────────────────────────────────── function GameOver({ scores }: { scores: Record }) { const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]); const champion = sorted[0]; return (
Fin del Juego
{champion && champion[1] > 0 && (
👑 {getLogo(champion[0]) && } {champion[0]} es la IA más graciosa
)}
); } // ── Standings ──────────────────────────────────────────────────────────────── function LeaderboardSection({ label, scores, competing, }: { label: string; scores: Record; competing: Set; }) { const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]); const maxScore = sorted[0]?.[1] || 1; return (
{label}
{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 (
{i === 0 && score > 0 ? "👑" : i + 1} {score}
); })}
); } function PlayerLeaderboard({ scores }: { scores: Record }) { const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]).slice(0, 7); const maxScore = sorted[0]?.[1] || 1; return (
Jugadores Jugar
{sorted.map(([name, score], i) => { const pct = Math.round((score / maxScore) * 100); return (
{i === 0 && score > 0 ? "🏆" : i + 1} {name} {score}
); })}
); } function Standings({ scores, viewerScores, playerScores, activeRound, }: { scores: Record; viewerScores: Record; playerScores: Record; activeRound: RoundState | null; }) { const competing = activeRound ? new Set([ activeRound.contestants[0].name, activeRound.contestants[1].name, ]) : new Set(); return ( ); } // ── 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(null); const [loaded, setLoaded] = useState(false); const [verifying, setVerifying] = useState(false); const [verifyError, setVerifyError] = useState(false); const [selectedTier, setSelectedTier] = useState(null); const [username, setUsername] = useState(""); const [buying, setBuying] = useState(false); const [buyError, setBuyError] = useState(null); const [text, setText] = useState(""); const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(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 (
Verificando pago {!verifyError &&
}
{verifyError && (

No se pudo confirmar. Recarga si el pago se completó.

)}
); } // Active credit — question form if (credit) { const badge = credit.questionsLeft === null ? "Ilimitadas" : `${credit.questionsLeft} restante${credit.questionsLeft !== 1 ? "s" : ""}`; return (
Propón una pregunta · {credit.username} {badge}
{sent &&

✓ ¡Enviada! Se usará en el próximo sorteo.

} {!exhausted ? (