diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 0000000..fc8fb4f --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { useEffect } from "react"; +import Link from "next/link"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + // Log the error to an error reporting service + console.error("Application error:", error); + }, [error]); + + return ( +
+ {/* Header */} +

+ Something went wrong! +

+ + {/* Description */} +

+ We're sorry, but something unexpected happened. Don't worry, + our practice exams are + still here waiting for you! +

+ + {/* Error Details (only in development) */} + {process.env.NODE_ENV === "development" && error.message && ( +
+

+ {error.message} +

+
+ )} + + {/* Action buttons */} +
+ + + 📚 Go to Home + +
+ + {/* Help text */} +

+ If this problem persists, please{" "} + + report it on GitHub + + . +

+
+ ); +} diff --git a/app/exam/page.tsx b/app/exam/page.tsx index a788375..b515713 100644 --- a/app/exam/page.tsx +++ b/app/exam/page.tsx @@ -27,11 +27,12 @@ const questionsQuery = gql` } `; -const Exam: NextPage<{ searchParams: { url: string; name: string } }> = ({ - searchParams, -}) => { +const Exam: NextPage = () => { const { isAccessBlocked, isInTrial } = useTrialAccess(); - const { url } = searchParams; + const [searchParams, setSearchParams] = useState( + null, + ); + const url = searchParams?.get("url") || ""; const { minutes, seconds } = { minutes: 15, seconds: 0, @@ -45,16 +46,23 @@ const Exam: NextPage<{ searchParams: { url: string; name: string } }> = ({ const [countAnswered, setCountAnswered] = useState(0); const { data, loading, error } = useQuery(questionsQuery, { variables: { range: 30, link: url }, + skip: !url, // Skip query if URL is not available }); const [resultPoints, setResultPoints] = useState(0); const [passed, setPassed] = useState(false); const [windowWidth, setWindowWidth] = useState(0); - const editedUrl = url.substring(0, url.lastIndexOf("/") + 1); + const editedUrl = + url && url.includes("/") ? url.substring(0, url.lastIndexOf("/") + 1) : ""; const elapsedSeconds = totalTimeInSeconds - (parseInt(remainingTime.split(":")[0]) * 60 + parseInt(remainingTime.split(":")[1])); + useEffect(() => { + const param = new URLSearchParams(window.location.search); + setSearchParams(param); + }, []); + const handleCountAnswered = () => { setCountAnswered(countAnswered + 1); }; @@ -89,35 +97,97 @@ const Exam: NextPage<{ searchParams: { url: string; name: string } }> = ({ setCurrentQuestion(data?.randomQuestions[0]); }, [data]); - // Show loading while checking trial access - if (isAccessBlocked === undefined) { + // Show loading while checking trial access or waiting for URL + if (isAccessBlocked === undefined || !searchParams) { return ; } + // Check if URL is missing + if (!url) { + return ( +
+
+
+ ⚠️ Exam URL is missing. Please select an exam from the home page. +
+ +
+
+ ); + } + // Block access if trial expired if (isAccessBlocked) { return ( -
-
- ⏰ Trial expired. Please sign in to continue taking exams. +
+
+
+ ⏰ Trial expired. Please sign in to continue taking exams. +
+
-
); } if (loading) return ; - if (error) return

Oh no... {error.message}

; + if (error) { + return ( +
+
+
+ ⚠️ Error loading exam questions +
+

+ {error.message} +

+ +
+
+ ); + } + + if (!data?.randomQuestions || data.randomQuestions.length === 0) { + return ( +
+
+
+ ⚠️ No questions found for this exam +
+

+ The exam questions could not be loaded. Please try again later or + select a different exam. +

+ +
+
+ ); + } const numberOfQuestions = data.randomQuestions.length || 0; return ( -
+
{isInTrial && (
@@ -142,13 +212,13 @@ const Exam: NextPage<{ searchParams: { url: string; name: string } }> = ({ )}
-

+

✅ {countAnswered}/{numberOfQuestions}

-

+

PRACTICE EXAM

-

+

{remainingTime}

@@ -202,7 +272,7 @@ const Exam: NextPage<{ searchParams: { url: string; name: string } }> = ({ c0.53,0,0.97,0.43,0.97,0.97V29.35z" /> -

+

Practice Exam help you practice skills, assess your knowledge, and identify the areas where you need additional preparation to accelerate your chances of succeeding on certification @@ -211,7 +281,7 @@ const Exam: NextPage<{ searchParams: { url: string; name: string } }> = ({ are likely to experience on Azure Fundamentals real exam.

-

+

This Practice Exam contains {numberOfQuestions} random questions (seen in upper left corner) and has a completion time limit of{" "} {remainingTime.split(":")[0]} minutes (seen in upper right @@ -221,7 +291,7 @@ const Exam: NextPage<{ searchParams: { url: string; name: string } }> = ({

+
+
+
+ ); + } return ( -
-

+
+

{name}

-

+

Test your knowledge under pressure with our timed exam mode or explore and master all the questions at your own pace with our practice mode.

-
- - +
+
setHoveredCard("practice")} + onMouseLeave={() => setHoveredCard(null)} + > + +
+
setHoveredCard("exam")} + onMouseLeave={() => setHoveredCard(null)} + > + +
); diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..dde0b61 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,42 @@ +"use client"; + +import Link from "next/link"; + +export default function NotFound() { + return ( +
+ {/* Header */} +

+ Page Not Found +

+ + {/* Description */} +

+ Oops! The page you're looking for seems to have drifted off into + space. Don't worry, our{" "} + practice exams are still + here waiting for you! +

+ + {/* 404 Numbers */} +
+ + 4 + + + 0 + + + 4 + +
+ + {/* Action buttons */} +
+ + 📚 Browse Exams + +
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 89d6b7e..423aee6 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -8,6 +8,7 @@ import useDebounce from "@azure-fundamentals/hooks/useDebounce"; const Home: NextPage = () => { const [searchTerm, setSearchTerm] = useState(""); + const [hoveredCard, setHoveredCard] = useState(null); const debouncedSearchTerm = useDebounce(searchTerm, 500); const handleSearchChange = (event: React.ChangeEvent) => { @@ -19,9 +20,11 @@ const Home: NextPage = () => { ); return ( -
-

Welcome!

-

+

+

+ Welcome! +

+

Select an exam from the list below.

{ value={searchTerm} onChange={handleSearchChange} placeholder="Search exams" - className="mb-6 px-4 py-2 border border-gray-300 rounded-md w-3/4 lg:w-1/2" + className="mb-6 px-4 py-2 border border-gray-200 dark:border-gray-800 bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-md w-3/4 lg:w-1/2 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" />
0 ? "grid-cols-1 sm:grid-cols-2 gap-x-10 gap-y-5" : "flex justify-center items-center" @@ -40,20 +43,27 @@ const Home: NextPage = () => { > {filteredExams.length > 0 ? ( filteredExams.map((exam) => ( - + onMouseEnter={() => setHoveredCard(exam.name)} + onMouseLeave={() => setHoveredCard(null)} + > + +
)) ) : ( -

+

No exams were found for your query.

)} diff --git a/app/practice/page.tsx b/app/practice/page.tsx index a007633..e9c6550 100644 --- a/app/practice/page.tsx +++ b/app/practice/page.tsx @@ -41,10 +41,12 @@ const Practice: NextPage = () => { const seq = seqParam ? parseInt(seqParam) : 1; const [currentQuestionIndex, setCurrentQuestionIndex] = useState(seq); - const editedUrl = url.substring(0, url.lastIndexOf("/") + 1); + const editedUrl = + url && url.includes("/") ? url.substring(0, url.lastIndexOf("/") + 1) : ""; const { loading, error, data } = useQuery(questionQuery, { variables: { id: currentQuestionIndex - 1, link: url }, + skip: !url, // Skip query if URL is not available }); useEffect(() => { @@ -58,6 +60,7 @@ const Practice: NextPage = () => { error: questionsError, } = useQuery(questionsQuery, { variables: { link: url }, + skip: !url, // Skip query if URL is not available }); const setThisSeqIntoURL = useCallback((seq: number) => { @@ -72,39 +75,106 @@ const Practice: NextPage = () => { }, [seq]); const handleNextQuestion = (questionNo: number) => { - if (questionNo > 0 && questionNo - 1 < questionsData?.questions?.count) { + // Fix off-by-one error: subtract 1 from the count since it's 1-indexed but count is 0-indexed + const totalQuestions = Math.max( + 0, + (questionsData?.questions?.count || 0) - 1, + ); + + // Allow navigation to questions 1 through totalQuestions + if (questionNo > 0 && questionNo <= totalQuestions) { setCurrentQuestionIndex(questionNo); setThisSeqIntoURL(questionNo); } }; - // Show loading while checking trial access - if (isAccessBlocked === undefined) { + // Show loading while checking trial access or waiting for URL + if (isAccessBlocked === undefined || !searchParams) { return ; } + // Check if URL is missing + if (!url) { + return ( +
+
+
+ ⚠️ Practice URL is missing. Please select an exam from the home + page. +
+ +
+
+ ); + } + // Block access if trial expired if (isAccessBlocked) { return ( -
-
- ⏰ Trial expired. Please sign in to continue practicing. +
+
+
+ ⏰ Trial expired. Please sign in to continue practicing. +
+
-
); } - if (error) return

Oh no... {error.message}

; - if (questionsError) return

Oh no... {questionsError.message}

; + if (error) { + return ( +
+
+
+ ⚠️ Error loading question +
+

+ {error.message} +

+ +
+
+ ); + } + if (questionsError) { + return ( +
+
+
+ ⚠️ Error loading questions +
+

+ {questionsError.message} +

+ +
+
+ ); + } return ( -
+
{isInTrial && (
@@ -131,7 +201,7 @@ const Practice: NextPage = () => { isLoading={loading || questionsLoading} questionSet={data?.questionById} handleNextQuestion={handleNextQuestion} - totalQuestions={questionsData?.questions?.count} + totalQuestions={Math.max(0, (questionsData?.questions?.count || 0) - 1)} currentQuestionIndex={currentQuestionIndex} link={editedUrl} /> diff --git a/components/Button.tsx b/components/Button.tsx index 2f2a416..138f71d 100644 --- a/components/Button.tsx +++ b/components/Button.tsx @@ -4,13 +4,16 @@ import React from "react"; const button = cva("button", { variants: { intent: { - primary: ["btn-primary", "focus:ring-blue-800"], + primary: ["btn-primary", "focus:ring-primary-800"], secondary: [ - "bg-emerald-600/50", - "border-emerald-600", - "hover:bg-emerald-600/60", - "focus:ring-green-800", - "border-emerald-600", + "bg-transparent", + "border-primary-500", + "hover:bg-primary-500/10", + "hover:border-primary-600", + "hover:scale-105", + "hover:shadow-primary-500/20", + "focus:ring-primary-800", + "text-primary-500 dark:text-white", "sm:mr-2", ], }, @@ -51,7 +54,7 @@ export const Button: React.FC = ({ intent, size, variant, - })} text-white rounded-lg focus:outline-none focus:ring-1 border mb-2 sm:mb-0 ${ + })} rounded-lg focus:outline-none focus:ring-1 border mb-2 sm:mb-0 disabled:cursor-not-allowed disabled:opacity-50 ${ className || "" }`} {...props} diff --git a/components/Cookie.tsx b/components/Cookie.tsx index 01c8040..0e3a3d1 100644 --- a/components/Cookie.tsx +++ b/components/Cookie.tsx @@ -4,28 +4,38 @@ import "vanilla-cookieconsent/dist/cookieconsent.css"; import * as CookieConsent from "vanilla-cookieconsent"; import getConfig from "@azure-fundamentals/utils/CookieConfig"; import addCookieConsentListeners from "@azure-fundamentals/utils/CookieListeners"; +import { useTheme } from "../contexts/ThemeContext"; import Script from "next/script"; const Cookie: FC = () => { const GA_TRACKING_ID = process.env.NEXT_PUBLIC_GA_TRACKING_ID; + const { theme } = useTheme(); useEffect(() => { addCookieConsentListeners(); CookieConsent.run(getConfig()); }, []); + const handleShowPreferences = () => { + if (CookieConsent && typeof CookieConsent.showPreferences === "function") { + CookieConsent.showPreferences(); + } + }; + return ( <>

+

+ {subparagraph !== "" && ( +

+ {subparagraph} +

+ )} + + {/* Corner arrow */} +
+
); diff --git a/components/ExamResult.tsx b/components/ExamResult.tsx index 7731aaf..3753580 100644 --- a/components/ExamResult.tsx +++ b/components/ExamResult.tsx @@ -26,11 +26,11 @@ const ExamResult: React.FC = ({ )}
- + POINTS -
+

{points} @@ -39,7 +39,7 @@ const ExamResult: React.FC = ({

-

+

{status ? ( <>

Congratulations!

diff --git a/components/Footer.tsx b/components/Footer.tsx index f58fa06..eced06e 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -13,10 +13,13 @@ import { } from "react-icons/si"; import GitHubButton from "react-github-btn"; import packageJson from "../package.json"; +import ParticlesFooter from "./ParticlesFooter"; +import { useTheme } from "../contexts/ThemeContext"; import "styles/footer.css"; const Footer = () => { const currentYear = new Date().getFullYear(); const iconSize = 28; + const { theme } = useTheme(); const socialMediaLinks = [ { @@ -57,9 +60,15 @@ const Footer = () => { }, ]; + const gradientClass = + theme === "dark" + ? "bg-gradient-to-r from-gray-900 to-primary-500/70" + : "bg-gradient-to-r from-white to-primary-800/100"; + return ( -