From b40ddde2c281b563eafb527e0a4ce986c307062e Mon Sep 17 00:00:00 2001 From: swve Date: Tue, 8 Apr 2025 15:44:13 +0200 Subject: [PATCH] feat: enhance course page and indicators --- .../(withmenu)/course/[courseuuid]/course.tsx | 255 ++++----- .../Courses/CourseActions/CoursesActions.tsx | 513 ++++++++---------- .../Courses/CourseAuthors/CourseAuthors.tsx | 410 ++++++++++++++ .../Courses/CourseProgress/CourseProgress.tsx | 122 +++++ .../Objects/StyledElements/Form/Form.tsx | 18 +- .../Pages/Courses/ActivityIndicators.tsx | 10 +- 6 files changed, 895 insertions(+), 433 deletions(-) create mode 100644 apps/web/components/Objects/Courses/CourseAuthors/CourseAuthors.tsx create mode 100644 apps/web/components/Objects/Courses/CourseProgress/CourseProgress.tsx diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx index 716a5e1a..236415c3 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx @@ -9,16 +9,14 @@ import { useRouter } from 'next/navigation' import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper' import { getCourseThumbnailMediaDirectory, - getUserAvatarMediaDirectory, } from '@services/media/media' -import { ArrowRight, Backpack, Check, File, Sparkles, Video } from 'lucide-react' +import { ArrowRight, Backpack, Check, File, Sparkles, StickyNote, Video, Square } from 'lucide-react' import { useOrg } from '@components/Contexts/OrgContext' -import UserAvatar from '@components/Objects/UserAvatar' -import CourseUpdates from '@components/Objects/Courses/CourseUpdates/CourseUpdates' import { CourseProvider } from '@components/Contexts/CourseContext' import { useMediaQuery } from 'usehooks-ts' import CoursesActions from '@components/Objects/Courses/CourseActions/CoursesActions' import CourseActionsMobile from '@components/Objects/Courses/CourseActions/CourseActionsMobile' +import CourseAuthors from '@components/Objects/Courses/CourseAuthors/CourseAuthors' const CourseClient = (props: any) => { const [learnings, setLearnings] = useState([]) @@ -64,6 +62,54 @@ const CourseClient = (props: any) => { getLearningTags() }, [org, course]) + const getActivityTypeLabel = (activityType: string) => { + switch (activityType) { + case 'TYPE_VIDEO': + return 'Video' + case 'TYPE_DOCUMENT': + return 'Document' + case 'TYPE_DYNAMIC': + return 'Page' + case 'TYPE_ASSIGNMENT': + return 'Assignment' + default: + return 'Learning Material' + } + } + + const getActivityTypeBadgeColor = (activityType: string) => { + switch (activityType) { + case 'TYPE_VIDEO': + return 'bg-neutral-100 text-neutral-500' + case 'TYPE_DOCUMENT': + return 'bg-neutral-100 text-neutral-500' + case 'TYPE_DYNAMIC': + return 'bg-neutral-100 text-neutral-500' + case 'TYPE_ASSIGNMENT': + return 'bg-neutral-100 text-neutral-500' + default: + return 'bg-neutral-100 text-neutral-500' + } + } + + const isActivityDone = (activity: any) => { + const run = course?.trail?.runs?.find( + (run: any) => run.course_id == course.id + ) + if (run) { + return run.steps.find((step: any) => step.activity_id == activity.id) + } + return false + } + + const isActivityCurrent = (activity: any) => { + const activity_uuid = activity.activity_uuid.replace('activity_', '') + if (props.current_activity && props.current_activity == activity_uuid) { + return true + } + return false + } + return ( <> {!course && !org ? ( @@ -76,11 +122,6 @@ const CourseClient = (props: any) => {

Course

{course.name}

-
- {!isMobile && - - } -
{props.course?.thumbnail_image && org ? ( @@ -195,147 +236,59 @@ const CourseClient = (props: any) => { {chapter.activities.map((activity: any) => { return (
-

-
-
- {activity.activity_type === - 'TYPE_DYNAMIC' && ( -
- +
+
+
+ {isActivityDone(activity) ? ( +
+ + +
+ ) : ( +
+
)} - {activity.activity_type === 'TYPE_VIDEO' && ( -
-
- )} - {activity.activity_type === - 'TYPE_DOCUMENT' && ( -
- -
- )} - {activity.activity_type === - 'TYPE_ASSIGNMENT' && ( -
- -
- )} -
- -

{activity.name}

- -
- {activity.activity_type === - 'TYPE_DYNAMIC' && ( -
- -
-

Page

- -
- -
- )} - {activity.activity_type === 'TYPE_VIDEO' && ( -
- -
-

Video

- +
+ +
+

{activity.name}

+ {isActivityCurrent(activity) && ( +
+ Current
- + )}
- )} - {activity.activity_type === - 'TYPE_DOCUMENT' && ( -
- -
-

Document

- -
- -
- )} - {activity.activity_type === - 'TYPE_ASSIGNMENT' && ( -
- -
-

Assignment

- -
- -
- )} +
+ {activity.activity_type === 'TYPE_DYNAMIC' && ( + + )} + {activity.activity_type === 'TYPE_VIDEO' && ( +
+ +
+ +
@@ -348,8 +301,16 @@ const CourseClient = (props: any) => { })}
-
+
+ {/* Actions Box */} + + {/* Authors & Updates Box */} +
+ + + +
diff --git a/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx b/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx index 49453edb..f2f45b3c 100644 --- a/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx +++ b/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx @@ -1,42 +1,31 @@ import React, { useState, useEffect } from 'react' -import UserAvatar from '../../UserAvatar' -import { getUserAvatarMediaDirectory } from '@services/media/media' import { removeCourse, startCourse } from '@services/courses/activity' import { revalidateTags } from '@services/utils/ts/requests' import { useRouter } from 'next/navigation' import { useLHSession } from '@components/Contexts/LHSessionContext' -import { useMediaQuery } from 'usehooks-ts' import { getUriWithOrg, getUriWithoutOrg } from '@services/config/config' import { getProductsByCourse } from '@services/payments/products' -import { LogIn, LogOut, ShoppingCart, AlertCircle, UserPen, ClockIcon } from 'lucide-react' +import { LogIn, LogOut, ShoppingCart, AlertCircle, UserPen, ClockIcon, ArrowRight, Sparkles, BookOpen } from 'lucide-react' import Modal from '@components/Objects/StyledElements/Modal/Modal' import CoursePaidOptions from './CoursePaidOptions' import { checkPaidAccess } from '@services/payments/payments' import { applyForContributor } from '@services/courses/courses' import toast from 'react-hot-toast' import { useContributorStatus } from '../../../../hooks/useContributorStatus' - -interface Author { - user: { - id: string - user_uuid: string - avatar_image: string - first_name: string - last_name: string - username: string - } - authorship: 'CREATOR' | 'CONTRIBUTOR' | 'MAINTAINER' | 'REPORTER' - authorship_status: 'ACTIVE' | 'INACTIVE' | 'PENDING' -} +import CourseProgress from '../CourseProgress/CourseProgress' +import UserAvatar from '@components/Objects/UserAvatar' interface CourseRun { status: string course_id: string + steps: Array<{ + activity_id: string + complete: boolean + }> } interface Course { id: string - authors: Author[] trail?: { runs: CourseRun[] } @@ -59,137 +48,7 @@ interface CourseActionsProps { } } -// Separate component for author display -const AuthorInfo = ({ author, isMobile }: { author: Author, isMobile: boolean }) => ( -
- -
-
Author
-
- {(author.user.first_name && author.user.last_name) ? ( -
-

{`${author.user.first_name} ${author.user.last_name}`}

- - @{author.user.username} - -
- ) : ( -
-

@{author.user.username}

-
- )} -
-
-
-) - -const MultipleAuthors = ({ authors, isMobile }: { authors: Author[], isMobile: boolean }) => { - const displayedAvatars = authors.slice(0, 3) - const displayedNames = authors.slice(0, 2) - const remainingCount = Math.max(0, authors.length - 3) - - // Consistent sizes for both avatars and badge - const avatarSize = isMobile ? 72 : 86 - const borderSize = "border-4" - - return ( -
-
Authors
- - {/* Avatars row */} -
- {displayedAvatars.map((author, index) => ( -
-
- -
-
- ))} - {remainingCount > 0 && ( -
-
- +{remainingCount} -
-
- )} -
- - {/* Names row - improved display logic */} -
-
- {authors.length === 1 ? ( - - {authors[0].user.first_name && authors[0].user.last_name - ? `${authors[0].user.first_name} ${authors[0].user.last_name}` - : `@${authors[0].user.username}`} - - ) : ( - <> - {displayedNames.map((author, index) => ( - - {author.user.first_name && author.user.last_name - ? `${author.user.first_name} ${author.user.last_name}` - : `@${author.user.username}`} - {index === 0 && authors.length > 1 && index < displayedNames.length - 1 && " & "} - - ))} - {authors.length > 2 && ( - - & {authors.length - 2} more - - )} - - )} -
-
- {authors.length === 1 ? ( - @{authors[0].user.username} - ) : ( - <> - {displayedNames.map((author, index) => ( - - @{author.user.username} - {index === 0 && authors.length > 1 && index < displayedNames.length - 1 && " & "} - - ))} - - )} -
-
-
- ) -} - -const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => { +function CoursesActions({ courseuuid, orgslug, course }: CourseActionsProps) { const router = useRouter() const session = useLHSession() as any const [linkedProducts, setLinkedProducts] = useState([]) @@ -198,7 +57,8 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => { const [isContributeLoading, setIsContributeLoading] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false) const [hasAccess, setHasAccess] = useState(null) - const { contributorStatus, refetch } = useContributorStatus(courseuuid); + const { contributorStatus, refetch } = useContributorStatus(courseuuid) + const [isProgressOpen, setIsProgressOpen] = useState(false) const isStarted = course.trail?.runs?.some( (run) => run.status === 'STATUS_IN_PROGRESS' && run.course_id === course.id @@ -321,12 +181,33 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => { } } - if (isLoading) { - return
- } + const renderActionButton = (action: 'start' | 'leave') => { + if (!session.data?.user) { + return ( + <> + + {action === 'start' ? 'Start Course' : 'Leave Course'} + + + ); + } + + return ( + <> + + {action === 'start' ? 'Start Course' : 'Leave Course'} + + + ); + }; const renderContributorButton = () => { - // Don't render anything if the course is not open to contributors or if the user status is INACTIVE if (contributorStatus === 'INACTIVE' || course.open_to_contributors !== true) { return null; } @@ -379,139 +260,219 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => { ); }; + const renderProgressSection = () => { + const totalActivities = course.chapters?.reduce((acc: number, chapter: any) => acc + chapter.activities.length, 0) || 0; + const completedActivities = course.trail?.runs?.find( + (run: CourseRun) => run.course_id === course.id + )?.steps?.filter((step) => step.complete)?.length || 0; + + const progressPercentage = Math.round((completedActivities / totalActivities) * 100); + + if (!isStarted) { + return ( +
+
+
+
+
+
+
+ + + +
+ +
+
+
+
Ready to Begin?
+
+ Start your learning journey with {totalActivities} exciting {totalActivities === 1 ? 'activity' : 'activities'} +
+
+
+
+
+
+
+ ); + } + + return ( +
+
+
+
+
+
+
+ + + + +
+ + {progressPercentage}% + +
+
+ +
+
+
+
+
+ ); + }; + + if (isLoading) { + return
+ } + if (linkedProducts.length > 0) { return ( -
- {hasAccess ? ( - <> -
-
-
-

You Own This Course

+
+
+ {hasAccess ? ( + <> +
+
+
+

You Own This Course

+
+

+ You have purchased this course and have full access to all content. +

-

- You have purchased this course and have full access to all content. -

-
- - {renderContributorButton()} - - ) : ( -
-
- -

Paid Course

-
-

- This course requires purchase to access its content. -

-
- )} - - {!hasAccess && ( - <> - } - dialogTitle="Purchase Course" - dialogDescription="Select a payment option to access this course" - minWidth="sm" - /> - - {renderContributorButton()} - - )} + + {renderContributorButton()} + + ) : ( + <> +
+
+ +

Paid Course

+
+

+ This course requires purchase to access its content. +

+
+ } + dialogTitle="Purchase Course" + dialogDescription="Select a payment option to access this course" + minWidth="sm" + /> + + {renderContributorButton()} + + )} +
) } return ( -
- - {renderContributorButton()} -
- ) -} +
+
+ {/* Progress Section */} + {renderProgressSection()} -function CoursesActions({ courseuuid, orgslug, course }: CourseActionsProps) { - const router = useRouter() - const session = useLHSession() as any - const isMobile = useMediaQuery('(max-width: 768px)') + {/* Start/Leave Course Button */} + - // Filter active authors and sort by role priority - const sortedAuthors = [...course.authors] - .filter(author => author.authorship_status === 'ACTIVE') - .sort((a, b) => { - const rolePriority: Record = { - 'CREATOR': 0, - 'MAINTAINER': 1, - 'CONTRIBUTOR': 2, - 'REPORTER': 3 - }; - return rolePriority[a.authorship] - rolePriority[b.authorship]; - }); + {/* Contributor Button */} + {renderContributorButton()} - return ( -
- -
- + {/* Course Progress Modal */} + setIsProgressOpen(false)} + />
) diff --git a/apps/web/components/Objects/Courses/CourseAuthors/CourseAuthors.tsx b/apps/web/components/Objects/Courses/CourseAuthors/CourseAuthors.tsx new file mode 100644 index 00000000..1ed560df --- /dev/null +++ b/apps/web/components/Objects/Courses/CourseAuthors/CourseAuthors.tsx @@ -0,0 +1,410 @@ +import React, { useState } from 'react' +import UserAvatar from '../../UserAvatar' +import { getUserAvatarMediaDirectory } from '@services/media/media' +import { useMediaQuery } from 'usehooks-ts' +import { Rss, PencilLine, TentTree } from 'lucide-react' +import { useCourse } from '@components/Contexts/CourseContext' +import { useLHSession } from '@components/Contexts/LHSessionContext' +import useSWR, { mutate } from 'swr' +import { getAPIUrl } from '@services/config/config' +import { swrFetcher } from '@services/utils/ts/requests' +import useAdminStatus from '@components/Hooks/useAdminStatus' +import { useOrg } from '@components/Contexts/OrgContext' +import { createCourseUpdate, deleteCourseUpdate } from '@services/courses/updates' +import toast from 'react-hot-toast' +import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal' +import dayjs from 'dayjs' +import relativeTime from 'dayjs/plugin/relativeTime' +import * as Form from '@radix-ui/react-form' +import FormLayout, { + FormField, + FormLabelAndMessage, + Input, + Textarea, +} from '@components/Objects/StyledElements/Form/Form' +import { useFormik } from 'formik' +import { motion } from 'framer-motion' + +dayjs.extend(relativeTime) + +interface Author { + user: { + id: string + user_uuid: string + avatar_image: string + first_name: string + last_name: string + username: string + } + authorship: 'CREATOR' | 'CONTRIBUTOR' | 'MAINTAINER' | 'REPORTER' + authorship_status: 'ACTIVE' | 'INACTIVE' | 'PENDING' +} + +interface CourseAuthorsProps { + authors: Author[] +} + +const MultipleAuthors = ({ authors, isMobile }: { authors: Author[], isMobile: boolean }) => { + const displayedAvatars = authors.slice(0, 3) + const displayedNames = authors.slice(0, 2) + const remainingCount = Math.max(0, authors.length - 3) + + // Consistent sizes for both avatars and badge + const avatarSize = isMobile ? 72 : 86 + const borderSize = "border-4" + + return ( +
+
Authors & Updates
+ + {/* Avatars row */} +
+ {displayedAvatars.map((author, index) => ( +
+
+ +
+
+ ))} + {remainingCount > 0 && ( +
+
+ +{remainingCount} +
+
+ )} +
+ + {/* Names row - improved display logic */} +
+
+ {authors.length === 1 ? ( + + {authors[0].user.first_name && authors[0].user.last_name + ? `${authors[0].user.first_name} ${authors[0].user.last_name}` + : `@${authors[0].user.username}`} + + ) : ( + <> + {displayedNames.map((author, index) => ( + + {author.user.first_name && author.user.last_name + ? `${author.user.first_name} ${author.user.last_name}` + : `@${author.user.username}`} + {index === 0 && authors.length > 1 && index < displayedNames.length - 1 && " & "} + + ))} + {authors.length > 2 && ( + + & {authors.length - 2} more + + )} + + )} +
+
+ {authors.length === 1 ? ( + @{authors[0].user.username} + ) : ( + <> + {displayedNames.map((author, index) => ( + + @{author.user.username} + {index === 0 && authors.length > 1 && index < displayedNames.length - 1 && " & "} + + ))} + + )} +
+
+
+ ) +} + +const UpdatesSection = () => { + const [selectedView, setSelectedView] = React.useState('list') + const adminStatus = useAdminStatus() + const course = useCourse() as any + const session = useLHSession() as any + const access_token = session?.data?.tokens?.access_token + const { data: updates } = useSWR( + `${getAPIUrl()}courses/${course?.courseStructure.course_uuid}/updates`, + (url) => swrFetcher(url, access_token) + ) + + return ( +
+
+
+
+ + Course Updates +
+ {updates && updates.length > 0 && ( + + {updates.length} {updates.length === 1 ? 'update' : 'updates'} + + )} +
+ {adminStatus.isAdmin && ( + + )} +
+ + +
+ {selectedView === 'list' ? ( + + ) : ( + + )} +
+
+
+ ) +} + +const NewUpdateForm = ({ setSelectedView }: { setSelectedView: (view: string) => void }) => { + const org = useOrg() as any + const course = useCourse() as any + const session = useLHSession() as any + + const formik = useFormik({ + initialValues: { + title: '', + content: '' + }, + validate: (values) => { + const errors: any = {} + if (!values.title) errors.title = 'Title is required' + if (!values.content) errors.content = 'Content is required' + return errors + }, + onSubmit: async (values) => { + const body = { + title: values.title, + content: values.content, + course_uuid: course.courseStructure.course_uuid, + org_id: org.id + } + const res = await createCourseUpdate(body, session.data?.tokens?.access_token) + if (res.status === 200) { + toast.success('Update added successfully') + setSelectedView('list') + mutate(`${getAPIUrl()}courses/${course?.courseStructure.course_uuid}/updates`) + } else { + toast.error('Failed to add update') + } + } + }) + + return ( +
+ + + + + + + + + + +