From ca12e799df51a1973657d0081f192cee7341d2c8 Mon Sep 17 00:00:00 2001 From: swve Date: Tue, 8 Apr 2025 14:22:48 +0200 Subject: [PATCH 1/3] feat: enhance activity completion handling and tooltip functionality --- .../activity/[activityid]/activity.tsx | 50 ++++++++---- .../activity/[activityid]/page.tsx | 2 +- .../StyledElements/Tooltip/Tooltip.tsx | 13 ++++ .../Pages/Courses/ActivityIndicators.tsx | 76 ++++++++++++++++++- 4 files changed, 121 insertions(+), 20 deletions(-) diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx index 1752862e..6ae554ff 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/activity.tsx @@ -260,14 +260,26 @@ export function MarkStatus(props: { const router = useRouter() const session = useLHSession() as any; const isMobile = useMediaQuery('(max-width: 768px)') + const [isLoading, setIsLoading] = React.useState(false); + async function markActivityAsCompleteFront() { - const trail = await markActivityAsComplete( - props.orgslug, - props.course.course_uuid, - props.activity.activity_uuid, - session.data?.tokens?.access_token - ) - router.refresh() + try { + setIsLoading(true); + const trail = await markActivityAsComplete( + props.orgslug, + props.course.course_uuid, + props.activity.activity_uuid, + session.data?.tokens?.access_token + ); + + // Mutate the course data to trigger re-render + await mutate(`${getAPIUrl()}courses/${props.course.course_uuid}/meta`); + router.refresh(); + } catch (error) { + toast.error('Failed to mark activity as complete'); + } finally { + setIsLoading(false); + } } const isActivityCompleted = () => { @@ -284,7 +296,7 @@ export function MarkStatus(props: { return ( <> {isActivityCompleted() ? ( -
+
{' '} @@ -292,14 +304,22 @@ export function MarkStatus(props: {
) : (
- {' '} - - - {' '} - {!isMobile && Mark as complete} + {isLoading ? ( +
+ + + + +
+ ) : ( + + + + )}{' '} + {!isMobile && {isLoading ? 'Marking...' : 'Mark as complete'}}
)} diff --git a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/page.tsx b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/page.tsx index d8341cd7..470251d0 100644 --- a/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/page.tsx +++ b/apps/web/app/orgs/[orgslug]/(withmenu)/course/[courseuuid]/activity/[activityid]/page.tsx @@ -21,7 +21,7 @@ type Session = { async function fetchCourseMetadata(courseuuid: string, access_token: string | null | undefined) { return await getCourseMetadata( courseuuid, - { revalidate: 1800, tags: ['courses'] }, + { revalidate: 0, tags: ['courses'] }, access_token || null ) } diff --git a/apps/web/components/Objects/StyledElements/Tooltip/Tooltip.tsx b/apps/web/components/Objects/StyledElements/Tooltip/Tooltip.tsx index 91495910..173f804f 100644 --- a/apps/web/components/Objects/StyledElements/Tooltip/Tooltip.tsx +++ b/apps/web/components/Objects/StyledElements/Tooltip/Tooltip.tsx @@ -9,6 +9,7 @@ type TooltipProps = { children: React.ReactNode side?: 'top' | 'right' | 'bottom' | 'left' // default is bottom slateBlack?: boolean + unstyled?: boolean // new prop to remove default styling } const ToolTip = (props: TooltipProps) => { @@ -19,6 +20,7 @@ const ToolTip = (props: TooltipProps) => { @@ -63,6 +65,17 @@ const TooltipContent = styled(Tooltip.Content, { color: 'white', }, }, + unstyled: { + true: { + padding: 0, + backgroundColor: 'transparent', + boxShadow: 'none', + borderRadius: 0, + fontSize: 'inherit', + lineHeight: 'inherit', + color: 'inherit', + }, + }, }, borderRadius: 4, diff --git a/apps/web/components/Pages/Courses/ActivityIndicators.tsx b/apps/web/components/Pages/Courses/ActivityIndicators.tsx index 230624c8..13482de8 100644 --- a/apps/web/components/Pages/Courses/ActivityIndicators.tsx +++ b/apps/web/components/Pages/Courses/ActivityIndicators.tsx @@ -2,6 +2,7 @@ import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip' import { getUriWithOrg } from '@services/config/config' import Link from 'next/link' import React from 'react' +import { Video, FileText, Layers, BookOpenCheck, Check } from 'lucide-react' interface Props { course: any @@ -16,7 +17,7 @@ function ActivityIndicators(props: Props) { const courseid = props.course_uuid.replace('course_', '') const done_activity_style = 'bg-teal-600 hover:bg-teal-700' - const black_activity_style = 'bg-black hover:bg-gray-700' + const black_activity_style = 'bg-zinc-300 hover:bg-zinc-400' const current_activity_style = 'bg-gray-600 animate-pulse hover:bg-gray-700' const trail = props.course.trail @@ -50,6 +51,51 @@ function ActivityIndicators(props: Props) { return black_activity_style } + const getActivityTypeIcon = (activityType: string) => { + switch (activityType) { + case 'TYPE_VIDEO': + return
-
- {!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 ( +
+ + + + + + + + + + +