Skip to content

Commit 1ca48f7

Browse files
committed
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
1 parent d838046 commit 1ca48f7

File tree

6 files changed

+203
-16
lines changed

6 files changed

+203
-16
lines changed

frontend/app/(pages)/quiz/[...query]/page.tsx

Lines changed: 84 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,23 @@ import useValidateUrlToken from "@/app/hooks/security/useValidateUrlToken";
44
import useAuth from "@/app/hooks/context_imports/useAuth";
55

66
import { SurvivalQuizInfo, TimeAttackQuizInfo, Message, Participant, ResponseStatus,
7-
PostTimeAttackEntry, PostSurvivalEntry } from "@/app/interfaces";
7+
PostTimeAttackEntry, PostSurvivalEntry, TimeAttackEntry, SurvivalEntry } from "@/app/interfaces";
88
import AnimatedScoreCounter from "@/app/components/AnimatedScoreCounter";
99
import Modal from "@/app/components/modals/Modal";
1010
import { toggleModal, isModalOpen, applyTextMarkup, renderModalResponseAlert,
11-
playSoundEffect, executeEventSequence } from "@/app/utilities/miscFunctions";
11+
playSoundEffect, executeEventSequence, isTimeAttackEntry } from "@/app/utilities/miscFunctions";
1212

1313
import useGetQuizInfo from "@/app/hooks/api_access/quizzes/useGetQuizInfo";
1414
import useGetRandomQuizMessage from "@/app/hooks/api_access/messages/useGetRandomQuizMessage";
1515
import usePostLeaderboardEntry from "@/app/hooks/api_access/leaderboards/usePostLeaderboardEntry";
1616
import useAdjustContentHeight from "@/app/hooks/useAdjustContentHeight";
17+
import useGetPastPlayerLeaderboardEntry from "@/app/hooks/api_access/leaderboards/useGetPastPlayerLeaderboardEntry";
18+
import usePatchLeaderboardEntry from "@/app/hooks/api_access/leaderboards/usePatchLeaderboardEntry";
1719

1820
import AnimateHeight from "react-animate-height";
1921
import { Height } from "react-animate-height";
22+
import {v4 as uuidv4} from "uuid";
23+
2024
import Link from "next/link";
2125
import { useRouter } from "next/navigation";
2226
import { useEffect, useState, useReducer, useRef } from "react";
@@ -56,6 +60,8 @@ export default function Quiz({ params }: { params: { query: string[] } }) {
5660
const getQuizInfo = useGetQuizInfo();
5761
const getRandomQuizMessage = useGetRandomQuizMessage();
5862
const postLeaderboardEntry = usePostLeaderboardEntry();
63+
const getPastPlayerLeaderboardEntry = useGetPastPlayerLeaderboardEntry();
64+
const patchLeaderboardEntry = usePatchLeaderboardEntry();
5965

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

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

@@ -139,6 +148,19 @@ export default function Quiz({ params }: { params: { query: string[] } }) {
139148
console.error("Error retrieving data, redirecting to root");
140149
router.push("/");
141150
}
151+
152+
// See if the player has a UUID in local storage, if not, create one and store it
153+
let uuid: string | null = localStorage.getItem("quizPlayerUUID");
154+
if (uuid) {
155+
// If there is a UUID, retrieve the player's previous leaderboard entry (if any)
156+
const entry: TimeAttackEntry | SurvivalEntry | null = await getPastPlayerLeaderboardEntry(quizId, uuid, shareableToken || undefined);
157+
if (entry) setPreviousLeaderboardEntry(entry);
158+
} else {
159+
uuid = uuidv4(); // Generate a new one
160+
localStorage.setItem("quizPlayerUUID", uuid);
161+
}
162+
setPlayerUUID(uuid);
163+
142164
setStaticDataLoading(false);
143165

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

175-
const submitLeaderboardEntry = async () => {
176-
if (!quizInfo || !totalTimeTaken || playerName === "") return;
197+
const submitLeaderboardEntry = async (isUpdate: boolean = false) => {
198+
if (!quizInfo || !totalTimeTaken || (!isUpdate && playerName === "") || !playerUUID) {
199+
console.error("Error submitting leaderboard entry: Missing required data");
200+
return;
201+
}
202+
if (isUpdate && !previousLeaderboardEntry) {
203+
console.error("Error submitting leaderboard entry: No previous entry found");
204+
return;
205+
}
177206
setSubmitting(true);
207+
208+
// Build the request object
178209
let postRequest: PostTimeAttackEntry | PostSurvivalEntry;
179210
if (isTimeAttack(quizInfo)) {
180211
postRequest = {
181-
playerName: playerName,
212+
playerName: (isUpdate && previousLeaderboardEntry) ? previousLeaderboardEntry.playerName : playerName,
182213
score: score,
183214
timeTaken: totalTimeTaken,
215+
playerUUID: playerUUID
184216
};
185217
} else {
186218
postRequest = {
187-
playerName: playerName,
219+
playerName: (isUpdate && previousLeaderboardEntry) ? previousLeaderboardEntry.playerName : playerName,
188220
streak: score,
189221
skipsUsed: skipsUsed,
222+
playerUUID: playerUUID
190223
};
191224
}
192-
const error: string | null = await postLeaderboardEntry(quizId, postRequest, shareableToken || undefined);
225+
226+
// Make the API request
227+
let error: string | null;
228+
if (isUpdate && previousLeaderboardEntry) {
229+
error = await patchLeaderboardEntry(quizId, previousLeaderboardEntry.id, postRequest, shareableToken || undefined);
230+
} else {
231+
error = await postLeaderboardEntry(quizId, postRequest, shareableToken || undefined);
232+
}
233+
193234
if (!error) {
194235
setResponseStatus({ message: "Entry submitted!", success: true, doAnimate: true });
195236
setScoreSubmitted(true);
@@ -198,14 +239,14 @@ export default function Quiz({ params }: { params: { query: string[] } }) {
198239
setResponseStatus({ message: error, success: false, doAnimate: true });
199240
}
200241

201-
// Display the response message for 3 seconds, then close the modal
242+
// Display the response message for 1.5 seconds, then close the modal
202243
setTimeout(() => {
203244
if (isModalOpen("leaderboard-submit-modal")) {
204245
toggleModal("leaderboard-submit-modal");
205246
}
206247
setSubmitting(false);
207248
setResponseStatus({ message: "", success: false, doAnimate: false });
208-
}, 3000);
249+
}, 1500);
209250
}
210251

211252
// 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[] } }) {
639680
{renderModalResponseAlert(responseStatus, true)}
640681
</>);
641682
} else if (submitting) {
642-
modalContent = (
643-
<div className="mb-8 mt-1 sm:my-12">
644-
<div className="mx-auto mb-3 text-2xl text-center">
683+
modalContent = (<>
684+
<div className="mt-[-12px]" />
685+
<div className="mb-6 mt-4 sm:mb-12 sm:mt-10">
686+
<div className="mx-auto mb-2 text-xl text-center">
645687
Submitting...
646688
</div>
647689
<div className="flex justify-center">
648690
<div className="spinner-circle w-12 h-12 sm:w-14 sm:h-14" />
649691
</div>
650692
</div>
651-
);
693+
</>);
694+
} else if (previousLeaderboardEntry && !noResubmit) {
695+
modalContent = (<>
696+
<div className="w-full mt-3 text-center text-2xl font-semibold">
697+
Update previous entry?
698+
</div>
699+
<div className="w-full text-center text-lg mt-3 text-zinc-300 font-light">
700+
You had a {isTimeAttack(quizInfo) ? "score" : "streak"} of:
701+
<span className="ml-2 text-white font-medium">
702+
{isTimeAttackEntry(previousLeaderboardEntry) ? previousLeaderboardEntry.score : previousLeaderboardEntry.streak}
703+
</span>
704+
</div>
705+
<div className="grid grid-cols-2 gap-2 mt-6 mb-5 mx-7">
706+
<button className="grow btn btn-lg bg-black border border-zinc-900 text-zinc-400 font-light"
707+
onClick={() => setNoResubmit(true)}>
708+
No Thanks
709+
</button>
710+
<button className={`grow btn btn-lg bg-gradient-to-r font-semibold
711+
${isTimeAttack(quizInfo)
712+
? " from-blue-500 from-0% via-blue-400 to-blue-500 to-100% text-indigo-100"
713+
: " from-purple-500 from-0% via-pink-500 to-purple-500 to-100% text-purple-100"}`}
714+
onClick={() => submitLeaderboardEntry(true)}>
715+
Update
716+
</button>
717+
</div>
718+
</>);
652719
} else {
653720
modalContent = (<>
654721
<div className={`w-full mt-2 text-center text-3xl font-semibold text-transparent bg-clip-text bg-gradient-to-r
@@ -730,7 +797,10 @@ export default function Quiz({ params }: { params: { query: string[] } }) {
730797
${isTimeAttack(quizInfo)
731798
? " from-blue-500 from-0% via-blue-400 to-blue-500 to-100% text-indigo-100"
732799
: " from-purple-500 from-0% via-pink-500 to-purple-500 to-100% text-purple-100"}`}
733-
onClick={() => { toggleModal("leaderboard-submit-modal") }}>
800+
onClick={() => {
801+
setNoResubmit(false);
802+
toggleModal("leaderboard-submit-modal");
803+
}}>
734804
Submit
735805
</button>
736806
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { EXTERNAL_API_ROOT } from "@/app/constants";
2+
import { TimeAttackEntry, SurvivalEntry } from "@/app/interfaces";
3+
4+
import useAuthFetch from "../../security/useAuthFetch";
5+
6+
export default function useGetPastPlayerLeaderboardEntry() {
7+
8+
const authFetch = useAuthFetch();
9+
10+
const getPastPlayerLeaderboardEntry = async (
11+
quizId: number,
12+
playerUUID: string,
13+
shareableToken?: string | undefined
14+
): Promise<TimeAttackEntry | SurvivalEntry | null> => {
15+
16+
const requestUrl: string = `${EXTERNAL_API_ROOT}/quizzes/${quizId}/leaderboard/${playerUUID}`;
17+
18+
try {
19+
const response: Response = await authFetch(requestUrl, {}, shareableToken);
20+
if (!response.ok) {
21+
if (response.status === 404) {
22+
console.log("No previous leaderboard entry found for player.");
23+
} else if (response.status >= 400 && response.status < 500) {
24+
console.error(`Client request rejected: ${response.status}`);
25+
} else if (response.status >= 500) {
26+
console.error(`Server failed request: ${response.status}`);
27+
}
28+
return null;
29+
}
30+
return await response.json();
31+
} catch (error) {
32+
console.error(error);
33+
return null;
34+
}
35+
}
36+
37+
return getPastPlayerLeaderboardEntry;
38+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { EXTERNAL_API_ROOT } from "@/app/constants";
2+
import { PostTimeAttackEntry, PostSurvivalEntry } from "@/app/interfaces";
3+
4+
import useAuthFetch from "../../security/useAuthFetch";
5+
6+
export default function usePatchLeaderboardEntry() {
7+
8+
const authFetch = useAuthFetch();
9+
10+
const patchLeaderboardEntry = async (
11+
quizId: number,
12+
entryId: number,
13+
request: PostTimeAttackEntry | PostSurvivalEntry,
14+
shareableToken?: string | undefined
15+
): Promise<string | null> => {
16+
17+
// We determine the request type by checking for a unique property
18+
const requestUrl: string = 'score' in request
19+
? `${EXTERNAL_API_ROOT}/quizzes/${quizId}/leaderboard/${entryId}/time-attack`
20+
: `${EXTERNAL_API_ROOT}/quizzes/${quizId}/leaderboard/${entryId}/survival`;
21+
22+
try {
23+
const response: Response = await authFetch(requestUrl, {
24+
method: "PATCH",
25+
headers: {
26+
"Content-Type": "application/json"
27+
},
28+
body: JSON.stringify(request)
29+
}, shareableToken);
30+
if (!response.ok) {
31+
if (response.status >= 400 && response.status < 500) {
32+
console.error(`Client request rejected: ${response.status}`);
33+
return "Client request rejected";
34+
} else if (response.status >= 500) {
35+
console.error(`Server failed request: ${response.status}`);
36+
return "Server failed to process request";
37+
}
38+
}
39+
return null;
40+
} catch (error) {
41+
console.error(error);
42+
return "Client failed to process request";
43+
}
44+
}
45+
46+
return patchLeaderboardEntry;
47+
}

frontend/app/interfaces.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export interface QuizLeaderboardInfo {
109109
// Post request interfaces
110110
export interface PostLeaderboardEntry {
111111
playerName: string;
112+
playerUUID: string;
112113
}
113114

114115
export interface PostTimeAttackEntry extends PostLeaderboardEntry {

frontend/package-lock.json

Lines changed: 30 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"@types/react": "18.2.22",
1414
"@types/react-dom": "18.2.7",
1515
"@types/swiper": "^6.0.0",
16+
"@types/uuid": "^9.0.8",
1617
"animate.css": "^4.1.1",
1718
"autoprefixer": "10.4.15",
1819
"next": "13.5.2",
@@ -24,6 +25,7 @@
2425
"swiper": "^11.0.6",
2526
"tailwindcss": "3.3.3",
2627
"typescript": "5.2.2",
27-
"universal-cookie": "^6.1.1"
28+
"universal-cookie": "^6.1.1",
29+
"uuid": "^9.0.1"
2830
}
2931
}

0 commit comments

Comments
 (0)