Skip to content

Commit bd94e4f

Browse files
feat: add exam cards
1 parent ac2af96 commit bd94e4f

File tree

4 files changed

+348
-35
lines changed

4 files changed

+348
-35
lines changed

src/components/exam-card.tsx

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import { Button } from "@freecodecamp/ui";
2+
import { getAttemptsByExamId, getExams } from "../utils/fetch";
3+
import { useNavigate } from "@tanstack/react-router";
4+
import { ExamLandingRoute } from "../pages/exam-landing";
5+
import { useQuery } from "@tanstack/react-query";
6+
import {
7+
Box,
8+
Card,
9+
CardBody,
10+
CardFooter,
11+
Heading,
12+
Text,
13+
Badge,
14+
Alert,
15+
AlertIcon,
16+
AlertDescription,
17+
Flex,
18+
Spinner,
19+
} from "@chakra-ui/react";
20+
import { WarningIcon } from "@chakra-ui/icons";
21+
22+
type Exams = Awaited<ReturnType<typeof getExams>>["data"];
23+
24+
interface ExamCardProps {
25+
exam: NonNullable<Exams>[number];
26+
}
27+
28+
interface ExamStatus {
29+
canTake: boolean;
30+
status:
31+
| "Available"
32+
| "InProgress"
33+
| "PendingModeration"
34+
| "RetakeLater"
35+
| "Expired";
36+
message?: string;
37+
alertStatus?: "success" | "info" | "warning" | "error";
38+
}
39+
40+
export function ExamCard({ exam }: ExamCardProps) {
41+
const navigate = useNavigate();
42+
43+
const attemptsQuery = useQuery({
44+
queryKey: ["exam-attempts", exam.id],
45+
queryFn: async () => {
46+
const { data, error } = await getAttemptsByExamId(exam.id);
47+
48+
if (error) {
49+
// @ts-expect-error TODO: fix error return type upstream
50+
throw new Error(error.message);
51+
}
52+
53+
return data;
54+
},
55+
});
56+
57+
function getExamStatus(): ExamStatus {
58+
if (attemptsQuery.isPending) {
59+
return { canTake: false, status: "Available" };
60+
}
61+
62+
if (!attemptsQuery.data || attemptsQuery.data.length === 0) {
63+
return { canTake: exam.canTake, status: "Available" };
64+
}
65+
66+
const latestAttempt = getLatestAttempt(attemptsQuery.data);
67+
68+
switch (latestAttempt.status) {
69+
case "InProgress":
70+
return {
71+
canTake: exam.canTake,
72+
status: latestAttempt.status,
73+
message: "You have an in-progress attempt for this exam!",
74+
alertStatus: "warning",
75+
};
76+
case "PendingModeration":
77+
return {
78+
canTake: exam.canTake,
79+
status: latestAttempt.status,
80+
message: "You have already completed this exam.",
81+
alertStatus: "info",
82+
};
83+
case "Expired":
84+
return {
85+
canTake: exam.canTake,
86+
status: latestAttempt.status,
87+
};
88+
default:
89+
const startTime = new Date(latestAttempt.startTime);
90+
const retakeAvailableAt = new Date(
91+
startTime.getTime() + exam.config.retakeTimeInS * 1000
92+
);
93+
const now = new Date();
94+
95+
if (now < retakeAvailableAt) {
96+
return {
97+
canTake: exam.canTake,
98+
status: "RetakeLater",
99+
message: `You can retake this exam on ${retakeAvailableAt.toLocaleString()}.`,
100+
alertStatus: "info",
101+
};
102+
}
103+
104+
return { canTake: exam.canTake, status: "Available" };
105+
}
106+
}
107+
108+
function getLatestAttempt(
109+
attempts: NonNullable<(typeof attemptsQuery)["data"]>
110+
) {
111+
return attempts.reduce((latest, current) => {
112+
return new Date(current.startTime) > new Date(latest.startTime)
113+
? current
114+
: latest;
115+
});
116+
}
117+
118+
const examStatus = getExamStatus();
119+
120+
return (
121+
<li style={{ listStyle: "none", marginBottom: "1rem" }}>
122+
<Card
123+
borderWidth={examStatus.status === "InProgress" ? "3px" : "1px"}
124+
borderColor={
125+
examStatus.status === "InProgress" ? "orange.400" : "gray.200"
126+
}
127+
boxShadow={examStatus.status === "InProgress" ? "lg" : "sm"}
128+
_hover={{ boxShadow: "md" }}
129+
transition="all 0.2s"
130+
>
131+
<CardBody>
132+
<Flex justifyContent="space-between" alignItems="flex-start" mb={2}>
133+
<Box flex={1}>
134+
<Heading size="md" mb={2}>
135+
{exam.config.name}
136+
</Heading>
137+
<Flex alignItems="center" gap={2}>
138+
<Text color="gray.600" fontSize="sm" marginBottom={0}>
139+
Duration:
140+
</Text>
141+
<Badge colorScheme="blue" fontSize="sm">
142+
{examTimeInHumanReadableFormat(exam.config.totalTimeInS)}
143+
</Badge>
144+
</Flex>
145+
</Box>
146+
{examStatus.status === "InProgress" && (
147+
<WarningIcon color="orange.400" boxSize={6} />
148+
)}
149+
</Flex>
150+
151+
{attemptsQuery.isPending ? (
152+
<Flex alignItems="center" gap={2} mt={3}>
153+
<Spinner size="sm" />
154+
<Text fontSize="sm" color="gray.500">
155+
Getting attempt status...
156+
</Text>
157+
</Flex>
158+
) : (
159+
attemptsQuery.isError && (
160+
<Alert status="error" mt={3} borderRadius="md">
161+
<AlertIcon />
162+
<AlertDescription fontSize="sm">
163+
{attemptsQuery.error.message}
164+
</AlertDescription>
165+
</Alert>
166+
)
167+
)}
168+
169+
{examStatus.message && (
170+
<Alert status={examStatus.alertStatus} mt={3} borderRadius="md">
171+
<AlertIcon />
172+
<AlertDescription fontSize="sm">
173+
{examStatus.message}
174+
</AlertDescription>
175+
</Alert>
176+
)}
177+
</CardBody>
178+
179+
<CardFooter pt={0}>
180+
<Button
181+
disabled={!exam.canTake || attemptsQuery.isPending}
182+
onClick={() => {
183+
navigate({
184+
to: ExamLandingRoute.to,
185+
params: { examId: exam.id },
186+
search: { note: exam.config.note },
187+
});
188+
}}
189+
style={{ width: "100%" }}
190+
>
191+
{examStatus.status === "InProgress"
192+
? "Continue Exam"
193+
: "Start Exam"}
194+
</Button>
195+
</CardFooter>
196+
</Card>
197+
</li>
198+
);
199+
}
200+
201+
// Converts seconds to Xh Ym format
202+
function examTimeInHumanReadableFormat(seconds: number) {
203+
const minutes = Math.floor(seconds / 60);
204+
const hours = Math.floor(minutes / 60);
205+
if (hours > 0) {
206+
return `${hours}h ${minutes % 60}m`;
207+
}
208+
209+
return `${minutes}m`;
210+
}

src/index.css

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,117 @@ body {
2828
--dark-yellow: #4d3800;
2929
--dark-blue: #002ead;
3030
--dark-green: #00471b;
31+
32+
--gray00: #ffffff;
33+
--gray05: #f5f6f7;
34+
--gray10: #dfdfe2;
35+
--gray15: #d0d0d5;
36+
--gray45: #858591;
37+
--gray75: #3b3b4f;
38+
--gray80: #2a2a40;
39+
--gray85: #1b1b32;
40+
--gray90: #0a0a23;
41+
--purple10: #dbb8ff;
42+
--purple50: #9400d3;
43+
--purple90: #5a01a7;
44+
--yellow05: #fcf8e3;
45+
--yellow10: #faebcc;
46+
--yellow40: #ffc300;
47+
--yellow45: #ffbf00;
48+
--yellow50: #f1be32;
49+
--yellow70: #8a6d3b;
50+
--yellow80: #66512c;
51+
--yellow90: #4d3800;
52+
--blue05: #d9edf7;
53+
--blue10: #bce8f1;
54+
--blue30: #99c9ff;
55+
--blue50: #198eee;
56+
--blue70: #31708f;
57+
--blue90: #002ead;
58+
--blue30-translucent: rgba(153, 201, 255, 0.3);
59+
--blue90-translucent: rgba(0, 46, 173, 0.3);
60+
--green05: #dff0d8;
61+
--green10: #d6e9c6;
62+
--green40: #acd157;
63+
--green70: #3c763d;
64+
--green80: #19562a;
65+
--green90: #00471b;
66+
--red05: #f2dede;
67+
--red10: #ebccd1;
68+
--red15: #ffadad;
69+
--red30: #f8577c;
70+
--red70: #a94442;
71+
--red80: #f82153;
72+
--red90: #850000;
73+
--red100: #5a3535;
74+
--orange30: #eda971;
75+
76+
--theme-color: #0a0a23;
77+
--yellow-gold: #ffbf00;
78+
--gray-00-translucent: rgba(255, 255, 255, 0.85);
79+
--gray-00: #ffffff;
80+
--gray-05: #f5f6f7;
81+
--gray-10: #dfdfe2;
82+
--gray-15: #d0d0d5;
83+
--gray-45: #858591;
84+
--gray-75: #3b3b4f;
85+
--gray-80: #2a2a40;
86+
--gray-85: #1b1b32;
87+
--gray-90: #0a0a23;
88+
--gray-90-translucent: rgba(10, 10, 35, 0.85);
89+
--purple-light: #dbb8ff;
90+
--purple-dark: #5a01a7;
91+
--yellow-light: #ffc300;
92+
--yellow-dark: #4d3800;
93+
--blue-light: rgb(153, 201, 255);
94+
--blue-light-translucent: rgba(153, 201, 255, 0.3);
95+
--blue-dark: rgb(0, 46, 173);
96+
--blue-dark-translucent: rgba(0, 46, 173, 0.3);
97+
--green-light: #acd157;
98+
--blue-mid: #198eee;
99+
--purple-mid: #9400d3;
100+
--green-dark: #00471b;
101+
--red-light: #ffadad;
102+
--red-dark: #850000;
103+
--love-light: #f8577c;
104+
--love-dark: #f82153;
105+
--editor-background-light: #fffffe;
106+
--editor-background-dark: #2a2b40;
107+
--focus-outline-color: var(--blue-mid);
108+
--font-family-sans-serif: "Lato", sans-serif;
109+
--font-family-monospace: "Hack-ZeroSlash", monospace;
110+
--header-element-size: 28px;
111+
--header-sub-element-size: 45px;
112+
--header-height: 38px;
113+
--breadcrumbs-height: 44px;
114+
--action-row-height: 64px;
115+
--z-index-breadcrumbs: 100;
116+
--z-index-flash: 150;
117+
--z-index-site-header: 200;
118+
119+
--primary-color-translucent: var(--gray-90-translucent);
120+
--primary-color: var(--gray-90);
121+
--secondary-color: var(--gray-85);
122+
--tertiary-color: var(--gray-80);
123+
--quaternary-color: var(--gray-75);
124+
--quaternary-background: var(--gray-15);
125+
--tertiary-background: var(--gray-10);
126+
--secondary-background: var(--gray-05);
127+
--primary-background: var(--gray-00);
128+
--primary-background-translucent: var(--gray-00-translucent);
129+
--highlight-color: var(--blue-dark);
130+
--highlight-background: var(--blue-light);
131+
--selection-color: var(--blue-dark-translucent);
132+
--success-color: var(--green-dark);
133+
--success-background: var(--green-light);
134+
--danger-color: var(--red-dark);
135+
--danger-background: var(--red-light);
136+
--yellow-background: var(--yellow-light);
137+
--yellow-color: var(--yellow-dark);
138+
--purple-background: var(--purple-light);
139+
--purple-color: var(--purple-dark);
140+
--love-color: var(--love-dark);
141+
--editor-background: var(--editor-background-light);
31142
}
32143

33144
.bottom-bubble-nav {

0 commit comments

Comments
 (0)