app/components/question-panel.tsx (179 lines of code) (raw):
/**
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
'use client';
import {DocumentReference} from 'firebase/firestore';
import {Game, Question, gameStates} from '@/app/types';
import BorderCountdownTimer from '@/app/components/border-countdown-timer';
import useFirebaseAuthentication from '@/app/hooks/use-firebase-authentication';
import QRCode from 'react-qr-code';
import {useEffect, useState} from 'react';
import Scoreboard from './scoreboard';
import useScoreboard from '@/app/hooks/use-scoreboard';
import {updateAnswerAction} from '@/app/actions/update-answer';
import {getTokens} from '@/app/lib/client-token-generator';
export default function QuestionPanel({game, gameRef, currentQuestion}: { game: Game, gameRef: DocumentReference, currentQuestion: Question }) {
const authUser = useFirebaseAuthentication();
const {currentPlayer, playerScores} = useScoreboard();
const isGameLeader = authUser.uid === game.leader.uid;
const [answersSelectedCount, setAnswersSelectedCount] = useState<number>(0);
const [hasEverGuessed, setHasEverGuessed] = useState<boolean>(false);
const existingGuesses = currentQuestion?.playerGuesses && currentQuestion.playerGuesses[authUser.uid];
const emptyAnswerSelection = Array(currentQuestion.answers.length).fill(false);
const answerSelection = existingGuesses || emptyAnswerSelection;
const gameId = gameRef.id;
const totalCorrectAnswerOptions = currentQuestion.answers.reduce((correctAnswerCount, answer) => {
return correctAnswerCount + (answer.isCorrect ? 1 : 0);
}, 0);
useEffect(() => {
setAnswersSelectedCount(answerSelection.reduce((correctAnswerCount, answerIsSelected) => {
return correctAnswerCount + (answerIsSelected ? 1 : 0);
}, 0));
}, [answerSelection]);
const isSingleAnswer = totalCorrectAnswerOptions === 1;
const onAnswerClick = async (answerIndex: number) => {
if (game.state === gameStates.AWAITING_PLAYER_ANSWERS && !isGameLeader) {
// If the user is only supposed to pick one answer, clear the other answers first
const newAnswerSelection: boolean[] = answerSelection.map((currentValue, index) => {
// update the selection to true
if (index === answerIndex) return true;
// update other selections to false if there is only one correct answer
if (isSingleAnswer) return false;
// otherwise, don't change it
return currentValue;
});
const tokens = await getTokens();
await updateAnswerAction({gameId, answerSelection: newAnswerSelection, tokens});
}
};
const gameShareLink = `${location.protocol}//${location.host}/game/${gameId}`;
const isShowingCorrectAnswers = game.state === gameStates.SHOWING_CORRECT_ANSWERS;
const totalPlayersWhoMadeAGuess = Object.values(currentQuestion.playerGuesses || []).length;
const countLeftToPick = totalCorrectAnswerOptions - answersSelectedCount;
const hasGuessed = answersSelectedCount > 0;
useEffect(() => {
if (hasGuessed) {
setHasEverGuessed(true);
}
}, [hasGuessed]);
return (
<div className={`grid lg:grid-cols-2`}>
<div className="flex flex-col">
<BorderCountdownTimer game={game} gameRef={gameRef}>
<div className="flex flex-col justify-between h-full">
<h2 className={isShowingCorrectAnswers ? 'transition-all text-sm font-light' : 'text-lg md:text-2xl lg:text-4xl'}>
{currentQuestion.prompt}
</h2>
<div>
{isShowingCorrectAnswers ? (<>
<h2 className="md:text-xl lg:text-2xl pt-5">
{currentQuestion.explanation}
</h2>
</>) : (<div>
<h2 className="text-xl lg:text-4xl pt-5">
Pick {totalCorrectAnswerOptions}
</h2>
{hasEverGuessed && (
<h3 className="font-light text-lg lg:text-xl">
{countLeftToPick !== 0 ? (
`Pick ${Math.abs(countLeftToPick)} ${countLeftToPick > 0 ? 'More' : 'Less'}`
) : 'You are all set'}
</h3>
)}
</div>)}
{hasEverGuessed && currentPlayer.displayName && <div className="mt-2">You are {currentPlayer.displayName}
{isShowingCorrectAnswers && currentPlayer?.score > -1 && ` and you have ${currentPlayer.score} point${currentPlayer.score === 1 ? '' : 's'}`}
</div>}
</div>
</div>
</BorderCountdownTimer>
<center className='hidden bg-gray-100 h-[50dvh] lg:block overflow-hidden'>
{(isShowingCorrectAnswers && playerScores.length > 0) ? (<>
<Scoreboard />
</>) : (<>
<div className="flex h-full">
<div className="m-auto">
<div>
Just getting here?
</div>
<div>
Scan the QR Code to join the game!
</div>
<QRCode value={gameShareLink} />
</div>
</div>
</>)}
</center>
</div>
<div className="grid grid-rows-4 h-[50dvh] lg:h-full">
{currentQuestion.answers.map((answer, index) => {
const guessesForThisAnswer = Object.values(currentQuestion.playerGuesses || []).reduce((playerGuesses, guess) => {
return playerGuesses + (guess[index] ? 1 : 0);
}, 0);
const guessPercentageForThisAnswer = guessesForThisAnswer / (totalPlayersWhoMadeAGuess || 1) * 100;
const colorOrder = ['red', 'blue', 'green', 'yellow'];
const color = colorOrder[index];
const isSelected = answerSelection[index];
const isLeaderReveal = !hasEverGuessed && isShowingCorrectAnswers && answer.isCorrect;
const isChecked = isSelected || isLeaderReveal;
const histogramBarPercentage = () => {
// don't show bar until after question
if (!isShowingCorrectAnswers) return 0;
// if no guesses were made, show full bar for correct answers
if (totalPlayersWhoMadeAGuess === 0 && isLeaderReveal) return 100;
return guessPercentageForThisAnswer;
};
const answerDisplayText = () => {
if (hasEverGuessed) {
if (answer.isCorrect && isSelected) return 'You got it ✭';
if (answer.isCorrect) return 'You missed this one';
if (isSelected) return 'Not this one ✖';
return <div className="whitespace-wrap"> </div>;
}
if (answer.isCorrect) return '✭';
return <div className="whitespace-wrap"> </div>;
};
return (<div className="flex" key={`${currentQuestion.prompt} ${answer.text}`}>
<button
onClick={() => onAnswerClick(index)}
className="m-1 w-full relative flex content-start text-left overflow-hidden"
>
<div className="w-full px-1 m-auto line-clamp-1 overflow-hidden border-8 border-transparent flex justify-between h-fit">
<span className={`h-fit text-xl lg:text-3xl my-auto`}>
{isSingleAnswer && (isChecked ? ' ● ' : ' ○ ')}
{!isSingleAnswer && (isChecked ? ' ☑ ' : ' ☐ ')}
{answer.text}
</span>
</div>
{isShowingCorrectAnswers && (<>
<div className="absolute bottom-0 right-0 border-8 border-transparent min-w-fit h-full text-right text-lg lg:text-2xl flex">
<div className="h-full w-20 max-w-full bg-gradient-to-l from-white via-transparent via-white via-20% -m-1" />
<div className="bg-white pl-1 h-fit my-auto">
<div className="whitespace-nowrap">
{answerDisplayText()}
</div>
{totalPlayersWhoMadeAGuess > 0 && (
<div>
{guessesForThisAnswer}
<span className='text-xs'> / {totalPlayersWhoMadeAGuess}</span>
</div>
)}
</div>
</div>
</>)
}
<div className="absolute bottom-0 h-full w-full text-black">
<div
className={`absolute bottom-0 left-0 h-full opacity-25 transition-all duration-[3000ms]`}
style={{
backgroundColor: answer.isCorrect ? `var(--google-cloud-${color})` : '#9ca3af',
width: `${histogramBarPercentage()}%`,
}}
/>
</div>
<div
className={`transition-all ${isChecked ? 'border-8' : 'border-4'} w-full absolute bottom-0 h-full`}
style={{
borderColor: (answer.isCorrect || !isShowingCorrectAnswers) ? `var(--google-cloud-${color})` : '#9ca3af',
}}
/>
</button>
</div>);
})}
</div >
</div >
);
}