Skip to content

Commit cf91afd

Browse files
authored
Merge pull request #44 from Eric-Zhang-Developer/feat/quiz-connect-database
Feat/quiz connect database
2 parents ca1c289 + f416f95 commit cf91afd

File tree

7 files changed

+177
-25
lines changed

7 files changed

+177
-25
lines changed

src/app/dashboard/__tests__/page.test.tsx

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,41 @@ import { render, screen } from "@testing-library/react";
33
import { describe, it, expect, beforeEach } from "vitest";
44
import DashboardPage from "../page";
55

6-
// Mock next/navigation to prevent redirect calls
6+
// Mock next/navigation
77
vi.mock("next/navigation", () => ({
88
redirect: vi.fn(),
99
}));
1010

1111
// Mock next/font/google
1212
vi.mock("next/font/google", () => ({
13-
Cinzel: () => ({
14-
className: "mocked-cinzel-font",
15-
}),
13+
Cinzel: () => ({ className: "mocked-cinzel-font" }),
1614
}));
1715

18-
// Mock Supabase server client to return a fake authenticated user
16+
// Mock for .from().select().eq()
17+
const mockFrom = vi.fn();
18+
const mockSelect = vi.fn();
19+
const mockEq = vi.fn();
20+
21+
mockFrom.mockReturnValue({
22+
select: mockSelect.mockReturnValue({
23+
eq: mockEq.mockResolvedValue({
24+
data: [], // Default: No completed quizzes
25+
error: null,
26+
}),
27+
}),
28+
});
29+
1930
vi.mock("../../../lib/supabase/server", () => ({
2031
createClient: vi.fn(async () => ({
2132
auth: {
2233
getUser: vi.fn(async () => ({
2334
data: {
24-
user: {
25-
26-
id: "test-user-id",
27-
},
35+
user: { email: "[email protected]", id: "test-user-id" },
2836
},
2937
})),
3038
signOut: vi.fn(),
3139
},
40+
from: mockFrom,
3241
})),
3342
}));
3443

@@ -66,4 +75,19 @@ describe("Dashboard Page Tests", () => {
6675
render(page);
6776
expect(screen.getByRole("link", { name: /Profile/i })).toBeInTheDocument();
6877
});
78+
79+
it("should show Green button for Hello World if completed", async () => {
80+
// Override the mock for THIS test to simulate completion
81+
mockEq.mockResolvedValue({
82+
data: [{ quiz_id: "hello-world" }],
83+
error: null,
84+
});
85+
86+
const page = await DashboardPage();
87+
render(page);
88+
89+
const link = screen.getByRole("link", { name: /Hello World/i });
90+
// Check if link is green
91+
expect(link.className).toContain("text-emerald-400");
92+
});
6993
});

src/app/dashboard/page.tsx

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { redirect } from "next/navigation";
22
import Link from "next/link";
33
import { createClient as createServerClient } from "../../lib/supabase/server";
4-
import { Cinzel } from 'next/font/google'; // Import Cinzel font
4+
import { Cinzel } from "next/font/google"; // Import Cinzel font
5+
import checkUserCompletedQuizzes from "@/lib/checkUserCompletedQuizzes";
56

67
//font for words
78
const cinzel = Cinzel({
@@ -26,34 +27,42 @@ export default async function DashboardPage() {
2627
redirect("/login");
2728
}
2829

29-
const celestialButtonClasses = "btn border-2 border-cyan-400 text-cyan-400 bg-transparent hover:bg-cyan-900/50 hover:border-cyan-200 hover:text-cyan-200 shadow-lg shadow-cyan-500/50 transition duration-300 ease-in-out w-full";
30-
const celestialButtonNoFullWidth = "btn border-2 border-cyan-400 text-cyan-400 bg-transparent hover:bg-cyan-900/50 hover:border-cyan-200 hover:text-cyan-200 shadow-lg shadow-cyan-500/50 transition duration-300 ease-in-out";
30+
// Check which quizzes has the user completed
31+
const completedQuizzes = await checkUserCompletedQuizzes();
3132

33+
const isHelloWorldComplete = completedQuizzes.has("hello-world");
34+
const isVariablesComplete = completedQuizzes.has("variables");
35+
36+
const celestialButtonClasses =
37+
"btn border-2 border-cyan-400 text-cyan-400 bg-transparent hover:bg-cyan-900/50 hover:border-cyan-200 hover:text-cyan-200 shadow-lg shadow-cyan-500/50 transition duration-300 ease-in-out w-full";
38+
const celestialButtonNoFullWidth =
39+
"btn border-2 border-cyan-400 text-cyan-400 bg-transparent hover:bg-cyan-900/50 hover:border-cyan-200 hover:text-cyan-200 shadow-lg shadow-cyan-500/50 transition duration-300 ease-in-out";
40+
const greyButtonClass =
41+
"btn border-2 border-gray-400 text-gray-400 bg-transparent hover:bg-gray-900/50 hover:border-gray-200 hover:text-gray-200 shadow-lg shadow-gray-500/50 transition duration-300 ease-in-out";
42+
const greenButtonClass =
43+
"btn border-2 border-emerald-400 text-emerald-400 bg-transparent hover:bg-emerald-900/50 hover:border-emerald-200 hover:text-emerald-200 shadow-lg shadow-emerald-500/50 transition duration-300 ease-in-out";
3244

3345
return (
3446
<main
3547
className={`relative min-h-dvh p-8 text-white ${cinzel.className}`}
3648
style={{
3749
backgroundImage: "url('/dashboard.png')",
38-
backgroundSize: 'cover',
39-
backgroundPosition: 'center',
50+
backgroundSize: "cover",
51+
backgroundPosition: "center",
4052
}}
4153
>
42-
<div className="flex justify-between items-start w-full">
43-
44-
<div className="flex flex-col gap-4 p-0 w-fit">
45-
<h1 className="text-white text-5xl font-bold tracking-wider mb-4">
46-
Dashboard
47-
</h1>
48-
<div className="flex flex-col gap-4 w-32">
54+
<div className="flex justify-between items-start w-full">
55+
<div className="flex flex-col gap-4 p-0 w-fit">
56+
<h1 className="text-white text-5xl font-bold tracking-wider mb-4">Dashboard</h1>
57+
<div className="flex flex-col gap-4 w-32">
4958
<Link href="/" className={celestialButtonClasses} aria-label="Go home">
5059
<span>Home</span>
5160
</Link>
5261
</div>
5362
</div>
5463

5564
{/* profile & logout */}
56-
<div className="flex items-center gap-4 p-0 w-fit">
65+
<div className="flex items-center gap-4 p-0 w-fit">
5766
<Link href="/profile" className={celestialButtonNoFullWidth} aria-label="Go to profile">
5867
<span>Profile</span>
5968
</Link>
@@ -66,11 +75,23 @@ export default async function DashboardPage() {
6675
</div>
6776

6877
{/* tutorials */}
69-
<div className="absolute bottom-16 left-1/2 -translate-x-1/2">
70-
<Link href="tutorial-hello-world" className={celestialButtonNoFullWidth}>
78+
<div className="absolute bottom-16 left-1/2 -translate-x-1/2">
79+
<div className="flex flex-col gap-16">
80+
<Link
81+
href="tutorial-variables"
82+
className={isVariablesComplete ? greenButtonClass : greyButtonClass}
83+
>
84+
Variables
85+
</Link>
86+
87+
<Link
88+
href="tutorial-hello-world"
89+
className={isHelloWorldComplete ? greenButtonClass : greyButtonClass}
90+
>
7191
Hello World
7292
</Link>
7393
</div>
94+
</div>
7495
</main>
7596
);
76-
}
97+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"use client";
2+
3+
import BackToDashBoardLink from "@/components/back-to-dashboard-link";
4+
import { Cinzel } from "next/font/google";
5+
6+
const cinzel = Cinzel({
7+
subsets: ["latin"],
8+
weight: ["400", "700"],
9+
});
10+
11+
// body text (Times New Roman= more readable)
12+
const bodyFontClass = "font-serif text-amber-950";
13+
// titles (Cinzel font)
14+
const cinzelTitleClass = cinzel.className;
15+
16+
export default function TutorialHelloWorld() {
17+
return (
18+
// Background of scroll
19+
<div
20+
className="min-h-screen p-4 md:p-12"
21+
style={{
22+
backgroundImage: "url('/geminiblurred.png')",
23+
backgroundSize: "cover",
24+
backgroundPosition: "center",
25+
backgroundColor: "#fef3c7",
26+
}}
27+
>
28+
<div className="inline-block p-4" style={{ zIndex: 10 }}>
29+
<BackToDashBoardLink />
30+
</div>
31+
32+
{/* "scroll"*/}
33+
<article
34+
className={`max-w-4xl mx-auto bg-amber-100 p-8 md:p-12 shadow-2xl shadow-amber-950/70 space-y-8
35+
${bodyFontClass} border border-amber-800 transform rotate-[-0.5deg]
36+
rounded-t-[4rem] rounded-b-lg`}
37+
>
38+
{/* title*/}
39+
<h1
40+
className={`text-4xl md:text-5xl font-bold ${cinzelTitleClass}
41+
border-b-4 border-amber-900 pb-4 mb-8 text-center uppercase`}
42+
>
43+
Quest: The Artisan&apos;s Toolkit - Mastering the Variable Vaults
44+
</h1>
45+
46+
<hr className="my-8 border-amber-900/50" />
47+
</article>
48+
</div>
49+
);
50+
}

src/components/tutorial-quiz.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
"use client";
2+
13
import { Cinzel } from "next/font/google";
24
import { useState } from "react";
35
import { QuizData } from "@/lib/types/types";
6+
import { createClient } from "@/lib/supabase/client";
47

58
const cinzel = Cinzel({ subsets: ["latin"], weight: ["700"] });
69

@@ -40,6 +43,7 @@ export default function Quiz({ quizData }: QuizProps) {
4043
setIsCorrect(null);
4144
} else {
4245
setShowResults(true);
46+
updateUserQuizProgress();
4347
}
4448
}, 2000); // 2 second delay so they can read the feedback
4549
};
@@ -52,6 +56,32 @@ export default function Quiz({ quizData }: QuizProps) {
5256
setIsCorrect(null);
5357
};
5458

59+
// Updates database that user completed quiz, no score to keep things simple
60+
// TODO: Update the insert with a upsert, and keep track of the user's most recent score on quiz
61+
// Also another neat feature would be loading the quiz result instead of resetting the quiz each time
62+
const updateUserQuizProgress = async () => {
63+
const supabase = createClient();
64+
65+
// Get user
66+
const {
67+
data: { user },
68+
} = await supabase.auth.getUser();
69+
70+
if (!user) {
71+
console.error("User not logged in, cannot save progress.");
72+
return;
73+
}
74+
75+
const { error: insertError } = await supabase.from("user_quiz_progress").insert({
76+
user_id: user.id,
77+
quiz_id: quizData.id,
78+
});
79+
80+
if (insertError) {
81+
console.error(`Supabase insertion error: ${insertError}`);
82+
}
83+
};
84+
5585
return (
5686
<div className="mt-12 p-8 border-2 border-amber-800/50 rounded-xl bg-amber-50/50 shadow-inner shadow-amber-900/20">
5787
<h2 className={`text-3xl font-bold text-center mb-6 text-amber-900 ${cinzel.className}`}>

src/data/quizzes/01-hello-world.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { QuizData } from "@/lib/types/types";
22

33
export const helloWorldQuiz: QuizData = {
44
title: "The Oracle's First Greeting",
5+
id: "hello-world",
56
questions: [
67
{
78
questionText: "What is the primary purpose of a 'Hello World' program?",
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { createClient } from "./supabase/server";
2+
3+
export default async function checkUserCompletedQuizzes() {
4+
const supabase = await createClient();
5+
const {
6+
data: { user },
7+
} = await supabase.auth.getUser();
8+
9+
if (!user) {
10+
console.error("User not logged in, cannot save progress.");
11+
return new Set();
12+
}
13+
14+
const { data: quizData, error: selectError } = await supabase
15+
.from("user_quiz_progress")
16+
.select("quiz_id")
17+
.eq("user_id", user.id);
18+
19+
if (selectError) {
20+
console.error(`Supabase selection error: ${selectError.message}`);
21+
return new Set();
22+
}
23+
24+
return new Set(quizData.map((row) => row.quiz_id));
25+
}

src/lib/types/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ export type Question = {
66

77
export type QuizData = {
88
title: string;
9+
id: string;
910
questions: Question[];
1011
};

0 commit comments

Comments
 (0)