feat: add viewer voting on user answers with leaderboard scoring
Viewers can now vote for their favourite audience answers during the 30-second voting window. Votes are persisted to the DB at round end and aggregated as SUM(votes) in the JUGADORES leaderboard. - db.ts: add persistUserAnswerVotes(); switch getPlayerScores() to SUM(votes) - game.ts: add userAnswerVotes to RoundState; persist votes before saveRound - server.ts: add userAnswerVoters map + /api/vote/respuesta endpoint - frontend.tsx: add userAnswerVotes type; vote state/handler in App; ▲ buttons in Arena - frontend.css: flex layout for user-answer rows; user-vote-btn styles Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
60
frontend.tsx
60
frontend.tsx
@@ -34,6 +34,7 @@ type RoundState = {
|
||||
viewerVotesB?: number;
|
||||
viewerVotingEndsAt?: number;
|
||||
userAnswers?: { username: string; text: string }[];
|
||||
userAnswerVotes?: Record<string, number>;
|
||||
};
|
||||
type GameState = {
|
||||
lastCompleted: RoundState | null;
|
||||
@@ -337,12 +338,16 @@ function Arena({
|
||||
viewerVotingSecondsLeft,
|
||||
myVote,
|
||||
onVote,
|
||||
myUserAnswerVote,
|
||||
onUserAnswerVote,
|
||||
}: {
|
||||
round: RoundState;
|
||||
total: number | null;
|
||||
viewerVotingSecondsLeft: number;
|
||||
myVote: "A" | "B" | null;
|
||||
onVote: (side: "A" | "B") => void;
|
||||
myUserAnswerVote: string | null;
|
||||
onUserAnswerVote: (username: string) => void;
|
||||
}) {
|
||||
const [contA, contB] = round.contestants;
|
||||
const showVotes = round.phase === "voting" || round.phase === "done";
|
||||
@@ -439,13 +444,35 @@ function Arena({
|
||||
<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>
|
||||
))}
|
||||
{round.userAnswers.map((a, i) => {
|
||||
const voteCount = round.userAnswerVotes?.[a.username] ?? 0;
|
||||
const isMyVote = myUserAnswerVote === a.username;
|
||||
return (
|
||||
<div key={i} className="user-answer">
|
||||
<div className="user-answer__main">
|
||||
<span className="user-answer__name">{a.username}</span>
|
||||
<span className="user-answer__sep"> — </span>
|
||||
<span className="user-answer__text">“{a.text}”</span>
|
||||
</div>
|
||||
{(showCountdown || voteCount > 0) && (
|
||||
<div className="user-answer__vote">
|
||||
{showCountdown && (
|
||||
<button
|
||||
className={`user-vote-btn ${isMyVote ? "user-vote-btn--active" : ""}`}
|
||||
onClick={() => onUserAnswerVote(a.username)}
|
||||
title="Votar"
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
)}
|
||||
{voteCount > 0 && (
|
||||
<span className="user-vote-count">{voteCount}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -950,6 +977,7 @@ function App() {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [viewerVotingSecondsLeft, setViewerVotingSecondsLeft] = useState(0);
|
||||
const [myVote, setMyVote] = useState<"A" | "B" | null>(null);
|
||||
const [myUserAnswerVote, setMyUserAnswerVote] = useState<string | null>(null);
|
||||
const lastVotedRoundRef = useRef<number | null>(null);
|
||||
const [playerScores, setPlayerScores] = useState<Record<string, number>>({});
|
||||
|
||||
@@ -984,11 +1012,12 @@ function App() {
|
||||
return () => clearInterval(interval);
|
||||
}, [state?.active?.viewerVotingEndsAt, state?.active?.phase]);
|
||||
|
||||
// Reset my vote when a new round starts
|
||||
// Reset my votes when a new round starts
|
||||
useEffect(() => {
|
||||
const roundNum = state?.active?.num ?? null;
|
||||
if (roundNum !== null && roundNum !== lastVotedRoundRef.current) {
|
||||
setMyVote(null);
|
||||
setMyUserAnswerVote(null);
|
||||
lastVotedRoundRef.current = roundNum;
|
||||
}
|
||||
}, [state?.active?.num]);
|
||||
@@ -1006,6 +1035,19 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUserAnswerVote(username: string) {
|
||||
setMyUserAnswerVote(username);
|
||||
try {
|
||||
await fetch("/api/vote/respuesta", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
} catch {
|
||||
// ignore network errors
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/ws`;
|
||||
@@ -1083,6 +1125,8 @@ function App() {
|
||||
viewerVotingSecondsLeft={viewerVotingSecondsLeft}
|
||||
myVote={myVote}
|
||||
onVote={handleVote}
|
||||
myUserAnswerVote={myUserAnswerVote}
|
||||
onUserAnswerVote={handleUserAnswerVote}
|
||||
/>
|
||||
) : (
|
||||
<div className="waiting">
|
||||
|
||||
Reference in New Issue
Block a user