diff --git a/package.json b/package.json index b9e13f2..14d41a8 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "lucide-react": "^0.306.0", "next": "14.0.4", "react": "^18", + "react-circular-progressbar": "^2.1.0", "react-dom": "^18", "tailwind-merge": "^2.2.0", "tailwindcss-animate": "^1.0.7", diff --git a/src/_temp_types/homeAttributes.ts b/src/_temp_types/homeAttributes.ts new file mode 100644 index 0000000..94fd01d --- /dev/null +++ b/src/_temp_types/homeAttributes.ts @@ -0,0 +1,8 @@ +export interface Attribute { + name: string; +} + +export interface InfoItem { + label: string; + value: string | number; +} diff --git a/src/_temp_types/user.ts b/src/_temp_types/user.ts index 00da8b1..715cc7b 100644 --- a/src/_temp_types/user.ts +++ b/src/_temp_types/user.ts @@ -5,7 +5,7 @@ export interface AuthUser { last_name: string; email: string; is_staff: boolean; - course_memberships: Array<{ course: number }>; + course_memberships: Array<{ id: number, name: string } >; } export interface AnonUser { diff --git a/src/app/(app)/course/[courseId]/home/(components)/AttributesUsed.tsx b/src/app/(app)/course/[courseId]/home/(components)/AttributesUsed.tsx new file mode 100644 index 0000000..8c7b3be --- /dev/null +++ b/src/app/(app)/course/[courseId]/home/(components)/AttributesUsed.tsx @@ -0,0 +1,40 @@ +import { Attribute } from "@/_temp_types/homeAttributes" + +const AttributesUsed = ({ attributes }: { attributes: Attribute[] }) => { + const displayedAttributes = attributes.slice(0, 15) + const hasMoreAttributes = attributes.length > 15 + + return ( +
+

Attributes Used

+
+
0 ? "grid-cols-1 sm:grid-cols-4" : ""}`}> + {attributes.length > 0 ? ( + <> + {displayedAttributes.map((attribute, index) => ( +
    +
  • {attribute.name}
  • +
+ ))} + {hasMoreAttributes && ( +
    +
  • ...
  • +
+ )} + + ) : ( +
    +
  • No attributes used
  • +
+ )} +
+
+
+ ) +} + +AttributesUsed.defaultProps = { + attributes: [], +} + +export default AttributesUsed diff --git a/src/app/(app)/course/[courseId]/home/(components)/InfoSection.tsx b/src/app/(app)/course/[courseId]/home/(components)/InfoSection.tsx new file mode 100644 index 0000000..84f3e4f --- /dev/null +++ b/src/app/(app)/course/[courseId]/home/(components)/InfoSection.tsx @@ -0,0 +1,36 @@ +import { InfoItem } from "@/_temp_types/homeAttributes" + +const InfoSection = ({ + title, + items = [], +}: { + title: string; + items?: InfoItem[]; +}) => ( +
+

{title}

+
+ {items.length > 0 ? ( + + ) : ( +

No data available

+ )} +
+
+) + +InfoSection.defaultProps = { + items: [], +} + +export default InfoSection diff --git a/src/app/(app)/course/[courseId]/home/(components)/OnboardingProgress.tsx b/src/app/(app)/course/[courseId]/home/(components)/OnboardingProgress.tsx new file mode 100644 index 0000000..95ec8c3 --- /dev/null +++ b/src/app/(app)/course/[courseId]/home/(components)/OnboardingProgress.tsx @@ -0,0 +1,60 @@ +import CircularProgressBar from "@/components/ui/circular-progress-bar" + +interface OnboardingProgressProps { + completionPercentage: number; + nextStepTitle: string; + courseId: string | number; +} + +const OnboardingProgress = ({ + completionPercentage, + nextStepTitle, + courseId, +}: OnboardingProgressProps) => { + return ( + <> +

+ Welcome back!{" "} + {completionPercentage === 100 ? ( + + Wanting to start a new team formation? + + ) : ( + + Your onboarding process is incomplete... + + )} +

+ + {completionPercentage !== 100 && ( +
+
+ +
+
+

+ You are {completionPercentage}% done with your current onboarding process. +

+

+ Next step: + + {nextStepTitle} + +

+
+
+ )} + + ) +} + +export default OnboardingProgress diff --git a/src/app/(app)/course/[courseId]/home/(hooks)/calculateOnboardingCompletion.tsx b/src/app/(app)/course/[courseId]/home/(hooks)/calculateOnboardingCompletion.tsx new file mode 100644 index 0000000..2ea0cfb --- /dev/null +++ b/src/app/(app)/course/[courseId]/home/(hooks)/calculateOnboardingCompletion.tsx @@ -0,0 +1,18 @@ +import { useSetupSteps } from "@/app/(app)/course/[courseId]/setup/(hooks)/useSetupSteps" + +export const CalculateOnboardingCompletion = () => { + const { steps, isLoading } = useSetupSteps() + const enabledSteps = steps.filter((step) => step.enabled) + + const completedSteps = enabledSteps.filter((step) => step.completed) + + const completionPercentage = Math.round((completedSteps.length / enabledSteps.length) * 100) + + const firstIncompleteStep = enabledSteps.find((step) => !step.completed) + + return { + completionPercentage, + nextStepTitle: firstIncompleteStep ? firstIncompleteStep.title : null, + isLoading, + } +} diff --git a/src/app/(app)/course/[courseId]/home/(hooks)/useAttributes.tsx b/src/app/(app)/course/[courseId]/home/(hooks)/useAttributes.tsx new file mode 100644 index 0000000..afb57c4 --- /dev/null +++ b/src/app/(app)/course/[courseId]/home/(hooks)/useAttributes.tsx @@ -0,0 +1,46 @@ +"use client" + +import { useCourse } from "@/app/(app)/course/[courseId]/(hooks)/useCourse" +import { useQuery } from "@tanstack/react-query" + +interface PastAttributesResponse { + team_set_name: string; + formation_date: string; + total_attributes_used: number; + attributes: { name: string }[]; +} + +const usePastAttributesQuery = ({ courseId }: { courseId: number }) => { + const attributeQuery = useQuery({ + queryKey: [`courses/${courseId}/previous-attributes`], + }) + + return { + getPastAttributes: attributeQuery.refetch, + ...attributeQuery, + } +} + +export const usePastAttributes = () => { + const { courseId } = useCourse() + const { data, isLoading, error, getPastAttributes } = usePastAttributesQuery({ + courseId: Number(courseId), + }) + + const formattedData = data + ? { + ...data, + formation_date: new Date(data.formation_date) + .toISOString() + .split("T")[0] + .replace(/-/g, "/"), + } + : undefined + + return { + data: formattedData, + isLoading, + error, + refetch: getPastAttributes, + } +} diff --git a/src/app/(app)/course/[courseId]/home/(hooks)/useHandleErrors.ts b/src/app/(app)/course/[courseId]/home/(hooks)/useHandleErrors.ts new file mode 100644 index 0000000..56fd2df --- /dev/null +++ b/src/app/(app)/course/[courseId]/home/(hooks)/useHandleErrors.ts @@ -0,0 +1,26 @@ +import { useToast } from "@/hooks/use-toast" +import { useEffect } from "react" + +interface ErrorState { + totalStudentsError?: any; + pastAttributesError?: any; +} + +export const useHandleErrors = ({ totalStudentsError, pastAttributesError }: ErrorState) => { + const { toast } = useToast() + + useEffect(() => { + if (totalStudentsError) { + toast({ + title: "Error fetching students", + description: "There was an error fetching the number of students enrolled on your LMS.", + }) + } + if (pastAttributesError) { + toast({ + title: "Error fetching attributes", + description: "There was an error fetching the attributes used in previous team formation.", + }) + } + }, [totalStudentsError, pastAttributesError, toast]) +} diff --git a/src/app/(app)/course/[courseId]/home/(hooks)/useTotalStudents.tsx b/src/app/(app)/course/[courseId]/home/(hooks)/useTotalStudents.tsx new file mode 100644 index 0000000..3968b75 --- /dev/null +++ b/src/app/(app)/course/[courseId]/home/(hooks)/useTotalStudents.tsx @@ -0,0 +1,34 @@ +"use client" + +import { useCourse } from "@/app/(app)/course/[courseId]/(hooks)/useCourse" +import { useQuery } from "@tanstack/react-query" + +const useTotalStudentsQuery = ({ courseId }: { courseId: number }) => { + const studentQuery = useQuery< + unknown, + unknown, + { total_students: number; opted_in_students: number } + >({ + queryKey: [`courses/${courseId}/student-counts`], + }) + + return { + getTotalStudentsAsync: studentQuery.refetch, + ...studentQuery, + } +} + +export const useTotalStudents = () => { + const { courseId } = useCourse() + const { data, isLoading, error, getTotalStudentsAsync } = useTotalStudentsQuery({ + courseId: Number(courseId), + }) + + return { + totalStudents: data?.total_students ?? 0, + optedInStudents: data?.opted_in_students ?? 0, + isLoading, + error, + refetch: getTotalStudentsAsync, + } +} diff --git a/src/app/(app)/course/[courseId]/home/page.tsx b/src/app/(app)/course/[courseId]/home/page.tsx new file mode 100644 index 0000000..6cc629a --- /dev/null +++ b/src/app/(app)/course/[courseId]/home/page.tsx @@ -0,0 +1,56 @@ +"use client" + +import { formatDate } from "@/../utils/format-date" +import { useCourse } from "@/app/(app)/course/[courseId]/(hooks)/useCourse" +import PageView from "@/components/views/Page" +import { ReloadIcon } from "@radix-ui/react-icons" +import AttributesUsed from "./(components)/AttributesUsed" +import InfoSection from "./(components)/InfoSection" +import OnboardingProgress from "./(components)/OnboardingProgress" +import { CalculateOnboardingCompletion } from "./(hooks)/calculateOnboardingCompletion" +import { usePastAttributes } from "./(hooks)/useAttributes" +import { useHandleErrors } from "./(hooks)/useHandleErrors" +import { useTotalStudents } from "./(hooks)/useTotalStudents" + +const HomePage = () => { + const { courseId } = useCourse() + const { completionPercentage, nextStepTitle, isLoading: isLoadingOnboarding } = CalculateOnboardingCompletion() + const { totalStudents, optedInStudents, isLoading: isLoadingStudents, error: totalStudentsError } = useTotalStudents() + const { data: pastAttributes, isLoading: isLoadingAttributes, error: pastAttributesError } = usePastAttributes() + useHandleErrors({ totalStudentsError, pastAttributesError }) + + const isLoading = isLoadingOnboarding || isLoadingStudents || isLoadingAttributes + + const signUpStats = [ + { label: "Students Enrolled on Your LMS", value: totalStudents }, + { label: "Total Team Formation Acceptions", value: optedInStudents }, + ] + + const previousTeamFormation = [ + { label: "Number of Adopted Attributes", value: pastAttributes?.total_attributes_used || 0 }, + { label: "Team Formation Date", value: pastAttributes ? formatDate(pastAttributes.formation_date) : "N/A" }, + ] + + return ( + + {isLoading ? ( +
+ +
+ ) : ( + <> + + + + + + )} +
+ ) +} + +export default HomePage diff --git a/src/app/(app)/course/[courseId]/page.tsx b/src/app/(app)/course/[courseId]/page.tsx index becb379..d3ecb63 100644 --- a/src/app/(app)/course/[courseId]/page.tsx +++ b/src/app/(app)/course/[courseId]/page.tsx @@ -1,7 +1,7 @@ import { redirect } from "next/navigation" const CourseHomepage = async ({ params }: { params: { courseId: string } }) => { - redirect(`/course/${params.courseId}/setup`) + redirect(`/course/${params.courseId}/home`) } export default CourseHomepage diff --git a/src/app/(app)/course/[courseId]/setup/page.tsx b/src/app/(app)/course/[courseId]/setup/page.tsx index 30b3dd2..2d51fba 100644 --- a/src/app/(app)/course/[courseId]/setup/page.tsx +++ b/src/app/(app)/course/[courseId]/setup/page.tsx @@ -1,10 +1,10 @@ "use client" -import React from "react" -import PageView from "@/components/views/Page" +import { useCourse } from "@/app/(app)/course/[courseId]/(hooks)/useCourse" import { SetupStepDetailCard } from "@/app/(app)/course/[courseId]/setup/(components)/SetupStepDetailCard" import { useSetupSteps } from "@/app/(app)/course/[courseId]/setup/(hooks)/useSetupSteps" -import { useCourse } from "@/app/(app)/course/[courseId]/(hooks)/useCourse" +import PageView from "@/components/views/Page" + const SetupPage = () => { const { steps, addedComponents } = useSetupSteps() @@ -13,7 +13,7 @@ const SetupPage = () => { diff --git a/src/app/(app)/course/[courseId]/students/page.tsx b/src/app/(app)/course/[courseId]/students/page.tsx index c0dd196..ef2f091 100644 --- a/src/app/(app)/course/[courseId]/students/page.tsx +++ b/src/app/(app)/course/[courseId]/students/page.tsx @@ -1,5 +1,6 @@ "use client" +import { useCourse } from "@/app/(app)/course/[courseId]/(hooks)/useCourse" import PageView from "@/components/views/Page" import { useImportStudentGradebookData } from "@/hooks/use-import-student-gradebook-data" import { useImportStudentsFromLms } from "@/hooks/use-import-students-from-lms" @@ -15,6 +16,7 @@ export default function StudentsPage() { } const StudentsPageView = () => { + const { courseId } = useCourse() const { refetch } = useStudents() const { @@ -32,7 +34,7 @@ const StudentsPageView = () => { { + if (authUser?.course_memberships) { + setLoading(false) + } + }, [authUser]) + + return ( + <> + +
+ + + + | +

Teamable

+
+
+ + + {authUser ? ( + + + {authUser.username[0].toUpperCase()} + + + ) : ( + + + + )} + + + Logout + + +
+
+ +
+
+
+

Your Courses

+ {loading ? ( +
+ +
+ ) : ( +
+ {authUser?.course_memberships.map((membership) => ( + router.push(`/course/${membership.id}/home`)} + > + + + {membership.name} + + + + ))} +
+ )} +
+
+ +
+

Your Courses

+ {loading ? ( +
+ +
+ ) : ( +
+ {authUser?.course_memberships.map((membership) => ( + router.push(`/course/${membership.id}/home`)} + > + + + {membership.name} + + + + ))} +
+ )} +
+
+ + ) +} diff --git a/src/app/page.tsx b/src/app/page.tsx index f463bc5..dff0144 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,6 @@ -import { redirect } from "next/navigation" -import { ROUTES } from "@/routes" import { authUserQueryFn } from "@/hooks/use-auth-user-query" +import { ROUTES } from "@/routes" +import { redirect } from "next/navigation" import { getTokenAuthHeaderServer } from "../../utils/auth-server" export default async function Home() { @@ -10,5 +10,5 @@ export default async function Home() { if (!user) redirect(ROUTES.SIGN_UP) if (!user.course_memberships.length) redirect(ROUTES.AUTH_ERROR) - redirect(`/course/${user.course_memberships[0].course}/setup`) + redirect(`/courses`) } diff --git a/src/components/ui/circular-progress-bar.tsx b/src/components/ui/circular-progress-bar.tsx new file mode 100644 index 0000000..e363047 --- /dev/null +++ b/src/components/ui/circular-progress-bar.tsx @@ -0,0 +1,27 @@ +"use client" + +import React from "react" +import { CircularProgressbar, buildStyles } from "react-circular-progressbar" +import "react-circular-progressbar/dist/styles.css" + +const progressBarStyles = buildStyles({ + rotation: -0.5, + strokeLinecap: "round", + textSize: "24px", + pathColor: "#000", + textColor: "#000", + trailColor: "#e6e6e6", +}) + +interface CircularProgressProps { + value: number; + text: string; +} + +const CircularProgressBar: React.FC = ({ value, text }) => { + return ( + + ) +} + +export default CircularProgressBar diff --git a/src/hooks/use-auth-user-query.ts b/src/hooks/use-auth-user-query.ts index 7eee83d..ea987a7 100644 --- a/src/hooks/use-auth-user-query.ts +++ b/src/hooks/use-auth-user-query.ts @@ -1,5 +1,5 @@ -import { useQuery } from "@tanstack/react-query" import { AuthUser } from "@/_temp_types/user" +import { useQuery } from "@tanstack/react-query" import { getTokenAuthHeader } from "../../utils/auth" export const useAuthUserQuery = () => { @@ -25,6 +25,7 @@ export const authUserQueryFn = async ({ }, }, ) + if (res.status === 401) return null const data = await res.json() if (!res.ok) throw data diff --git a/utils/format-date.ts b/utils/format-date.ts new file mode 100644 index 0000000..c716e74 --- /dev/null +++ b/utils/format-date.ts @@ -0,0 +1,2 @@ +export const formatDate = (dateString: string) => + dateString.split("T")[0].replace(/-/g, "/") diff --git a/yarn.lock b/yarn.lock index d42542f..bd4890e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6381,6 +6381,11 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +react-circular-progressbar@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/react-circular-progressbar/-/react-circular-progressbar-2.1.0.tgz#99e5ae499c21de82223b498289e96f66adb8fa3a" + integrity sha512-xp4THTrod4aLpGy68FX/k1Q3nzrfHUjUe5v6FsdwXBl3YVMwgeXYQKDrku7n/D6qsJA9CuunarAboC2xCiKs1g== + react-dom@^18: version "18.2.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d"