From 1ca48f7d3a06c22db97879dd848b532d45bc350f Mon Sep 17 00:00:00 2001 From: Nathan Raymant Date: Mon, 4 Mar 2024 00:39:10 -0500 Subject: [PATCH] Added support for updating previous leaderboard submissions; UUID is generated and stored in localStorage, and then tied with the player's leaderboard entries server-side --- frontend/app/(pages)/quiz/[...query]/page.tsx | 98 ++++++++++++++++--- .../useGetPastPlayerLeaderboardEntry.ts | 38 +++++++ .../leaderboards/usePatchLeaderboardEntry.ts | 47 +++++++++ frontend/app/interfaces.ts | 1 + frontend/package-lock.json | 31 +++++- frontend/package.json | 4 +- 6 files changed, 203 insertions(+), 16 deletions(-) create mode 100644 frontend/app/hooks/api_access/leaderboards/useGetPastPlayerLeaderboardEntry.ts create mode 100644 frontend/app/hooks/api_access/leaderboards/usePatchLeaderboardEntry.ts diff --git a/frontend/app/(pages)/quiz/[...query]/page.tsx b/frontend/app/(pages)/quiz/[...query]/page.tsx index 0aff062..b0080e4 100644 --- a/frontend/app/(pages)/quiz/[...query]/page.tsx +++ b/frontend/app/(pages)/quiz/[...query]/page.tsx @@ -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"; @@ -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(); @@ -69,6 +75,8 @@ export default function Quiz({ params }: { params: { query: string[] } }) { const [nextMessage, setNextMessage] = useState(null); const [seenMessageIds, setSeenMessageIds] = useState>([]); // Prevent repeats const [playerName, setPlayerName] = useState(""); // The name of the player (for submitting to the leaderboard) + const [playerUUID, setPlayerUUID] = useState(null); // The UUID of the player (for updating previous leaderboard submissions) + const [previousLeaderboardEntry, setPreviousLeaderboardEntry] = useState(null); // The player's previous leaderboard entry [if any] const [scoreSubmitted, setScoreSubmitted] = useState(false); // Whether the score has been submitted to the leaderboard // ----------- State (Game) ----------- @@ -82,6 +90,7 @@ export default function Quiz({ params }: { params: { query: string[] } }) { // ----------- State (UI) ------------- const [staticDataLoading, setStaticDataLoading] = useState(true); const [submitting, setSubmitting] = useState(false); // Whether the score is being submitted to the leaderboard + const [noResubmit, setNoResubmit] = useState(false); // Whether the user has decided not to update their previous leaderboard entry // The response status of the leaderboard submission const [responseStatus, setResponseStatus] = useState({ message: "", success: false, doAnimate: false }); @@ -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 @@ -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); @@ -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. @@ -639,16 +680,42 @@ export default function Quiz({ params }: { params: { query: string[] } }) { {renderModalResponseAlert(responseStatus, true)} ); } else if (submitting) { - modalContent = ( -
-
+ modalContent = (<> +
+
+
Submitting...
- ); + ); + } else if (previousLeaderboardEntry && !noResubmit) { + modalContent = (<> +
+ Update previous entry? +
+
+ You had a {isTimeAttack(quizInfo) ? "score" : "streak"} of: + + {isTimeAttackEntry(previousLeaderboardEntry) ? previousLeaderboardEntry.score : previousLeaderboardEntry.streak} + +
+
+ + +
+ ); } else { modalContent = (<>
{ toggleModal("leaderboard-submit-modal") }}> + onClick={() => { + setNoResubmit(false); + toggleModal("leaderboard-submit-modal"); + }}> Submit } diff --git a/frontend/app/hooks/api_access/leaderboards/useGetPastPlayerLeaderboardEntry.ts b/frontend/app/hooks/api_access/leaderboards/useGetPastPlayerLeaderboardEntry.ts new file mode 100644 index 0000000..022ff93 --- /dev/null +++ b/frontend/app/hooks/api_access/leaderboards/useGetPastPlayerLeaderboardEntry.ts @@ -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 => { + + 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; +} \ No newline at end of file diff --git a/frontend/app/hooks/api_access/leaderboards/usePatchLeaderboardEntry.ts b/frontend/app/hooks/api_access/leaderboards/usePatchLeaderboardEntry.ts new file mode 100644 index 0000000..a5944f4 --- /dev/null +++ b/frontend/app/hooks/api_access/leaderboards/usePatchLeaderboardEntry.ts @@ -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 => { + + // 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; +} \ No newline at end of file diff --git a/frontend/app/interfaces.ts b/frontend/app/interfaces.ts index 743d9f8..70c9ff6 100644 --- a/frontend/app/interfaces.ts +++ b/frontend/app/interfaces.ts @@ -109,6 +109,7 @@ export interface QuizLeaderboardInfo { // Post request interfaces export interface PostLeaderboardEntry { playerName: string; + playerUUID: string; } export interface PostTimeAttackEntry extends PostLeaderboardEntry { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fcb2309..ddea818 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,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", @@ -23,7 +24,8 @@ "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" } }, "node_modules/@alloc/quick-lru": { @@ -340,6 +342,11 @@ "swiper": "*" } }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==" + }, "node_modules/animate.css": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/animate.css/-/animate.css-4.1.1.tgz", @@ -1781,6 +1788,18 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", @@ -2108,6 +2127,11 @@ "swiper": "*" } }, + "@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==" + }, "animate.css": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/animate.css/-/animate.css-4.1.1.tgz", @@ -3015,6 +3039,11 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==" + }, "watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index f6e4bcd..e4bd454 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", @@ -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" } }