From d3df80a8b2c07603b5b5c7ed4aaab2d3f27b6ec2 Mon Sep 17 00:00:00 2001 From: swve Date: Tue, 4 Mar 2025 23:02:55 +0100 Subject: [PATCH] feat: add mobile version CoursesActions, improve the user flow --- .../(withmenu)/course/[courseuuid]/course.tsx | 511 +++++++++--------- .../CourseActions/CourseActionsMobile.tsx | 232 ++++++++ .../Courses/CourseActions/CoursesActions.tsx | 35 +- 3 files changed, 523 insertions(+), 255 deletions(-) create mode 100644 apps/web/components/Objects/Courses/CourseActions/CourseActionsMobile.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 178e4e04..adec92ed 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/course.tsx @@ -18,6 +18,7 @@ import CourseUpdates from '@components/Objects/Courses/CourseUpdates/CourseUpdat 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' const CourseClient = (props: any) => { const [learnings, setLearnings] = useState([]) @@ -65,265 +66,273 @@ const CourseClient = (props: any) => { {!course && !org ? ( ) : ( - -
-
-

Course

-

{course.name}

-
-
- {!isMobile && - - } -
-
- - {props.course?.thumbnail_image && org ? ( -
- ) : ( -
- )} - - - -
-
-

About

-
-

{course.about}

+ <> + +
+
+

Course

+

{course.name}

+
+ {!isMobile && + + } +
+
- {learnings.length > 0 && learnings[0]?.text !== 'null' && ( -
-

- What you will learn -

-
- {learnings.map((learning: any) => { - // Handle both new format (object with text and emoji) and legacy format (string) - const learningText = typeof learning === 'string' ? learning : learning.text - const learningEmoji = typeof learning === 'string' ? null : learning.emoji - const learningId = typeof learning === 'string' ? learning : learning.id || learning.text - - if (!learningText) return null - - return ( -
-
- {learningEmoji ? ( - {learningEmoji} - ) : ( - + {props.course?.thumbnail_image && org ? ( +
+ ) : ( +
+ )} + + + +
+
+

About

+
+

{course.about}

+
+ + {learnings.length > 0 && learnings[0]?.text !== 'null' && ( +
+

+ What you will learn +

+
+ {learnings.map((learning: any) => { + // Handle both new format (object with text and emoji) and legacy format (string) + const learningText = typeof learning === 'string' ? learning : learning.text + const learningEmoji = typeof learning === 'string' ? null : learning.emoji + const learningId = typeof learning === 'string' ? learning : learning.id || learning.text + + if (!learningText) return null + + return ( +
+
+ {learningEmoji ? ( + {learningEmoji} + ) : ( + + )} +
+

{learningText}

+ {learning.link && ( + + Link to {learningText} + + )}
-

{learningText}

- {learning.link && ( - - Link to {learningText} - - - )} -
- ) - })} -
-
- )} - -

Course Lessons

-
- {course.chapters.map((chapter: any) => { - return ( -
-
-

{chapter.name}

-

- {chapter.activities.length} Activities -

-
-
- {chapter.activities.map((activity: any) => { - return ( - <> -

-
-
- {activity.activity_type === - 'TYPE_DYNAMIC' && ( -
- -
- )} - {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.activity_type === - 'TYPE_DOCUMENT' && ( - <> - -
-

Document

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

Assignment

- -
- - - )} -
-
- - ) - })} -
+ ) + })}
- ) - })} +
+ )} + +

Course Lessons

+
+ {course.chapters.map((chapter: any) => { + return ( +
+
+

{chapter.name}

+

+ {chapter.activities.length} Activities +

+
+
+ {chapter.activities.map((activity: any) => { + return ( + <> +

+
+
+ {activity.activity_type === + 'TYPE_DYNAMIC' && ( +
+ +
+ )} + {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.activity_type === + 'TYPE_DOCUMENT' && ( + <> + +
+

Document

+ +
+ + + )} + {activity.activity_type === + 'TYPE_ASSIGNMENT' && ( + <> + +
+

Assignment

+ +
+ + + )} +
+
+ + ) + })} +
+
+ ) + })} +
+
+
+
-
- + + + {isMobile && ( +
+
-
- + )} + )} ) diff --git a/apps/web/components/Objects/Courses/CourseActions/CourseActionsMobile.tsx b/apps/web/components/Objects/Courses/CourseActions/CourseActionsMobile.tsx new file mode 100644 index 00000000..cf589478 --- /dev/null +++ b/apps/web/components/Objects/Courses/CourseActions/CourseActionsMobile.tsx @@ -0,0 +1,232 @@ +import React, { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { useLHSession } from '@components/Contexts/LHSessionContext' +import { getUriWithoutOrg, getUriWithOrg } from '@services/config/config' +import { getProductsByCourse } from '@services/payments/products' +import { LogIn, LogOut, ShoppingCart } from 'lucide-react' +import Modal from '@components/Objects/StyledElements/Modal/Modal' +import CoursePaidOptions from './CoursePaidOptions' +import { checkPaidAccess } from '@services/payments/payments' +import { removeCourse, startCourse } from '@services/courses/activity' +import { revalidateTags } from '@services/utils/ts/requests' +import UserAvatar from '../../UserAvatar' +import { getUserAvatarMediaDirectory } from '@services/media/media' + +interface Author { + user_uuid: string + avatar_image: string + first_name: string + last_name: string + username: string +} + +interface CourseRun { + status: string + course_id: string +} + +interface Course { + id: string + authors: Author[] + trail?: { + runs: CourseRun[] + } + chapters?: Array<{ + name: string + activities: Array<{ + activity_uuid: string + name: string + activity_type: string + }> + }> +} + +interface CourseActionsMobileProps { + courseuuid: string + orgslug: string + course: Course & { + org_id: number + } +} + +const CourseActionsMobile = ({ courseuuid, orgslug, course }: CourseActionsMobileProps) => { + const router = useRouter() + const session = useLHSession() as any + const [linkedProducts, setLinkedProducts] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [isModalOpen, setIsModalOpen] = useState(false) + const [hasAccess, setHasAccess] = useState(null) + + const isStarted = course.trail?.runs?.some( + (run) => run.status === 'STATUS_IN_PROGRESS' && run.course_id === course.id + ) ?? false + + useEffect(() => { + const fetchLinkedProducts = async () => { + try { + const response = await getProductsByCourse( + course.org_id, + course.id, + session.data?.tokens?.access_token + ) + setLinkedProducts(response.data || []) + } catch (error) { + console.error('Failed to fetch linked products') + } finally { + setIsLoading(false) + } + } + + fetchLinkedProducts() + }, [course.id, course.org_id, session.data?.tokens?.access_token]) + + useEffect(() => { + const checkAccess = async () => { + if (!session.data?.user) return + try { + const response = await checkPaidAccess( + parseInt(course.id), + course.org_id, + session.data?.tokens?.access_token + ) + setHasAccess(response.has_access) + } catch (error) { + console.error('Failed to check course access') + setHasAccess(false) + } + } + + if (linkedProducts.length > 0) { + checkAccess() + } + }, [course.id, course.org_id, session.data?.tokens?.access_token, linkedProducts]) + + const handleCourseAction = async () => { + if (!session.data?.user) { + router.push(getUriWithoutOrg(`/signup?orgslug=${orgslug}`)) + return + } + + if (isStarted) { + await removeCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token) + await revalidateTags(['courses'], orgslug) + router.refresh() + } else { + await startCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token) + await revalidateTags(['courses'], orgslug) + + // Get the first activity from the first chapter + const firstChapter = course.chapters?.[0] + const firstActivity = firstChapter?.activities?.[0] + + if (firstActivity) { + // Redirect to the first activity + router.push( + getUriWithOrg(orgslug, '') + + `/course/${courseuuid}/activity/${firstActivity.activity_uuid.replace('activity_', '')}` + ) + } else { + router.refresh() + } + } + } + + if (isLoading) { + return
+ } + + const author = course.authors[0] + const authorName = author.first_name && author.last_name + ? `${author.first_name} ${author.last_name}` + : `@${author.username}` + + return ( +
+
+ +
+ Author + {authorName} +
+
+ +
+ {linkedProducts.length > 0 ? ( + hasAccess ? ( + + ) : ( + <> + } + dialogTitle="Purchase Course" + dialogDescription="Select a payment option to access this course" + minWidth="sm" + /> + + + ) + ) : ( + + )} +
+
+ ) +} + +export default CourseActionsMobile \ No newline at end of file diff --git a/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx b/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx index 2d69cdb8..03e69c88 100644 --- a/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx +++ b/apps/web/components/Objects/Courses/CourseActions/CoursesActions.tsx @@ -32,6 +32,14 @@ interface Course { trail?: { runs: CourseRun[] } + chapters?: Array<{ + name: string + activities: Array<{ + activity_uuid: string + name: string + activity_type: string + }> + }> } interface CourseActionsProps { @@ -129,10 +137,29 @@ const Actions = ({ courseuuid, orgslug, course }: CourseActionsProps) => { router.push(getUriWithoutOrg(`/signup?orgslug=${orgslug}`)) return } - const action = isStarted ? removeCourse : startCourse - await action('course_' + courseuuid, orgslug, session.data?.tokens?.access_token) - await revalidateTags(['courses'], orgslug) - router.refresh() + + if (isStarted) { + await removeCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token) + await revalidateTags(['courses'], orgslug) + router.refresh() + } else { + await startCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token) + await revalidateTags(['courses'], orgslug) + + // Get the first activity from the first chapter + const firstChapter = course.chapters?.[0] + const firstActivity = firstChapter?.activities?.[0] + + if (firstActivity) { + // Redirect to the first activity + router.push( + getUriWithOrg(orgslug, '') + + `/course/${courseuuid}/activity/${firstActivity.activity_uuid.replace('activity_', '')}` + ) + } else { + router.refresh() + } + } } if (isLoading) {