Skip to content

Commit

Permalink
Added support for updating previous leaderboard submissions; UUID is …
Browse files Browse the repository at this point in the history
…generated and stored in localStorage, and then tied with the player's leaderboard entries server-side
  • Loading branch information
Nathan7934 committed Mar 4, 2024
1 parent d838046 commit 1ca48f7
Show file tree
Hide file tree
Showing 6 changed files with 203 additions and 16 deletions.
98 changes: 84 additions & 14 deletions frontend/app/(pages)/quiz/[...query]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,23 @@ import useValidateUrlToken from "@/app/hooks/security/useValidateUrlToken";
import useAuth from "@/app/hooks/context_imports/useAuth";

import { SurvivalQuizInfo, TimeAttackQuizInfo, Message, Participant, ResponseStatus,
PostTimeAttackEntry, PostSurvivalEntry } from "@/app/interfaces";
PostTimeAttackEntry, PostSurvivalEntry, TimeAttackEntry, SurvivalEntry } from "@/app/interfaces";
import AnimatedScoreCounter from "@/app/components/AnimatedScoreCounter";
import Modal from "@/app/components/modals/Modal";
import { toggleModal, isModalOpen, applyTextMarkup, renderModalResponseAlert,
playSoundEffect, executeEventSequence } from "@/app/utilities/miscFunctions";
playSoundEffect, executeEventSequence, isTimeAttackEntry } from "@/app/utilities/miscFunctions";

import useGetQuizInfo from "@/app/hooks/api_access/quizzes/useGetQuizInfo";
import useGetRandomQuizMessage from "@/app/hooks/api_access/messages/useGetRandomQuizMessage";
import usePostLeaderboardEntry from "@/app/hooks/api_access/leaderboards/usePostLeaderboardEntry";
import useAdjustContentHeight from "@/app/hooks/useAdjustContentHeight";
import useGetPastPlayerLeaderboardEntry from "@/app/hooks/api_access/leaderboards/useGetPastPlayerLeaderboardEntry";
import usePatchLeaderboardEntry from "@/app/hooks/api_access/leaderboards/usePatchLeaderboardEntry";

import AnimateHeight from "react-animate-height";
import { Height } from "react-animate-height";
import {v4 as uuidv4} from "uuid";

import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState, useReducer, useRef } from "react";
Expand Down Expand Up @@ -56,6 +60,8 @@ export default function Quiz({ params }: { params: { query: string[] } }) {
const getQuizInfo = useGetQuizInfo();
const getRandomQuizMessage = useGetRandomQuizMessage();
const postLeaderboardEntry = usePostLeaderboardEntry();
const getPastPlayerLeaderboardEntry = useGetPastPlayerLeaderboardEntry();
const patchLeaderboardEntry = usePatchLeaderboardEntry();

// Security
const { auth } = useAuth();
Expand All @@ -69,6 +75,8 @@ export default function Quiz({ params }: { params: { query: string[] } }) {
const [nextMessage, setNextMessage] = useState<Message | null>(null);
const [seenMessageIds, setSeenMessageIds] = useState<Array<number>>([]); // Prevent repeats
const [playerName, setPlayerName] = useState<string>(""); // The name of the player (for submitting to the leaderboard)
const [playerUUID, setPlayerUUID] = useState<string | null>(null); // The UUID of the player (for updating previous leaderboard submissions)
const [previousLeaderboardEntry, setPreviousLeaderboardEntry] = useState<TimeAttackEntry | SurvivalEntry | null>(null); // The player's previous leaderboard entry [if any]
const [scoreSubmitted, setScoreSubmitted] = useState<boolean>(false); // Whether the score has been submitted to the leaderboard

// ----------- State (Game) -----------
Expand All @@ -82,6 +90,7 @@ export default function Quiz({ params }: { params: { query: string[] } }) {
// ----------- State (UI) -------------
const [staticDataLoading, setStaticDataLoading] = useState<boolean>(true);
const [submitting, setSubmitting] = useState<boolean>(false); // Whether the score is being submitted to the leaderboard
const [noResubmit, setNoResubmit] = useState<boolean>(false); // Whether the user has decided not to update their previous leaderboard entry
// The response status of the leaderboard submission
const [responseStatus, setResponseStatus] = useState<ResponseStatus>({ message: "", success: false, doAnimate: false });

Expand Down Expand Up @@ -139,6 +148,19 @@ export default function Quiz({ params }: { params: { query: string[] } }) {
console.error("Error retrieving data, redirecting to root");
router.push("/");
}

// See if the player has a UUID in local storage, if not, create one and store it
let uuid: string | null = localStorage.getItem("quizPlayerUUID");
if (uuid) {
// If there is a UUID, retrieve the player's previous leaderboard entry (if any)
const entry: TimeAttackEntry | SurvivalEntry | null = await getPastPlayerLeaderboardEntry(quizId, uuid, shareableToken || undefined);
if (entry) setPreviousLeaderboardEntry(entry);
} else {
uuid = uuidv4(); // Generate a new one
localStorage.setItem("quizPlayerUUID", uuid);
}
setPlayerUUID(uuid);

setStaticDataLoading(false);

// Begin the intro splash timing sequence
Expand Down Expand Up @@ -172,24 +194,43 @@ export default function Quiz({ params }: { params: { query: string[] } }) {
}
}

const submitLeaderboardEntry = async () => {
if (!quizInfo || !totalTimeTaken || playerName === "") return;
const submitLeaderboardEntry = async (isUpdate: boolean = false) => {
if (!quizInfo || !totalTimeTaken || (!isUpdate && playerName === "") || !playerUUID) {
console.error("Error submitting leaderboard entry: Missing required data");
return;
}
if (isUpdate && !previousLeaderboardEntry) {
console.error("Error submitting leaderboard entry: No previous entry found");
return;
}
setSubmitting(true);

// Build the request object
let postRequest: PostTimeAttackEntry | PostSurvivalEntry;
if (isTimeAttack(quizInfo)) {
postRequest = {
playerName: playerName,
playerName: (isUpdate && previousLeaderboardEntry) ? previousLeaderboardEntry.playerName : playerName,
score: score,
timeTaken: totalTimeTaken,
playerUUID: playerUUID
};
} else {
postRequest = {
playerName: playerName,
playerName: (isUpdate && previousLeaderboardEntry) ? previousLeaderboardEntry.playerName : playerName,
streak: score,
skipsUsed: skipsUsed,
playerUUID: playerUUID
};
}
const error: string | null = await postLeaderboardEntry(quizId, postRequest, shareableToken || undefined);

// Make the API request
let error: string | null;
if (isUpdate && previousLeaderboardEntry) {
error = await patchLeaderboardEntry(quizId, previousLeaderboardEntry.id, postRequest, shareableToken || undefined);
} else {
error = await postLeaderboardEntry(quizId, postRequest, shareableToken || undefined);
}

if (!error) {
setResponseStatus({ message: "Entry submitted!", success: true, doAnimate: true });
setScoreSubmitted(true);
Expand All @@ -198,14 +239,14 @@ export default function Quiz({ params }: { params: { query: string[] } }) {
setResponseStatus({ message: error, success: false, doAnimate: true });
}

// Display the response message for 3 seconds, then close the modal
// Display the response message for 1.5 seconds, then close the modal
setTimeout(() => {
if (isModalOpen("leaderboard-submit-modal")) {
toggleModal("leaderboard-submit-modal");
}
setSubmitting(false);
setResponseStatus({ message: "", success: false, doAnimate: false });
}, 3000);
}, 1500);
}

// Gets a random selection of 4 or 3 (depending on type) participants to choose from, including the correct participant.
Expand Down Expand Up @@ -639,16 +680,42 @@ export default function Quiz({ params }: { params: { query: string[] } }) {
{renderModalResponseAlert(responseStatus, true)}
</>);
} else if (submitting) {
modalContent = (
<div className="mb-8 mt-1 sm:my-12">
<div className="mx-auto mb-3 text-2xl text-center">
modalContent = (<>
<div className="mt-[-12px]" />
<div className="mb-6 mt-4 sm:mb-12 sm:mt-10">
<div className="mx-auto mb-2 text-xl text-center">
Submitting...
</div>
<div className="flex justify-center">
<div className="spinner-circle w-12 h-12 sm:w-14 sm:h-14" />
</div>
</div>
);
</>);
} else if (previousLeaderboardEntry && !noResubmit) {
modalContent = (<>
<div className="w-full mt-3 text-center text-2xl font-semibold">
Update previous entry?
</div>
<div className="w-full text-center text-lg mt-3 text-zinc-300 font-light">
You had a {isTimeAttack(quizInfo) ? "score" : "streak"} of:
<span className="ml-2 text-white font-medium">
{isTimeAttackEntry(previousLeaderboardEntry) ? previousLeaderboardEntry.score : previousLeaderboardEntry.streak}
</span>
</div>
<div className="grid grid-cols-2 gap-2 mt-6 mb-5 mx-7">
<button className="grow btn btn-lg bg-black border border-zinc-900 text-zinc-400 font-light"
onClick={() => setNoResubmit(true)}>
No Thanks
</button>
<button className={`grow btn btn-lg bg-gradient-to-r font-semibold
${isTimeAttack(quizInfo)
? " from-blue-500 from-0% via-blue-400 to-blue-500 to-100% text-indigo-100"
: " from-purple-500 from-0% via-pink-500 to-purple-500 to-100% text-purple-100"}`}
onClick={() => submitLeaderboardEntry(true)}>
Update
</button>
</div>
</>);
} else {
modalContent = (<>
<div className={`w-full mt-2 text-center text-3xl font-semibold text-transparent bg-clip-text bg-gradient-to-r
Expand Down Expand Up @@ -730,7 +797,10 @@ export default function Quiz({ params }: { params: { query: string[] } }) {
${isTimeAttack(quizInfo)
? " from-blue-500 from-0% via-blue-400 to-blue-500 to-100% text-indigo-100"
: " from-purple-500 from-0% via-pink-500 to-purple-500 to-100% text-purple-100"}`}
onClick={() => { toggleModal("leaderboard-submit-modal") }}>
onClick={() => {
setNoResubmit(false);
toggleModal("leaderboard-submit-modal");
}}>
Submit
</button>
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { EXTERNAL_API_ROOT } from "@/app/constants";
import { TimeAttackEntry, SurvivalEntry } from "@/app/interfaces";

import useAuthFetch from "../../security/useAuthFetch";

export default function useGetPastPlayerLeaderboardEntry() {

const authFetch = useAuthFetch();

const getPastPlayerLeaderboardEntry = async (
quizId: number,
playerUUID: string,
shareableToken?: string | undefined
): Promise<TimeAttackEntry | SurvivalEntry | null> => {

const requestUrl: string = `${EXTERNAL_API_ROOT}/quizzes/${quizId}/leaderboard/${playerUUID}`;

try {
const response: Response = await authFetch(requestUrl, {}, shareableToken);
if (!response.ok) {
if (response.status === 404) {
console.log("No previous leaderboard entry found for player.");
} else if (response.status >= 400 && response.status < 500) {
console.error(`Client request rejected: ${response.status}`);
} else if (response.status >= 500) {
console.error(`Server failed request: ${response.status}`);
}
return null;
}
return await response.json();
} catch (error) {
console.error(error);
return null;
}
}

return getPastPlayerLeaderboardEntry;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { EXTERNAL_API_ROOT } from "@/app/constants";
import { PostTimeAttackEntry, PostSurvivalEntry } from "@/app/interfaces";

import useAuthFetch from "../../security/useAuthFetch";

export default function usePatchLeaderboardEntry() {

const authFetch = useAuthFetch();

const patchLeaderboardEntry = async (
quizId: number,
entryId: number,
request: PostTimeAttackEntry | PostSurvivalEntry,
shareableToken?: string | undefined
): Promise<string | null> => {

// We determine the request type by checking for a unique property
const requestUrl: string = 'score' in request
? `${EXTERNAL_API_ROOT}/quizzes/${quizId}/leaderboard/${entryId}/time-attack`
: `${EXTERNAL_API_ROOT}/quizzes/${quizId}/leaderboard/${entryId}/survival`;

try {
const response: Response = await authFetch(requestUrl, {
method: "PATCH",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(request)
}, shareableToken);
if (!response.ok) {
if (response.status >= 400 && response.status < 500) {
console.error(`Client request rejected: ${response.status}`);
return "Client request rejected";
} else if (response.status >= 500) {
console.error(`Server failed request: ${response.status}`);
return "Server failed to process request";
}
}
return null;
} catch (error) {
console.error(error);
return "Client failed to process request";
}
}

return patchLeaderboardEntry;
}
1 change: 1 addition & 0 deletions frontend/app/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export interface QuizLeaderboardInfo {
// Post request interfaces
export interface PostLeaderboardEntry {
playerName: string;
playerUUID: string;
}

export interface PostTimeAttackEntry extends PostLeaderboardEntry {
Expand Down
31 changes: 30 additions & 1 deletion frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@types/react": "18.2.22",
"@types/react-dom": "18.2.7",
"@types/swiper": "^6.0.0",
"@types/uuid": "^9.0.8",
"animate.css": "^4.1.1",
"autoprefixer": "10.4.15",
"next": "13.5.2",
Expand All @@ -24,6 +25,7 @@
"swiper": "^11.0.6",
"tailwindcss": "3.3.3",
"typescript": "5.2.2",
"universal-cookie": "^6.1.1"
"universal-cookie": "^6.1.1",
"uuid": "^9.0.1"
}
}

0 comments on commit 1ca48f7

Please sign in to comment.