The host marks the answering team "Got it" or "Wrong" (G / W). Wrong passes the turn to the next team in the buzz order; Got it closes the buzzers until "Next question" (B). Undo reverses either mark. In a direct game a correct answer adds 1 to the team's score in the game's first criterion, as an ordinary admin score row, so it flows through the scoring engine (multiplier, placement) and the existing range/step trigger. The point is written before the mark, so a refused point never leaves a "correct" with nothing behind it. Judged games only get the mark: the judges' scores stand. Needs supabase/migrations/0004_buzz_result.sql applied before deploying. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
112 lines
4.6 KiB
TypeScript
112 lines
4.6 KiB
TypeScript
"use client";
|
|
|
|
import { clearBuzzes, markBuzz } from "@/app/admin/live-actions";
|
|
import { Button } from "@/components/ui/button";
|
|
import { formatBuzzGap, type BuzzerView } from "@/lib/buzzer";
|
|
import type { LiveGame } from "@/lib/live-view";
|
|
|
|
import { ErrorText } from "./error-text";
|
|
import { useAction } from "./use-action";
|
|
|
|
/**
|
|
* Who buzzed for the current question, in order. The host marks the answering team:
|
|
* "Got it" gives the point and ends the question, "Wrong" passes the turn down the order.
|
|
*/
|
|
export function BuzzerPanel({ eventId, game, buzzer }: { eventId: string; game: LiveGame; buzzer: BuzzerView }) {
|
|
const { pending, error, run } = useAction();
|
|
const { buzzes, answeringTeamId } = buzzer;
|
|
const decided = buzzes.some((entry) => entry.result === "correct");
|
|
// Only a direct game takes the point; in a judged game the judges' scores stand.
|
|
const pointCriterion = game.scoringMode === "direct" ? game.criteria[0] : undefined;
|
|
|
|
return (
|
|
<div className="rounded-md border border-ink/15 p-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<h3 className="text-lg font-bold">Buzzers</h3>
|
|
<Button
|
|
variant={decided ? "primary" : "secondary"}
|
|
disabled={pending || buzzes.length === 0}
|
|
onClick={() => run(() => clearBuzzes(eventId, game.id))}
|
|
>
|
|
Next question (B)
|
|
</Button>
|
|
</div>
|
|
|
|
{buzzes.length === 0 ? (
|
|
<p role="status" className="mt-1">
|
|
{buzzer.open ? "Waiting for a buzz…" : "Closed — the game is done."}
|
|
</p>
|
|
) : (
|
|
<ol className="mt-2 flex flex-col gap-1">
|
|
{buzzes.map((entry, index) => {
|
|
const answering = entry.teamId === answeringTeamId;
|
|
return (
|
|
<li
|
|
key={entry.teamId}
|
|
aria-current={answering ? "true" : undefined}
|
|
className={`flex min-h-11 flex-wrap items-center gap-2 rounded-md px-2 py-1 ${
|
|
answering ? "bg-floodlight text-xl font-bold" : entry.result === "wrong" ? "text-ink/70" : ""
|
|
}`}
|
|
>
|
|
<span className="w-5 tabular-nums">{index + 1}</span>
|
|
<span aria-hidden className="inline-block size-3 rounded-full" style={{ backgroundColor: entry.color }} />
|
|
<span className={`min-w-0 flex-1 truncate ${entry.result === "wrong" ? "line-through" : ""}`}>
|
|
{entry.name}
|
|
</span>
|
|
<span className="text-sm font-normal tabular-nums">
|
|
{index === 0 ? "first" : formatBuzzGap(entry.afterFirstMs)}
|
|
</span>
|
|
|
|
{answering && (
|
|
<>
|
|
<Button
|
|
disabled={pending}
|
|
onClick={() => run(() => markBuzz(eventId, game.id, entry.teamId, "correct"))}
|
|
>
|
|
{pointCriterion ? "Got it +1 (G)" : "Got it (G)"}
|
|
</Button>
|
|
<Button
|
|
variant="danger"
|
|
disabled={pending}
|
|
onClick={() => run(() => markBuzz(eventId, game.id, entry.teamId, "wrong"))}
|
|
>
|
|
Wrong (W)
|
|
</Button>
|
|
</>
|
|
)}
|
|
{entry.result && (
|
|
<>
|
|
<strong className={entry.result === "correct" ? "" : "text-alert-strong"}>
|
|
{entry.result === "correct" ? "✓ Got it" : "✗ Wrong"}
|
|
</strong>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
disabled={pending}
|
|
aria-label={`Undo for ${entry.name}`}
|
|
onClick={() => run(() => markBuzz(eventId, game.id, entry.teamId, null))}
|
|
>
|
|
Undo
|
|
</Button>
|
|
</>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
)}
|
|
|
|
<p role="status" className="mt-2 text-sm text-ink/70">
|
|
{decided
|
|
? "Answered. Press “Next question” to open the buzzers again."
|
|
: buzzes.length > 0 && !answeringTeamId
|
|
? "Nobody is waiting to answer. The buzzers are still open."
|
|
: pointCriterion
|
|
? `“Got it” adds 1 to ${pointCriterion.label}. “Wrong” passes to the next team.`
|
|
: "Judged game: “Got it” adds no point — the judges score. “Wrong” passes to the next team."}
|
|
</p>
|
|
<ErrorText>{error}</ErrorText>
|
|
</div>
|
|
);
|
|
}
|