diff --git a/apps/api/src/routes/organization/organization.ts b/apps/api/src/routes/organization/organization.ts index e059db2b..7aac334d 100644 --- a/apps/api/src/routes/organization/organization.ts +++ b/apps/api/src/routes/organization/organization.ts @@ -442,15 +442,19 @@ export const organizationRouter = new Hono() async (c) => { try { const user = c.get('user')!; - const { limit } = c.req.valid('query'); + const { limit, page = 1 } = c.req.valid('query'); const orgId = c.req.header('cio-org-id')!; - const result = await getRecommendedCourses(orgId, user.id, limit); + const { data, total } = await getRecommendedCourses(orgId, user.id, limit, page); + + const resolvedLimit = limit ?? total; + const totalPages = resolvedLimit > 0 ? Math.ceil(total / resolvedLimit) : 1; return c.json( { success: true, - data: result + data, + pagination: { page, limit: resolvedLimit, total, totalPages } }, 200 ); diff --git a/apps/api/src/services/course/invite.ts b/apps/api/src/services/course/invite.ts index 551e7cf2..400ed6e3 100644 --- a/apps/api/src/services/course/invite.ts +++ b/apps/api/src/services/course/invite.ts @@ -649,7 +649,7 @@ export async function enrollInCourse( return { success: true, alreadyJoined: true, - redirectTo: '/lms' + redirectTo: `/courses/${courseId}/lessons?next=true` }; } @@ -692,7 +692,7 @@ export async function enrollInCourse( return { success: true, alreadyJoined: false, - redirectTo: '/lms' + redirectTo: `/courses/${courseId}/lessons?next=true` }; } @@ -1056,6 +1056,6 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex return { success: true, alreadyJoined: result.alreadyJoined, - redirectTo: '/lms' + redirectTo: `/courses/${result.courseId}/lessons?next=true` }; } diff --git a/apps/api/src/services/organization.ts b/apps/api/src/services/organization.ts index bebc0651..c64bf5f9 100644 --- a/apps/api/src/services/organization.ts +++ b/apps/api/src/services/organization.ts @@ -469,9 +469,9 @@ export async function getUserEnrolledCourses(orgId: string, userId: string) { * @param userId - User ID to exclude enrolled courses * @returns Array of recommended courses */ -export async function getRecommendedCourses(orgId: string, userId: string, limit?: number) { +export async function getRecommendedCourses(orgId: string, userId: string, limit?: number, page?: number) { try { - return getExploreCourses({ orgId, profileId: userId, limit }); + return getExploreCourses({ orgId, profileId: userId, limit, page }); } catch (error) { if (error instanceof AppError) throw error; throw new AppError( diff --git a/apps/dashboard/src/lib/features/course/api/courses.svelte.ts b/apps/dashboard/src/lib/features/course/api/courses.svelte.ts index e3f21899..ce2ab84d 100644 --- a/apps/dashboard/src/lib/features/course/api/courses.svelte.ts +++ b/apps/dashboard/src/lib/features/course/api/courses.svelte.ts @@ -21,6 +21,9 @@ export class CoursesApi extends BaseApiWithErrors { orgCoursesPagination = $state(null); enrolledCourses = $state([]); recommendedCourses = $state([]); + recommendedCoursesPagination = $state<{ page: number; limit: number; total: number; totalPages: number } | null>( + null + ); private activeOrgCoursesRequestController: AbortController | null = null; cancelOrgCoursesRequest() { @@ -135,17 +138,24 @@ export class CoursesApi extends BaseApiWithErrors { * Fetches recommended courses (published courses user isn't enrolled in) for the current organization * Org ID is automatically added from currentOrg store */ - async getRecommendedCourses(options?: { limit?: number }) { + async getRecommendedCourses(options?: { limit?: number; page?: number }) { + const query: Record = {}; + if (options?.limit) query.limit = String(options.limit); + if (options?.page) query.page = String(options.page); + await this.execute({ requestFn: () => classroomio.organization.courses.recommended.$get({ - query: options?.limit ? { limit: String(options.limit) } : {} + query }), logContext: 'fetching recommended courses', onSuccess: (response) => { if (response.data) { this.recommendedCourses = response.data; } + if ('pagination' in response && response.pagination) { + this.recommendedCoursesPagination = response.pagination as typeof this.recommendedCoursesPagination; + } this.errors = {}; }, onError: (result) => { diff --git a/apps/dashboard/src/lib/features/course/components/course-list-row.svelte b/apps/dashboard/src/lib/features/course/components/course-list-row.svelte index e40a68c1..f8ae8a1e 100644 --- a/apps/dashboard/src/lib/features/course/components/course-list-row.svelte +++ b/apps/dashboard/src/lib/features/course/components/course-list-row.svelte @@ -26,6 +26,18 @@ color?: string | null; } + type ColumnKey = 'published' | 'tags' | 'students' | 'actions'; + + const COLUMN_TRACKS: [string, string][] = [ + ['banner', '7rem'], + ['title', 'minmax(0, 2fr)'], + ['published', '5.5rem'], + ['tags', 'minmax(0, 2fr)'], + ['content', '4.5rem'], + ['students', '6rem'], + ['actions', '7rem'] + ]; + interface Props { id: string; slug?: string; @@ -41,6 +53,8 @@ tags?: Tag[]; isExplore?: boolean; isLMS?: boolean; + hiddenColumns?: ColumnKey[]; + onExploreClick?: () => void; } let { @@ -57,7 +71,9 @@ updatedAt, tags = [], isExplore = false, - isLMS = false + isLMS = false, + hiddenColumns = [], + onExploreClick }: Props = $props(); const titleInitial = $derived(title.trim().charAt(0).toUpperCase() || 'C'); @@ -66,12 +82,20 @@ const showPublicCourseLinks = $derived(isPublished && type === 'PUBLIC' && slug.trim().length > 0); - const showActionsColumn = $derived(!isLMS || (isLMS && showPublicCourseLinks)); + const hidden = $derived(new Set(hiddenColumns)); + + const showActionsColumn = $derived( + !hidden.has('actions') && (!isLMS || (isLMS && showPublicCourseLinks) || (isLMS && isExplore)) + ); const gridTemplateColumns = $derived( - showActionsColumn - ? '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem 2.5rem' - : '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem' + COLUMN_TRACKS.filter(([key]) => { + if (hidden.has(key)) return false; + if (key === 'actions') return showActionsColumn; + return true; + }) + .map(([, track]) => track) + .join(' ') ); const studentPlaceholderAvatarUrls = $derived.by(() => [ @@ -91,6 +115,10 @@ }); function handleRowClick() { + if (isExplore && onExploreClick) { + onExploreClick(); + return; + } if (isExplore) { goto(resolve(`/course/${slug}`, {})); return; @@ -161,35 +189,39 @@ -
- {#if isPublished} - - {$t('courses.course_card.published')} - - {:else} - - {$t('courses.course_card.unpublished')} - - {/if} -
+ {#if !hidden.has('published')} +
+ {#if isPublished} + + {$t('courses.course_card.published')} + + {:else} + + {$t('courses.course_card.unpublished')} + + {/if} +
+ {/if} -
- {#if tags.length === 0} - — - {:else} - {#each tags as tag (tag.id)} - - - {tag.name} - - {/each} - {/if} -
+ {#if !hidden.has('tags')} +
+ {#if tags.length === 0} + — + {:else} + {#each tags as tag (tag.id)} + + + {tag.name} + + {/each} + {/if} +
+ {/if}
@@ -204,97 +236,112 @@
-
- {#if totalStudents > 0} - {#if totalStudents < 3} -
- - - - -

{totalStudents}

-
- {:else} -
- {#each studentPlaceholderAvatarUrls as avatarUrl, slotIndex (slotIndex)} + {#if !hidden.has('students')} +
+ {#if totalStudents > 0} + {#if totalStudents < 3} +
- + - {/each} - - +{totalStudents - 2} - -
+

{totalStudents}

+
+ {:else} +
+ {#each studentPlaceholderAvatarUrls as avatarUrl, slotIndex (slotIndex)} + + + + + {/each} + + +{totalStudents - 2} + +
+ {/if} + {:else} + + + + +

0

{/if} - {:else} - - - - -

0

- {/if} -
+
+ {/if} {#if showActionsColumn}
- - - {#snippet child({ props })} - - {/snippet} - - - {#if showPublicCourseLinks} - - openCoursePreview({ - courseId: id, - courseSlug: slug, - currentOrgDomain: $currentOrgDomain - })} - > - - {$t('courses.course_card.context_menu.open_public_course')} - - void handleCopyCourseUrl()}> - - {$t('courses.course_card.context_menu.copy_course_url')} - - {/if} - {#if !isLMS} + {#if isLMS && isExplore} + + {:else} + + + {#snippet child({ props })} + + {/snippet} + + {#if showPublicCourseLinks} - + + openCoursePreview({ + courseId: id, + courseSlug: slug, + currentOrgDomain: $currentOrgDomain + })} + > + + {$t('courses.course_card.context_menu.open_public_course')} + + void handleCopyCourseUrl()}> + + {$t('courses.course_card.context_menu.copy_course_url')} + {/if} - - {$t('courses.course_card.context_menu.open')} - - - {$t('courses.course_card.context_menu.clone')} - - - - {$t('courses.course_card.context_menu.delete')} - - {/if} - - + {#if !isLMS} + {#if showPublicCourseLinks} + + {/if} + + {$t('courses.course_card.context_menu.open')} + + + {$t('courses.course_card.context_menu.clone')} + + + + {$t('courses.course_card.context_menu.delete')} + + {/if} + + + {/if}
{/if} diff --git a/apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar-navigation.svelte b/apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar-navigation.svelte index 7c2bfbc5..2eea69c8 100644 --- a/apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar-navigation.svelte +++ b/apps/dashboard/src/lib/features/course/components/sidebar/course-sidebar-navigation.svelte @@ -89,6 +89,19 @@ isLesson: true, icon: getNavIcon(NAV_IDS.LESSONS) }, + { + id: NAV_IDS.CERTIFICATES, + title: $t('course.navItems.nav_certificates'), + url: getNavItemRoute(id, 'certificates'), + isActive: (path || page.url.pathname) === getNavItemRoute(id, 'certificates'), + show() { + if (isStudent && $isFreePlan) { + return false; + } + return true; + }, + icon: getNavIcon(NAV_IDS.CERTIFICATES) + }, { id: NAV_IDS.ANALYTICS, title: $t('course.navItems.nav_analytics'), @@ -144,19 +157,6 @@ }, icon: getNavIcon(NAV_IDS.COMPLIANCE) }, - { - id: NAV_IDS.CERTIFICATES, - title: $t('course.navItems.nav_certificates'), - url: getNavItemRoute(id, 'certificates'), - isActive: (path || page.url.pathname) === getNavItemRoute(id, 'certificates'), - show() { - if (isStudent && $isFreePlan) { - return false; - } - return true; - }, - icon: getNavIcon(NAV_IDS.CERTIFICATES) - }, { id: NAV_IDS.LANDING_PAGE, title: $t('course.navItems.nav_landing_page'), diff --git a/apps/dashboard/src/lib/features/course/pages/settings.svelte b/apps/dashboard/src/lib/features/course/pages/settings.svelte index de42bd57..35a0395d 100644 --- a/apps/dashboard/src/lib/features/course/pages/settings.svelte +++ b/apps/dashboard/src/lib/features/course/pages/settings.svelte @@ -158,7 +158,8 @@ grading: $settings.grading, lessonDownload: $settings.lessonDownload, allowNewStudent: $settings.allowNewStudents ?? false, - isContentGroupingEnabled: $settings.isContentGroupingEnabled + isContentGroupingEnabled: $settings.isContentGroupingEnabled, + progressionMode: $settings.progressionMode } as NonNullable; const updatedCourse = { @@ -222,6 +223,7 @@ isPublished: !!course.isPublished, allowNewStudents: !!course.metadata?.allowNewStudent, isContentGroupingEnabled: course.metadata?.isContentGroupingEnabled ?? true, + progressionMode: course.metadata?.progressionMode ?? 'free', callout: normalizeCallout(course.callout) }); }); @@ -522,6 +524,35 @@ + + {$t('course.navItem.settings.progression_mode_title')} + {$t('course.navItem.settings.progression_mode_description')} + + { + if (value === 'free' || value === 'sequential') { + $settings.progressionMode = value; + hasUnsavedChanges = true; + } + }} + > +
+
+ + +
+
+ + +
+
+
+
+
+ + + {$t('course.navItem.settings.lesson_download')} {$t('course.navItem.settings.available')} @@ -737,6 +768,7 @@ checked={$settings.isPublished} onCheckedChange={(checked) => { $settings.isPublished = checked; + if (checked) $settings.allowNewStudents = true; hasUnsavedChanges = true; }} /> diff --git a/apps/dashboard/src/lib/features/lms/components/course-preview-modal.svelte b/apps/dashboard/src/lib/features/lms/components/course-preview-modal.svelte index 51e78246..5e4010ad 100644 --- a/apps/dashboard/src/lib/features/lms/components/course-preview-modal.svelte +++ b/apps/dashboard/src/lib/features/lms/components/course-preview-modal.svelte @@ -3,7 +3,10 @@ import * as Dialog from '@cio/ui/base/dialog'; import { Button } from '@cio/ui/base/button'; import { DEFAULT_COURSE_BANNER_IMAGE } from '@cio/ui'; + import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content'; + import HTMLRender from '$features/ui/html-render.svelte'; import BookOpenIcon from '@lucide/svelte/icons/book-open'; + import XIcon from '@lucide/svelte/icons/x'; import { t } from '$lib/utils/functions/translations'; import { calcCourseDiscount, isCourseFree } from '$lib/utils/functions/course'; import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter'; @@ -44,12 +47,22 @@ - -
- {course.title} + +
+ {course.title} + + + Close +
-
+

{course.title}

{#if course.description} @@ -70,12 +83,14 @@ {#if requirements}

{$t('course.navItem.landing_page.requirement')}

-

{requirements}

+ + +
{/if}
-
+
{#if allowNewStudent}

diff --git a/apps/dashboard/src/lib/features/lms/pages/dashboard.svelte b/apps/dashboard/src/lib/features/lms/pages/dashboard.svelte index afbd0459..b24f81fd 100644 --- a/apps/dashboard/src/lib/features/lms/pages/dashboard.svelte +++ b/apps/dashboard/src/lib/features/lms/pages/dashboard.svelte @@ -17,7 +17,8 @@ import { currentOrg, currentOrgDomain } from '$lib/utils/store/org'; import { profile } from '$lib/utils/store/user'; import { coursesApi } from '$features/course/api'; - import { CourseCard } from '$features/course/components'; + import * as ResourceListRow from '@cio/ui/custom/resource-list-row'; + import { CourseListRow } from '$features/course/components'; import CoursePublicBadge from '$features/course/components/course-public-badge.svelte'; import CoursePreviewModal from '$features/lms/components/course-preview-modal.svelte'; import type { RecommendedCourses } from '$features/course/types'; @@ -63,7 +64,7 @@ if (!$profile.id || !$currentOrg.id) return; coursesApi.getEnrolledCourses(); - coursesApi.getRecommendedCourses({ limit: 6 }); + coursesApi.getRecommendedCourses({ limit: 3 }); }); $effect(() => { @@ -103,7 +104,7 @@ return getStudentCourseProgressPercent(rightCourse) - getStudentCourseProgressPercent(leftCourse); }) - .slice(0, 3); + .slice(0, 2); } async function loadLoginStreak() { @@ -261,9 +262,14 @@

-

- {$t('dashboard.currently_learning')} -

+
+

+ {$t('dashboard.currently_learning')} +

+ +
{#if coursesApi.isLoading} @@ -418,19 +424,28 @@
{:else} -
+ {#each coursesApi.recommendedCourses as course (course.id)} - { selectedCourse = course; previewOpen = true; }} /> {/each} -
+ {/if}
{/if} diff --git a/apps/dashboard/src/lib/features/lms/pages/explore.svelte b/apps/dashboard/src/lib/features/lms/pages/explore.svelte index 05856e7b..dbbe09c8 100644 --- a/apps/dashboard/src/lib/features/lms/pages/explore.svelte +++ b/apps/dashboard/src/lib/features/lms/pages/explore.svelte @@ -10,11 +10,15 @@ import type { RecommendedCourses } from '$features/course/types'; import { CourseSortBy, DEFAULT_COURSE_SORT, parseCourseSortValue } from '$features/course/utils/constants'; import CoursePreviewModal from '$features/lms/components/course-preview-modal.svelte'; + import * as Pagination from '@cio/ui/base/pagination'; + + const EXPLORE_LIMIT = 12; let searchValue = $state(''); let sortKey = $state(DEFAULT_COURSE_SORT); let selectedCourse = $state(null); let previewOpen = $state(false); + let currentPage = $state(1); $effect(() => { if (!browser) return; @@ -44,6 +48,19 @@ return coursesFiltered; }); + const pagination = $derived(coursesApi.recommendedCoursesPagination); + + function fetchPage(page: number) { + currentPage = page; + coursesApi.getRecommendedCourses({ limit: EXPLORE_LIMIT, page }); + } + + $effect(() => { + if (searchValue) { + currentPage = 1; + } + }); + onMount(() => { const courseView = localStorage.getItem('courseView') as 'grid' | 'list' | null; @@ -57,7 +74,7 @@ $effect(() => { if (!$profile.id || !$currentOrg.id) return; - coursesApi.getRecommendedCourses(); + coursesApi.getRecommendedCourses({ limit: EXPLORE_LIMIT, page: currentPage }); }); @@ -76,6 +93,40 @@ }} /> +{#if pagination && pagination.totalPages > 1} + + {#snippet children({ pages, currentPage: activePage })} + + + + + {#each pages as pageItem (pageItem.key)} + {#if pageItem.type === 'ellipsis'} + + + + {:else} + + + {pageItem.value} + + + {/if} + {/each} + + + + + {/snippet} + +{/if} + {#if selectedCourse} {/if} diff --git a/apps/dashboard/src/lib/features/ui/navigation/lms-navigation.ts b/apps/dashboard/src/lib/features/ui/navigation/lms-navigation.ts index fd815269..d66b36d4 100644 --- a/apps/dashboard/src/lib/features/ui/navigation/lms-navigation.ts +++ b/apps/dashboard/src/lib/features/ui/navigation/lms-navigation.ts @@ -57,6 +57,12 @@ export const baseNavConfig: NavItemConfig[] = [ icon: CourseIcon, matchPattern: '^/lms/mylearning(/.*)?$' }, + { + titleKey: 'lms_navigation.explore', + path: '/explore', + icon: ExploreIcon, + matchPattern: '^/lms/explore(/.*)?$' + }, { titleKey: 'lms_navigation.programs', path: '/programs', @@ -76,20 +82,14 @@ export const baseNavConfig: NavItemConfig[] = [ icon: CommunityIcon, matchPattern: '^/lms/community(/.*)?$', show: (currentOrg) => currentOrg?.customization?.dashboard?.community === true, - supportsDynamicSegment: true, // Supports /community/[slug] + supportsDynamicSegment: true, nestedRoutes: [ { path: 'ask', - titleKey: 'Ask Question' // Could be translated + titleKey: 'Ask Question' } ] }, - { - titleKey: 'lms_navigation.explore', - path: '/explore', - icon: ExploreIcon, - matchPattern: '^/lms/explore(/.*)?$' - }, { titleKey: 'lms_navigation.settings', path: '/settings', diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json index a8ba3e7c..f1d5310c 100644 --- a/apps/dashboard/src/lib/utils/translations/en.json +++ b/apps/dashboard/src/lib/utils/translations/en.json @@ -2201,6 +2201,38 @@ "no_lesson": "No lessons yet", "content_locked_title": "This content is locked", "content_locked_description": "Your instructor has not unlocked this content yet. Complete previous content or check back later.", + "content_progression_locked_title": "Complete previous content first", + "content_progression_locked_description": "This content unlocks after you finish the previous required item in the course.", + "content_progression_locked_description_lesson": "This lesson unlocks after you finish the previous required item in the course.", + "content_progression_locked_description_exercise": "This exercise unlocks after you finish the previous required item in the course.", + "completion_policy": { + "label": "Completion rule", + "manual_label": "Manual", + "manual_description": "Students mark the lesson complete themselves.", + "video_watch_label": "Video watch", + "video_watch_description": "The lesson completes automatically after students watch the selected videos.", + "none_label": "None", + "none_description": "This lesson does not block course progression.", + "watch_threshold": "Watch threshold (%)" + }, + "settings": { + "progression": { + "heading": "Progression", + "intro": "Choose how students complete this lesson and move through the course.", + "option_guide_heading": "What each option means", + "watch_videos_heading": "Videos to enforce", + "watch_videos_helper": "Only uploaded videos can be enforced. YouTube and external embeds cannot be tracked.", + "watch_videos_empty": "Add an uploaded video in the Video tab to use watch enforcement.", + "watch_videos_required": "Select at least one video when using video watch.", + "students_only_note": "These rules apply to students only. You can preview and navigate freely while teaching." + } + }, + "watch_progress": { + "status": "Watched {percent}% of the video content", + "videos_complete": "Watched {percent}% of the required video content ({complete} of {required} videos)", + "seek_blocked": "You cannot skip ahead on this video", + "complete_via_watch": "This lesson completes when you watch enough of the video" + }, "share_your_knowledge": "Share your knowledge with the world by creating engaging lessons. Start by clicking on the Add button.", "section_prompt": { "cta": "Enable Sections", @@ -2297,6 +2329,9 @@ "slide_link": "Slide Link", "helper_message": "You can embed Google Slides or Canva Presentation" }, + "settings": { + "title": "Settings" + }, "video": { "title": "Video", "embed_link": "Embed Link", @@ -2362,13 +2397,15 @@ "generate_1080_unavailable": "1080p unavailable for this source video", "playback_error": "Loading this video failed.", "playback_reload": "Reload video", + "loading": "Loading video…", "simple_card": { "kind_youtube": "YouTube", "kind_upload": "Upload", "kind_google_drive": "Google Drive", "kind_generic": "Embedded", "course_fallback": "Course", - "menu_aria": "Video options" + "menu_aria": "Video options", + "transcription_in_progress": "(Transcript generation in progress)" }, "transcript": { "title": "Transcript", @@ -2471,6 +2508,10 @@ "settings": "Settings", "settings_allow_multiple": "Allow multiple attempts", "settings_allow_multiple_helper": "Students can submit more than once and review past attempts on the results screen.", + "settings_completion_policy": "Completion rule", + "settings_completion_submitted": "Submitted — any submission counts", + "settings_completion_passed": "Passed — minimum score required", + "settings_pass_threshold": "Pass threshold (%)", "settings_save": "Save settings", "submissions": "Submissions", "save": "Save", @@ -3297,6 +3338,13 @@ "signatory_one_role": "Signatory 1 role", "signatory_two_name": "Signatory 2 name", "signatory_two_role": "Signatory 2 role", + "signatory_one_toggle": "Show signatory 1", + "signatory_two_toggle": "Show signatory 2", + "signature_upload": "Signature image", + "signature_upload_hint": "Upload a signature image (optional)", + "signature_dont_crop": "Don't Crop", + "signature_upload_in_progress": "Wait for the signature upload to finish before saving.", + "save_failed": "Could not save the certificate design. Check the form and try again.", "id_format": "Certificate ID format", "id_format_hint": "Use '{'seq'}' for the last four digits, '{'year'}' for the issue year, '{'month'}' for the month.", "accent_hint": "Accent color appears on the recipient rule, stamps, and the template's headline highlight.", @@ -3486,6 +3534,10 @@ "drag": "Drag and drop the labels to re-order your material tabs", "content_grouping_title": "Content grouping", "content_grouping_description": "Group lessons and exercises by section in the content list.", + "progression_mode_title": "Student progression", + "progression_mode_description": "Control whether students can access all unlocked content or must complete items in order.", + "progression_mode_free": "Free — unlocked content is accessible", + "progression_mode_sequential": "Sequential — complete prior items to unlock next", "lesson_download": "Lessons DownLoad", "available": "Make the Lesson available for Download in pdf for registered students", "course_download": "Course Downlaod", @@ -3985,6 +4037,7 @@ "thumbnail_regen_failed": "Failed to regenerate thumbnails", "transcription_started": "Transcription started", "transcription_start_failed": "Could not start transcription", + "transcription_already_running": "A transcription job is already in progress for this video", "transcription_completed": "Transcription finished", "transcript_fetch_failed": "Could not load transcript", "transcript_saved": "Transcript updated", diff --git a/apps/dashboard/src/routes/(org-site)/course/[slug]/enroll/+page.svelte b/apps/dashboard/src/routes/(org-site)/course/[slug]/enroll/+page.svelte index 01e63eed..20106e3a 100644 --- a/apps/dashboard/src/routes/(org-site)/course/[slug]/enroll/+page.svelte +++ b/apps/dashboard/src/routes/(org-site)/course/[slug]/enroll/+page.svelte @@ -75,6 +75,8 @@ return; } + let navigatingAway = false; + try { const body = data.token ? { inviteToken: data.token } : {}; const result = await courseApi.enroll(data.course!.id, body); @@ -90,10 +92,11 @@ already_joined: result.data.alreadyJoined }); + navigatingAway = true; // need to force page reload to avoid cache issues window.location.href = result.data.redirectTo || '/lms'; } finally { - loading = false; + if (!navigatingAway) loading = false; } } diff --git a/packages/db/src/queries/course/course.ts b/packages/db/src/queries/course/course.ts index 7bc68aa9..8937164a 100644 --- a/packages/db/src/queries/course/course.ts +++ b/packages/db/src/queries/course/course.ts @@ -1023,6 +1023,12 @@ interface GetExploreCoursesOptions { orgId: string; profileId: string; limit?: number; + page?: number; +} + +export interface GetExploreCoursesResult { + data: TBaseCourse[]; + total: number; } /** @@ -1031,25 +1037,40 @@ interface GetExploreCoursesOptions { * @param options.orgId Organization ID * @param options.profileId Profile ID to exclude enrolled courses * @param options.limit Optional max number of courses to return - * @returns Array of courses with explore-level data + * @param options.page Optional page number (1-based, used with limit) + * @returns Courses with explore-level data and total count */ export const getExploreCourses = async ({ orgId, profileId, - limit -}: GetExploreCoursesOptions): Promise => { + limit, + page = 1 +}: GetExploreCoursesOptions): Promise => { try { - const baseQuery = db - .select({ - course: schema.course, - lessonCount: sql`COUNT(DISTINCT ${schema.lesson.id})`.as('total_lessons'), - exerciseCount: sql`( - SELECT COUNT(*)::bigint - FROM ${schema.exercise} as ex - LEFT JOIN ${schema.lesson} as el ON el.id = ex.lesson_id - WHERE ${or(eq(sql`ex.course_id`, schema.course.id), eq(sql`el.course_id`, schema.course.id))} - )`.as('exercise_count') - }) + const whereCondition = and( + eq(schema.group.organizationId, orgId), + eq(schema.course.status, 'ACTIVE'), + eq(schema.course.isPublished, true), + isNull(schema.groupmember.id), + or( + sql`${schema.course.metadata}->>'allowNewStudent' IS NULL`, + sql`${schema.course.metadata}->>'allowNewStudent' != 'false'` + ) + ); + + const courseSelect = { + course: schema.course, + lessonCount: sql`COUNT(DISTINCT ${schema.lesson.id})`.as('total_lessons'), + exerciseCount: sql`( + SELECT COUNT(*)::bigint + FROM ${schema.exercise} as ex + LEFT JOIN ${schema.lesson} as el ON el.id = ex.lesson_id + WHERE ${or(eq(sql`ex.course_id`, schema.course.id), eq(sql`el.course_id`, schema.course.id))} + )`.as('exercise_count') + }; + + const baseSelect = db + .select(courseSelect) .from(schema.course) .innerJoin(schema.group, eq(schema.course.groupId, schema.group.id)) .leftJoin(schema.lesson, eq(schema.course.id, schema.lesson.courseId)) @@ -1057,24 +1078,33 @@ export const getExploreCourses = async ({ schema.groupmember, and(eq(schema.groupmember.groupId, schema.course.groupId), eq(schema.groupmember.profileId, profileId)) ) - .where( - and( - eq(schema.group.organizationId, orgId), - eq(schema.course.status, 'ACTIVE'), - eq(schema.course.isPublished, true), - isNull(schema.groupmember.id) - ) - ) + .where(whereCondition) .groupBy(schema.course.id, schema.group.id) .orderBy(desc(schema.course.createdAt)); - const result = limit ? await baseQuery.limit(limit) : await baseQuery; + const countSelect = db + .select({ total: count(schema.course.id) }) + .from(schema.course) + .innerJoin(schema.group, eq(schema.course.groupId, schema.group.id)) + .leftJoin( + schema.groupmember, + and(eq(schema.groupmember.groupId, schema.course.groupId), eq(schema.groupmember.profileId, profileId)) + ) + .where(whereCondition); - return result.map((row) => ({ - ...row.course, - lessonCount: Number(row.lessonCount), - exerciseCount: Number(row.exerciseCount) - })); + const [countRows, rows] = await Promise.all([ + countSelect, + limit ? baseSelect.limit(limit).offset((page - 1) * limit) : baseSelect + ]); + + return { + data: rows.map((row) => ({ + ...row.course, + lessonCount: Number(row.lessonCount), + exerciseCount: Number(row.exerciseCount) + })), + total: Number(countRows[0]?.total ?? 0) + }; } catch (error) { console.error('getExploreCourses error:', error); throw new Error(`Failed to get explore courses: ${error instanceof Error ? error.message : 'Unknown error'}`); diff --git a/packages/utils/src/validation/course/course.ts b/packages/utils/src/validation/course/course.ts index 0c9f5d6c..73acae88 100644 --- a/packages/utils/src/validation/course/course.ts +++ b/packages/utils/src/validation/course/course.ts @@ -4,7 +4,8 @@ import { ALLOWED_CONTENT_TYPES, ALLOWED_DOCUMENT_TYPES } from '../constants'; import { ZCourseCalloutInput } from './callout'; export const ZGetRecommendedCourses = z.object({ - limit: z.string().transform(Number).pipe(z.number().min(1).max(50)).optional() + limit: z.string().transform(Number).pipe(z.number().min(1).max(50)).optional(), + page: z.string().transform(Number).pipe(z.number().min(1)).optional() }); export type TGetRecommendedCourses = z.infer; @@ -57,7 +58,9 @@ export type TCourseDownloadParam = z.infer; */ export const ZCertificateSignatory = z.object({ name: z.string().max(80).default(''), - role: z.string().max(80).default('') + role: z.string().max(80).default(''), + enabled: z.boolean().default(true), + signatureUrl: z.url().optional() }); export type TCertificateSignatory = z.infer; @@ -82,7 +85,14 @@ export type TCertificateDesign = z.infer; export const ZCertificateDownloadRequest = z.object({ studentName: z.string().min(1).max(120), studentId: z.string().min(1).max(64).optional(), - issuedAt: z.string().datetime().optional(), + issuedAt: z.preprocess((value) => { + if (typeof value !== 'string' || value.trim() === '') return undefined; + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return undefined; + + return parsed.toISOString(); + }, z.string().datetime().optional()), /** When true, skips the eligibility check (course owner previewing their own design). */ previewMode: z.boolean().optional() }); @@ -355,7 +365,8 @@ const ZCourseMetadataFields = z.object({ lessonDownload: z.boolean().optional(), allowNewStudent: z.boolean(), sectionDisplay: z.record(z.string(), z.boolean()).optional(), - isContentGroupingEnabled: z.boolean().optional() + isContentGroupingEnabled: z.boolean().optional(), + progressionMode: z.enum(['free', 'sequential']).optional() }); // Course metadata schema matching the database structure