feat: improve LMS course discovery and enrollment UX
- Add paginated recommended courses to LMS dashboard (top 3, list
view)
and explore page (12 per page); exclude courses blocking new
students
- Polish course preview modal: remove padding, render requirements as
HTML, round banner image, secondary close button
- Redirect to course start page after successful enrollment instead of
/lms;
keep spinner active until navigation completes
- Auto-enable "allow new students" when a course is published
- Move Explore after My Learning in LMS sidebar nav
- Move Certificates after Content in course sidebar
- Add hiddenColumns prop to CourseListRow; show "Learn more" button
instead of actions menu in LMS explore context
This commit is contained in:
@@ -442,15 +442,19 @@ export const organizationRouter = new Hono()
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
try {
|
try {
|
||||||
const user = c.get('user')!;
|
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 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(
|
return c.json(
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
data: result
|
data,
|
||||||
|
pagination: { page, limit: resolvedLimit, total, totalPages }
|
||||||
},
|
},
|
||||||
200
|
200
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -649,7 +649,7 @@ export async function enrollInCourse(
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
alreadyJoined: true,
|
alreadyJoined: true,
|
||||||
redirectTo: '/lms'
|
redirectTo: `/courses/${courseId}/lessons?next=true`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,7 +692,7 @@ export async function enrollInCourse(
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
alreadyJoined: false,
|
alreadyJoined: false,
|
||||||
redirectTo: '/lms'
|
redirectTo: `/courses/${courseId}/lessons?next=true`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1056,6 +1056,6 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
|||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
alreadyJoined: result.alreadyJoined,
|
alreadyJoined: result.alreadyJoined,
|
||||||
redirectTo: '/lms'
|
redirectTo: `/courses/${result.courseId}/lessons?next=true`
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -469,9 +469,9 @@ export async function getUserEnrolledCourses(orgId: string, userId: string) {
|
|||||||
* @param userId - User ID to exclude enrolled courses
|
* @param userId - User ID to exclude enrolled courses
|
||||||
* @returns Array of recommended 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 {
|
try {
|
||||||
return getExploreCourses({ orgId, profileId: userId, limit });
|
return getExploreCourses({ orgId, profileId: userId, limit, page });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AppError) throw error;
|
if (error instanceof AppError) throw error;
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export class CoursesApi extends BaseApiWithErrors {
|
|||||||
orgCoursesPagination = $state<OrgCoursesPagination | null>(null);
|
orgCoursesPagination = $state<OrgCoursesPagination | null>(null);
|
||||||
enrolledCourses = $state<UserEnrolledCourses>([]);
|
enrolledCourses = $state<UserEnrolledCourses>([]);
|
||||||
recommendedCourses = $state<RecommendedCourses>([]);
|
recommendedCourses = $state<RecommendedCourses>([]);
|
||||||
|
recommendedCoursesPagination = $state<{ page: number; limit: number; total: number; totalPages: number } | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
private activeOrgCoursesRequestController: AbortController | null = null;
|
private activeOrgCoursesRequestController: AbortController | null = null;
|
||||||
|
|
||||||
cancelOrgCoursesRequest() {
|
cancelOrgCoursesRequest() {
|
||||||
@@ -135,17 +138,24 @@ export class CoursesApi extends BaseApiWithErrors {
|
|||||||
* Fetches recommended courses (published courses user isn't enrolled in) for the current organization
|
* Fetches recommended courses (published courses user isn't enrolled in) for the current organization
|
||||||
* Org ID is automatically added from currentOrg store
|
* Org ID is automatically added from currentOrg store
|
||||||
*/
|
*/
|
||||||
async getRecommendedCourses(options?: { limit?: number }) {
|
async getRecommendedCourses(options?: { limit?: number; page?: number }) {
|
||||||
|
const query: Record<string, string> = {};
|
||||||
|
if (options?.limit) query.limit = String(options.limit);
|
||||||
|
if (options?.page) query.page = String(options.page);
|
||||||
|
|
||||||
await this.execute<GetRecommendedCoursesRequest>({
|
await this.execute<GetRecommendedCoursesRequest>({
|
||||||
requestFn: () =>
|
requestFn: () =>
|
||||||
classroomio.organization.courses.recommended.$get({
|
classroomio.organization.courses.recommended.$get({
|
||||||
query: options?.limit ? { limit: String(options.limit) } : {}
|
query
|
||||||
}),
|
}),
|
||||||
logContext: 'fetching recommended courses',
|
logContext: 'fetching recommended courses',
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
this.recommendedCourses = response.data;
|
this.recommendedCourses = response.data;
|
||||||
}
|
}
|
||||||
|
if ('pagination' in response && response.pagination) {
|
||||||
|
this.recommendedCoursesPagination = response.pagination as typeof this.recommendedCoursesPagination;
|
||||||
|
}
|
||||||
this.errors = {};
|
this.errors = {};
|
||||||
},
|
},
|
||||||
onError: (result) => {
|
onError: (result) => {
|
||||||
|
|||||||
@@ -26,6 +26,18 @@
|
|||||||
color?: string | null;
|
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 {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
@@ -41,6 +53,8 @@
|
|||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
isExplore?: boolean;
|
isExplore?: boolean;
|
||||||
isLMS?: boolean;
|
isLMS?: boolean;
|
||||||
|
hiddenColumns?: ColumnKey[];
|
||||||
|
onExploreClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -57,7 +71,9 @@
|
|||||||
updatedAt,
|
updatedAt,
|
||||||
tags = [],
|
tags = [],
|
||||||
isExplore = false,
|
isExplore = false,
|
||||||
isLMS = false
|
isLMS = false,
|
||||||
|
hiddenColumns = [],
|
||||||
|
onExploreClick
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const titleInitial = $derived(title.trim().charAt(0).toUpperCase() || 'C');
|
const titleInitial = $derived(title.trim().charAt(0).toUpperCase() || 'C');
|
||||||
@@ -66,12 +82,20 @@
|
|||||||
|
|
||||||
const showPublicCourseLinks = $derived(isPublished && type === 'PUBLIC' && slug.trim().length > 0);
|
const showPublicCourseLinks = $derived(isPublished && type === 'PUBLIC' && slug.trim().length > 0);
|
||||||
|
|
||||||
const showActionsColumn = $derived(!isLMS || (isLMS && showPublicCourseLinks));
|
const hidden = $derived(new Set<string>(hiddenColumns));
|
||||||
|
|
||||||
|
const showActionsColumn = $derived(
|
||||||
|
!hidden.has('actions') && (!isLMS || (isLMS && showPublicCourseLinks) || (isLMS && isExplore))
|
||||||
|
);
|
||||||
|
|
||||||
const gridTemplateColumns = $derived(
|
const gridTemplateColumns = $derived(
|
||||||
showActionsColumn
|
COLUMN_TRACKS.filter(([key]) => {
|
||||||
? '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem 2.5rem'
|
if (hidden.has(key)) return false;
|
||||||
: '7rem minmax(0, 2fr) 5.5rem minmax(0, 2fr) 4.5rem 6rem'
|
if (key === 'actions') return showActionsColumn;
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map(([, track]) => track)
|
||||||
|
.join(' ')
|
||||||
);
|
);
|
||||||
|
|
||||||
const studentPlaceholderAvatarUrls = $derived.by(() => [
|
const studentPlaceholderAvatarUrls = $derived.by(() => [
|
||||||
@@ -91,6 +115,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function handleRowClick() {
|
function handleRowClick() {
|
||||||
|
if (isExplore && onExploreClick) {
|
||||||
|
onExploreClick();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (isExplore) {
|
if (isExplore) {
|
||||||
goto(resolve(`/course/${slug}`, {}));
|
goto(resolve(`/course/${slug}`, {}));
|
||||||
return;
|
return;
|
||||||
@@ -161,35 +189,39 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Published badge -->
|
<!-- Published badge -->
|
||||||
<div>
|
{#if !hidden.has('published')}
|
||||||
{#if isPublished}
|
<div>
|
||||||
<Badge variant="success" class="whitespace-nowrap">
|
{#if isPublished}
|
||||||
{$t('courses.course_card.published')}
|
<Badge variant="success" class="whitespace-nowrap">
|
||||||
</Badge>
|
{$t('courses.course_card.published')}
|
||||||
{:else}
|
</Badge>
|
||||||
<Badge variant="secondary" class="whitespace-nowrap">
|
{:else}
|
||||||
{$t('courses.course_card.unpublished')}
|
<Badge variant="secondary" class="whitespace-nowrap">
|
||||||
</Badge>
|
{$t('courses.course_card.unpublished')}
|
||||||
{/if}
|
</Badge>
|
||||||
</div>
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Tags -->
|
<!-- Tags -->
|
||||||
<div class="flex min-w-0 flex-wrap items-center gap-1">
|
{#if !hidden.has('tags')}
|
||||||
{#if tags.length === 0}
|
<div class="flex min-w-0 flex-wrap items-center gap-1">
|
||||||
<span class="ui:text-muted-foreground text-xs">—</span>
|
{#if tags.length === 0}
|
||||||
{:else}
|
<span class="ui:text-muted-foreground text-xs">—</span>
|
||||||
{#each tags as tag (tag.id)}
|
{:else}
|
||||||
<Badge variant="outline" class="max-w-[140px] truncate">
|
{#each tags as tag (tag.id)}
|
||||||
<span
|
<Badge variant="outline" class="max-w-[140px] truncate">
|
||||||
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 shrink-0 rounded-full"
|
<span
|
||||||
style={tag.color ? `background-color: ${tag.color}` : undefined}
|
class="ui:bg-primary/60 inline-block h-1.5 w-1.5 shrink-0 rounded-full"
|
||||||
aria-hidden="true"
|
style={tag.color ? `background-color: ${tag.color}` : undefined}
|
||||||
></span>
|
aria-hidden="true"
|
||||||
{tag.name}
|
></span>
|
||||||
</Badge>
|
{tag.name}
|
||||||
{/each}
|
</Badge>
|
||||||
{/if}
|
{/each}
|
||||||
</div>
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Lessons / exercises -->
|
<!-- Lessons / exercises -->
|
||||||
<div class="flex flex-col gap-1 tabular-nums">
|
<div class="flex flex-col gap-1 tabular-nums">
|
||||||
@@ -204,97 +236,112 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Students -->
|
<!-- Students -->
|
||||||
<div class="flex items-center gap-1">
|
{#if !hidden.has('students')}
|
||||||
{#if totalStudents > 0}
|
<div class="flex items-center gap-1">
|
||||||
{#if totalStudents < 3}
|
{#if totalStudents > 0}
|
||||||
<div class="flex items-center gap-1">
|
{#if totalStudents < 3}
|
||||||
<Avatar.Root class="ui:border-background size-6 border-2">
|
<div class="flex items-center gap-1">
|
||||||
<Avatar.Image src={studentPlaceholderAvatarUrls[0]} alt="" loading="lazy" decoding="async" />
|
|
||||||
<Avatar.Fallback aria-hidden="true">
|
|
||||||
{titleInitial}
|
|
||||||
</Avatar.Fallback>
|
|
||||||
</Avatar.Root>
|
|
||||||
<p class="text-xs tabular-nums">{totalStudents}</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="flex -space-x-2">
|
|
||||||
{#each studentPlaceholderAvatarUrls as avatarUrl, slotIndex (slotIndex)}
|
|
||||||
<Avatar.Root class="ui:border-background size-6 border-2">
|
<Avatar.Root class="ui:border-background size-6 border-2">
|
||||||
<Avatar.Image src={avatarUrl} alt="" loading="lazy" decoding="async" />
|
<Avatar.Image src={studentPlaceholderAvatarUrls[0]} alt="" loading="lazy" decoding="async" />
|
||||||
<Avatar.Fallback aria-hidden="true">
|
<Avatar.Fallback aria-hidden="true">
|
||||||
{titleInitial}
|
{titleInitial}
|
||||||
</Avatar.Fallback>
|
</Avatar.Fallback>
|
||||||
</Avatar.Root>
|
</Avatar.Root>
|
||||||
{/each}
|
<p class="text-xs tabular-nums">{totalStudents}</p>
|
||||||
<Avatar.Root class="ui:border-background size-6 border-2">
|
</div>
|
||||||
<Avatar.Fallback class="text-[10px]">+{totalStudents - 2}</Avatar.Fallback>
|
{:else}
|
||||||
</Avatar.Root>
|
<div class="flex -space-x-2">
|
||||||
</div>
|
{#each studentPlaceholderAvatarUrls as avatarUrl, slotIndex (slotIndex)}
|
||||||
|
<Avatar.Root class="ui:border-background size-6 border-2">
|
||||||
|
<Avatar.Image src={avatarUrl} alt="" loading="lazy" decoding="async" />
|
||||||
|
<Avatar.Fallback aria-hidden="true">
|
||||||
|
{titleInitial}
|
||||||
|
</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
{/each}
|
||||||
|
<Avatar.Root class="ui:border-background size-6 border-2">
|
||||||
|
<Avatar.Fallback class="text-[10px]">+{totalStudents - 2}</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<Avatar.Root class="size-6">
|
||||||
|
<Avatar.Image src={emptyStateAvatarUrl} alt="" loading="lazy" decoding="async" />
|
||||||
|
<Avatar.Fallback aria-hidden="true">
|
||||||
|
{titleInitial}
|
||||||
|
</Avatar.Fallback>
|
||||||
|
</Avatar.Root>
|
||||||
|
<p class="text-xs">0</p>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
</div>
|
||||||
<Avatar.Root class="size-6">
|
{/if}
|
||||||
<Avatar.Image src={emptyStateAvatarUrl} alt="" loading="lazy" decoding="async" />
|
|
||||||
<Avatar.Fallback aria-hidden="true">
|
|
||||||
{titleInitial}
|
|
||||||
</Avatar.Fallback>
|
|
||||||
</Avatar.Root>
|
|
||||||
<p class="text-xs">0</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
{#if showActionsColumn}
|
{#if showActionsColumn}
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<DropdownMenu.Root>
|
{#if isLMS && isExplore}
|
||||||
<DropdownMenu.Trigger>
|
<Button
|
||||||
{#snippet child({ props })}
|
variant="outline"
|
||||||
<Button
|
size="sm"
|
||||||
{...props}
|
onclick={(e) => {
|
||||||
variant="ghost"
|
e.stopPropagation();
|
||||||
size="icon"
|
onExploreClick?.();
|
||||||
class="size-8"
|
}}
|
||||||
aria-label={$t('courses.course_card.actions_menu_aria')}
|
>
|
||||||
onclick={(e) => e.stopPropagation()}
|
{$t('courses.course_card.learn_more')}
|
||||||
>
|
</Button>
|
||||||
<EllipsisVerticalIcon class="size-4" />
|
{:else}
|
||||||
</Button>
|
<DropdownMenu.Root>
|
||||||
{/snippet}
|
<DropdownMenu.Trigger>
|
||||||
</DropdownMenu.Trigger>
|
{#snippet child({ props })}
|
||||||
<DropdownMenu.Content align="end">
|
<Button
|
||||||
{#if showPublicCourseLinks}
|
{...props}
|
||||||
<DropdownMenu.Item
|
variant="ghost"
|
||||||
onclick={() =>
|
size="icon"
|
||||||
openCoursePreview({
|
class="size-8"
|
||||||
courseId: id,
|
aria-label={$t('courses.course_card.actions_menu_aria')}
|
||||||
courseSlug: slug,
|
onclick={(e) => e.stopPropagation()}
|
||||||
currentOrgDomain: $currentOrgDomain
|
>
|
||||||
})}
|
<EllipsisVerticalIcon class="size-4" />
|
||||||
>
|
</Button>
|
||||||
<ExternalLinkIcon class="mr-2 size-4" />
|
{/snippet}
|
||||||
{$t('courses.course_card.context_menu.open_public_course')}
|
</DropdownMenu.Trigger>
|
||||||
</DropdownMenu.Item>
|
<DropdownMenu.Content align="end">
|
||||||
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
|
|
||||||
<CopyIcon class="mr-2 size-4" />
|
|
||||||
{$t('courses.course_card.context_menu.copy_course_url')}
|
|
||||||
</DropdownMenu.Item>
|
|
||||||
{/if}
|
|
||||||
{#if !isLMS}
|
|
||||||
{#if showPublicCourseLinks}
|
{#if showPublicCourseLinks}
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Item
|
||||||
|
onclick={() =>
|
||||||
|
openCoursePreview({
|
||||||
|
courseId: id,
|
||||||
|
courseSlug: slug,
|
||||||
|
currentOrgDomain: $currentOrgDomain
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ExternalLinkIcon class="mr-2 size-4" />
|
||||||
|
{$t('courses.course_card.context_menu.open_public_course')}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item onclick={() => void handleCopyCourseUrl()}>
|
||||||
|
<CopyIcon class="mr-2 size-4" />
|
||||||
|
{$t('courses.course_card.context_menu.copy_course_url')}
|
||||||
|
</DropdownMenu.Item>
|
||||||
{/if}
|
{/if}
|
||||||
<DropdownMenu.Item onclick={handleOpen}>
|
{#if !isLMS}
|
||||||
{$t('courses.course_card.context_menu.open')}
|
{#if showPublicCourseLinks}
|
||||||
</DropdownMenu.Item>
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item onclick={handleClone}>
|
{/if}
|
||||||
{$t('courses.course_card.context_menu.clone')}
|
<DropdownMenu.Item onclick={handleOpen}>
|
||||||
</DropdownMenu.Item>
|
{$t('courses.course_card.context_menu.open')}
|
||||||
<DropdownMenu.Separator />
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item variant="destructive" onclick={handleDelete}>
|
<DropdownMenu.Item onclick={handleClone}>
|
||||||
{$t('courses.course_card.context_menu.delete')}
|
{$t('courses.course_card.context_menu.clone')}
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
{/if}
|
<DropdownMenu.Separator />
|
||||||
</DropdownMenu.Content>
|
<DropdownMenu.Item variant="destructive" onclick={handleDelete}>
|
||||||
</DropdownMenu.Root>
|
{$t('courses.course_card.context_menu.delete')}
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
{/if}
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+13
-13
@@ -89,6 +89,19 @@
|
|||||||
isLesson: true,
|
isLesson: true,
|
||||||
icon: getNavIcon(NAV_IDS.LESSONS)
|
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,
|
id: NAV_IDS.ANALYTICS,
|
||||||
title: $t('course.navItems.nav_analytics'),
|
title: $t('course.navItems.nav_analytics'),
|
||||||
@@ -144,19 +157,6 @@
|
|||||||
},
|
},
|
||||||
icon: getNavIcon(NAV_IDS.COMPLIANCE)
|
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,
|
id: NAV_IDS.LANDING_PAGE,
|
||||||
title: $t('course.navItems.nav_landing_page'),
|
title: $t('course.navItems.nav_landing_page'),
|
||||||
|
|||||||
@@ -158,7 +158,8 @@
|
|||||||
grading: $settings.grading,
|
grading: $settings.grading,
|
||||||
lessonDownload: $settings.lessonDownload,
|
lessonDownload: $settings.lessonDownload,
|
||||||
allowNewStudent: $settings.allowNewStudents ?? false,
|
allowNewStudent: $settings.allowNewStudents ?? false,
|
||||||
isContentGroupingEnabled: $settings.isContentGroupingEnabled
|
isContentGroupingEnabled: $settings.isContentGroupingEnabled,
|
||||||
|
progressionMode: $settings.progressionMode
|
||||||
} as NonNullable<Course['metadata']>;
|
} as NonNullable<Course['metadata']>;
|
||||||
|
|
||||||
const updatedCourse = {
|
const updatedCourse = {
|
||||||
@@ -222,6 +223,7 @@
|
|||||||
isPublished: !!course.isPublished,
|
isPublished: !!course.isPublished,
|
||||||
allowNewStudents: !!course.metadata?.allowNewStudent,
|
allowNewStudents: !!course.metadata?.allowNewStudent,
|
||||||
isContentGroupingEnabled: course.metadata?.isContentGroupingEnabled ?? true,
|
isContentGroupingEnabled: course.metadata?.isContentGroupingEnabled ?? true,
|
||||||
|
progressionMode: course.metadata?.progressionMode ?? 'free',
|
||||||
callout: normalizeCallout(course.callout)
|
callout: normalizeCallout(course.callout)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -522,6 +524,35 @@
|
|||||||
|
|
||||||
<Field.Separator />
|
<Field.Separator />
|
||||||
|
|
||||||
|
<Field.Set>
|
||||||
|
<Field.Legend>{$t('course.navItem.settings.progression_mode_title')}</Field.Legend>
|
||||||
|
<Field.Description>{$t('course.navItem.settings.progression_mode_description')}</Field.Description>
|
||||||
|
<Field.Field>
|
||||||
|
<RadioGroup.Root
|
||||||
|
value={$settings.progressionMode}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === 'free' || value === 'sequential') {
|
||||||
|
$settings.progressionMode = value;
|
||||||
|
hasUnsavedChanges = true;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<RadioGroup.Item value="free" id="progression-free" />
|
||||||
|
<Label for="progression-free">{$t('course.navItem.settings.progression_mode_free')}</Label>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<RadioGroup.Item value="sequential" id="progression-sequential" />
|
||||||
|
<Label for="progression-sequential">{$t('course.navItem.settings.progression_mode_sequential')}</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</RadioGroup.Root>
|
||||||
|
</Field.Field>
|
||||||
|
</Field.Set>
|
||||||
|
|
||||||
|
<Field.Separator />
|
||||||
|
|
||||||
<Field.Set>
|
<Field.Set>
|
||||||
<Field.Legend>{$t('course.navItem.settings.lesson_download')}</Field.Legend>
|
<Field.Legend>{$t('course.navItem.settings.lesson_download')}</Field.Legend>
|
||||||
<Field.Description>{$t('course.navItem.settings.available')}</Field.Description>
|
<Field.Description>{$t('course.navItem.settings.available')}</Field.Description>
|
||||||
@@ -737,6 +768,7 @@
|
|||||||
checked={$settings.isPublished}
|
checked={$settings.isPublished}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
$settings.isPublished = checked;
|
$settings.isPublished = checked;
|
||||||
|
if (checked) $settings.allowNewStudents = true;
|
||||||
hasUnsavedChanges = true;
|
hasUnsavedChanges = true;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
import * as Dialog from '@cio/ui/base/dialog';
|
import * as Dialog from '@cio/ui/base/dialog';
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
import { DEFAULT_COURSE_BANNER_IMAGE } from '@cio/ui';
|
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 BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||||
|
import XIcon from '@lucide/svelte/icons/x';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { calcCourseDiscount, isCourseFree } from '$lib/utils/functions/course';
|
import { calcCourseDiscount, isCourseFree } from '$lib/utils/functions/course';
|
||||||
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
|
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
|
||||||
@@ -44,12 +47,22 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root bind:open>
|
<Dialog.Root bind:open>
|
||||||
<Dialog.Content class="overflow-hidden p-0 sm:max-w-2xl">
|
<Dialog.Content class="ui:p-0 overflow-hidden sm:max-w-2xl" showCloseButton={false}>
|
||||||
<div class="aspect-video w-full overflow-hidden">
|
<div class="relative overflow-hidden rounded-md">
|
||||||
<img src={course.logo || DEFAULT_COURSE_BANNER_IMAGE} alt={course.title} class="h-full w-full object-cover" />
|
<img
|
||||||
|
src={course.logo || DEFAULT_COURSE_BANNER_IMAGE}
|
||||||
|
alt={course.title}
|
||||||
|
class="aspect-video w-full object-cover"
|
||||||
|
/>
|
||||||
|
<Dialog.Close
|
||||||
|
class="ui:absolute ui:top-3 ui:right-3 ui:inline-flex ui:size-8 ui:items-center ui:justify-center ui:rounded-md ui:bg-secondary ui:text-secondary-foreground ui:hover:bg-secondary/80 ui:transition-colors ui:cursor-pointer"
|
||||||
|
>
|
||||||
|
<XIcon class="ui:size-4" />
|
||||||
|
<span class="ui:sr-only">Close</span>
|
||||||
|
</Dialog.Close>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="max-h-80 overflow-y-auto px-6 py-4">
|
<div class="max-h-72 overflow-y-auto px-6 py-4">
|
||||||
<h2 class="text-xl font-semibold tracking-tight">{course.title}</h2>
|
<h2 class="text-xl font-semibold tracking-tight">{course.title}</h2>
|
||||||
|
|
||||||
{#if course.description}
|
{#if course.description}
|
||||||
@@ -70,12 +83,14 @@
|
|||||||
{#if requirements}
|
{#if requirements}
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<h3 class="text-sm font-semibold">{$t('course.navItem.landing_page.requirement')}</h3>
|
<h3 class="text-sm font-semibold">{$t('course.navItem.landing_page.requirement')}</h3>
|
||||||
<p class="ui:text-muted-foreground mt-1 text-sm leading-relaxed">{requirements}</p>
|
<HTMLRender className="text-sm mt-1" disableMaxWidth={true}>
|
||||||
|
<SafeHtmlContent content={requirements} />
|
||||||
|
</HTMLRender>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ui:border-t ui:bg-card sticky bottom-0 flex items-center justify-between px-6 py-4">
|
<div class="ui:border-t ui:bg-card sticky bottom-0 flex items-center justify-between px-6 py-4" data-sticky="true">
|
||||||
<div>
|
<div>
|
||||||
{#if allowNewStudent}
|
{#if allowNewStudent}
|
||||||
<p class="text-lg font-bold">
|
<p class="text-lg font-bold">
|
||||||
|
|||||||
@@ -17,7 +17,8 @@
|
|||||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||||
import { profile } from '$lib/utils/store/user';
|
import { profile } from '$lib/utils/store/user';
|
||||||
import { coursesApi } from '$features/course/api';
|
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 CoursePublicBadge from '$features/course/components/course-public-badge.svelte';
|
||||||
import CoursePreviewModal from '$features/lms/components/course-preview-modal.svelte';
|
import CoursePreviewModal from '$features/lms/components/course-preview-modal.svelte';
|
||||||
import type { RecommendedCourses } from '$features/course/types';
|
import type { RecommendedCourses } from '$features/course/types';
|
||||||
@@ -63,7 +64,7 @@
|
|||||||
if (!$profile.id || !$currentOrg.id) return;
|
if (!$profile.id || !$currentOrg.id) return;
|
||||||
|
|
||||||
coursesApi.getEnrolledCourses();
|
coursesApi.getEnrolledCourses();
|
||||||
coursesApi.getRecommendedCourses({ limit: 6 });
|
coursesApi.getRecommendedCourses({ limit: 3 });
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -103,7 +104,7 @@
|
|||||||
|
|
||||||
return getStudentCourseProgressPercent(rightCourse) - getStudentCourseProgressPercent(leftCourse);
|
return getStudentCourseProgressPercent(rightCourse) - getStudentCourseProgressPercent(leftCourse);
|
||||||
})
|
})
|
||||||
.slice(0, 3);
|
.slice(0, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadLoginStreak() {
|
async function loadLoginStreak() {
|
||||||
@@ -261,9 +262,14 @@
|
|||||||
|
|
||||||
<div class="grid items-stretch gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(280px,0.72fr)]">
|
<div class="grid items-stretch gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(280px,0.72fr)]">
|
||||||
<section class="flex h-full flex-col space-y-4">
|
<section class="flex h-full flex-col space-y-4">
|
||||||
<h2 class="ui:text-muted-foreground text-sm font-semibold tracking-[0.14em] uppercase">
|
<div class="flex items-center justify-between">
|
||||||
{$t('dashboard.currently_learning')}
|
<h2 class="ui:text-muted-foreground text-sm font-semibold tracking-[0.14em] uppercase">
|
||||||
</h2>
|
{$t('dashboard.currently_learning')}
|
||||||
|
</h2>
|
||||||
|
<Button variant="outline" size="sm" onclick={() => goto('/lms/mylearning')}>
|
||||||
|
{$t('dashboard.view_more')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col space-y-3">
|
<div class="flex flex-1 flex-col space-y-3">
|
||||||
{#if coursesApi.isLoading}
|
{#if coursesApi.isLoading}
|
||||||
@@ -418,19 +424,28 @@
|
|||||||
<Spinner class="size-6" />
|
<Spinner class="size-6" />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<ResourceListRow.Group>
|
||||||
{#each coursesApi.recommendedCourses as course (course.id)}
|
{#each coursesApi.recommendedCourses as course (course.id)}
|
||||||
<CourseCard
|
<CourseListRow
|
||||||
{course}
|
id={course.id}
|
||||||
|
slug={course.slug ?? ''}
|
||||||
|
title={course.title}
|
||||||
|
logo={course.logo}
|
||||||
|
type={course.type}
|
||||||
|
description={course.description ?? ''}
|
||||||
|
isPublished={course.isPublished ?? false}
|
||||||
|
lessonCount={course.lessonCount}
|
||||||
|
exerciseCount={course.exerciseCount}
|
||||||
isExplore={true}
|
isExplore={true}
|
||||||
isLMS={true}
|
isLMS={true}
|
||||||
|
hiddenColumns={['published', 'tags', 'students']}
|
||||||
onExploreClick={() => {
|
onExploreClick={() => {
|
||||||
selectedCourse = course;
|
selectedCourse = course;
|
||||||
previewOpen = true;
|
previewOpen = true;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</ResourceListRow.Group>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -10,11 +10,15 @@
|
|||||||
import type { RecommendedCourses } from '$features/course/types';
|
import type { RecommendedCourses } from '$features/course/types';
|
||||||
import { CourseSortBy, DEFAULT_COURSE_SORT, parseCourseSortValue } from '$features/course/utils/constants';
|
import { CourseSortBy, DEFAULT_COURSE_SORT, parseCourseSortValue } from '$features/course/utils/constants';
|
||||||
import CoursePreviewModal from '$features/lms/components/course-preview-modal.svelte';
|
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 searchValue = $state('');
|
||||||
let sortKey = $state<CourseSortBy>(DEFAULT_COURSE_SORT);
|
let sortKey = $state<CourseSortBy>(DEFAULT_COURSE_SORT);
|
||||||
let selectedCourse = $state<RecommendedCourses[number] | null>(null);
|
let selectedCourse = $state<RecommendedCourses[number] | null>(null);
|
||||||
let previewOpen = $state(false);
|
let previewOpen = $state(false);
|
||||||
|
let currentPage = $state(1);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
@@ -44,6 +48,19 @@
|
|||||||
return coursesFiltered;
|
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(() => {
|
onMount(() => {
|
||||||
const courseView = localStorage.getItem('courseView') as 'grid' | 'list' | null;
|
const courseView = localStorage.getItem('courseView') as 'grid' | 'list' | null;
|
||||||
|
|
||||||
@@ -57,7 +74,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!$profile.id || !$currentOrg.id) return;
|
if (!$profile.id || !$currentOrg.id) return;
|
||||||
|
|
||||||
coursesApi.getRecommendedCourses();
|
coursesApi.getRecommendedCourses({ limit: EXPLORE_LIMIT, page: currentPage });
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -76,6 +93,40 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{#if pagination && pagination.totalPages > 1}
|
||||||
|
<Pagination.Root
|
||||||
|
count={pagination.total}
|
||||||
|
perPage={pagination.limit}
|
||||||
|
page={currentPage}
|
||||||
|
onPageChange={fetchPage}
|
||||||
|
class="mt-6"
|
||||||
|
>
|
||||||
|
{#snippet children({ pages, currentPage: activePage })}
|
||||||
|
<Pagination.Content>
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.PrevButton />
|
||||||
|
</Pagination.Item>
|
||||||
|
{#each pages as pageItem (pageItem.key)}
|
||||||
|
{#if pageItem.type === 'ellipsis'}
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.Ellipsis />
|
||||||
|
</Pagination.Item>
|
||||||
|
{:else}
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.Link page={pageItem} isActive={activePage === pageItem.value}>
|
||||||
|
{pageItem.value}
|
||||||
|
</Pagination.Link>
|
||||||
|
</Pagination.Item>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.NextButton />
|
||||||
|
</Pagination.Item>
|
||||||
|
</Pagination.Content>
|
||||||
|
{/snippet}
|
||||||
|
</Pagination.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if selectedCourse}
|
{#if selectedCourse}
|
||||||
<CoursePreviewModal course={selectedCourse} bind:open={previewOpen} />
|
<CoursePreviewModal course={selectedCourse} bind:open={previewOpen} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -57,6 +57,12 @@ export const baseNavConfig: NavItemConfig[] = [
|
|||||||
icon: CourseIcon,
|
icon: CourseIcon,
|
||||||
matchPattern: '^/lms/mylearning(/.*)?$'
|
matchPattern: '^/lms/mylearning(/.*)?$'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
titleKey: 'lms_navigation.explore',
|
||||||
|
path: '/explore',
|
||||||
|
icon: ExploreIcon,
|
||||||
|
matchPattern: '^/lms/explore(/.*)?$'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
titleKey: 'lms_navigation.programs',
|
titleKey: 'lms_navigation.programs',
|
||||||
path: '/programs',
|
path: '/programs',
|
||||||
@@ -76,20 +82,14 @@ export const baseNavConfig: NavItemConfig[] = [
|
|||||||
icon: CommunityIcon,
|
icon: CommunityIcon,
|
||||||
matchPattern: '^/lms/community(/.*)?$',
|
matchPattern: '^/lms/community(/.*)?$',
|
||||||
show: (currentOrg) => currentOrg?.customization?.dashboard?.community === true,
|
show: (currentOrg) => currentOrg?.customization?.dashboard?.community === true,
|
||||||
supportsDynamicSegment: true, // Supports /community/[slug]
|
supportsDynamicSegment: true,
|
||||||
nestedRoutes: [
|
nestedRoutes: [
|
||||||
{
|
{
|
||||||
path: 'ask',
|
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',
|
titleKey: 'lms_navigation.settings',
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
|
|||||||
@@ -2201,6 +2201,38 @@
|
|||||||
"no_lesson": "No lessons yet",
|
"no_lesson": "No lessons yet",
|
||||||
"content_locked_title": "This content is locked",
|
"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_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.",
|
"share_your_knowledge": "Share your knowledge with the world by creating engaging lessons. Start by clicking on the Add button.",
|
||||||
"section_prompt": {
|
"section_prompt": {
|
||||||
"cta": "Enable Sections",
|
"cta": "Enable Sections",
|
||||||
@@ -2297,6 +2329,9 @@
|
|||||||
"slide_link": "Slide Link",
|
"slide_link": "Slide Link",
|
||||||
"helper_message": "You can embed Google Slides or Canva Presentation"
|
"helper_message": "You can embed Google Slides or Canva Presentation"
|
||||||
},
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings"
|
||||||
|
},
|
||||||
"video": {
|
"video": {
|
||||||
"title": "Video",
|
"title": "Video",
|
||||||
"embed_link": "Embed Link",
|
"embed_link": "Embed Link",
|
||||||
@@ -2362,13 +2397,15 @@
|
|||||||
"generate_1080_unavailable": "1080p unavailable for this source video",
|
"generate_1080_unavailable": "1080p unavailable for this source video",
|
||||||
"playback_error": "Loading this video failed.",
|
"playback_error": "Loading this video failed.",
|
||||||
"playback_reload": "Reload video",
|
"playback_reload": "Reload video",
|
||||||
|
"loading": "Loading video…",
|
||||||
"simple_card": {
|
"simple_card": {
|
||||||
"kind_youtube": "YouTube",
|
"kind_youtube": "YouTube",
|
||||||
"kind_upload": "Upload",
|
"kind_upload": "Upload",
|
||||||
"kind_google_drive": "Google Drive",
|
"kind_google_drive": "Google Drive",
|
||||||
"kind_generic": "Embedded",
|
"kind_generic": "Embedded",
|
||||||
"course_fallback": "Course",
|
"course_fallback": "Course",
|
||||||
"menu_aria": "Video options"
|
"menu_aria": "Video options",
|
||||||
|
"transcription_in_progress": "(Transcript generation in progress)"
|
||||||
},
|
},
|
||||||
"transcript": {
|
"transcript": {
|
||||||
"title": "Transcript",
|
"title": "Transcript",
|
||||||
@@ -2471,6 +2508,10 @@
|
|||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"settings_allow_multiple": "Allow multiple attempts",
|
"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_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",
|
"settings_save": "Save settings",
|
||||||
"submissions": "Submissions",
|
"submissions": "Submissions",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
@@ -3297,6 +3338,13 @@
|
|||||||
"signatory_one_role": "Signatory 1 role",
|
"signatory_one_role": "Signatory 1 role",
|
||||||
"signatory_two_name": "Signatory 2 name",
|
"signatory_two_name": "Signatory 2 name",
|
||||||
"signatory_two_role": "Signatory 2 role",
|
"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": "Certificate ID format",
|
||||||
"id_format_hint": "Use '{'seq'}' for the last four digits, '{'year'}' for the issue year, '{'month'}' for the month.",
|
"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.",
|
"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",
|
"drag": "Drag and drop the labels to re-order your material tabs",
|
||||||
"content_grouping_title": "Content grouping",
|
"content_grouping_title": "Content grouping",
|
||||||
"content_grouping_description": "Group lessons and exercises by section in the content list.",
|
"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",
|
"lesson_download": "Lessons DownLoad",
|
||||||
"available": "Make the Lesson available for Download in pdf for registered students",
|
"available": "Make the Lesson available for Download in pdf for registered students",
|
||||||
"course_download": "Course Downlaod",
|
"course_download": "Course Downlaod",
|
||||||
@@ -3985,6 +4037,7 @@
|
|||||||
"thumbnail_regen_failed": "Failed to regenerate thumbnails",
|
"thumbnail_regen_failed": "Failed to regenerate thumbnails",
|
||||||
"transcription_started": "Transcription started",
|
"transcription_started": "Transcription started",
|
||||||
"transcription_start_failed": "Could not start transcription",
|
"transcription_start_failed": "Could not start transcription",
|
||||||
|
"transcription_already_running": "A transcription job is already in progress for this video",
|
||||||
"transcription_completed": "Transcription finished",
|
"transcription_completed": "Transcription finished",
|
||||||
"transcript_fetch_failed": "Could not load transcript",
|
"transcript_fetch_failed": "Could not load transcript",
|
||||||
"transcript_saved": "Transcript updated",
|
"transcript_saved": "Transcript updated",
|
||||||
|
|||||||
@@ -75,6 +75,8 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let navigatingAway = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = data.token ? { inviteToken: data.token } : {};
|
const body = data.token ? { inviteToken: data.token } : {};
|
||||||
const result = await courseApi.enroll(data.course!.id, body);
|
const result = await courseApi.enroll(data.course!.id, body);
|
||||||
@@ -90,10 +92,11 @@
|
|||||||
already_joined: result.data.alreadyJoined
|
already_joined: result.data.alreadyJoined
|
||||||
});
|
});
|
||||||
|
|
||||||
|
navigatingAway = true;
|
||||||
// need to force page reload to avoid cache issues
|
// need to force page reload to avoid cache issues
|
||||||
window.location.href = result.data.redirectTo || '/lms';
|
window.location.href = result.data.redirectTo || '/lms';
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
if (!navigatingAway) loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1023,6 +1023,12 @@ interface GetExploreCoursesOptions {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
profileId: string;
|
profileId: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
page?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GetExploreCoursesResult {
|
||||||
|
data: TBaseCourse[];
|
||||||
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1031,25 +1037,40 @@ interface GetExploreCoursesOptions {
|
|||||||
* @param options.orgId Organization ID
|
* @param options.orgId Organization ID
|
||||||
* @param options.profileId Profile ID to exclude enrolled courses
|
* @param options.profileId Profile ID to exclude enrolled courses
|
||||||
* @param options.limit Optional max number of courses to return
|
* @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 ({
|
export const getExploreCourses = async ({
|
||||||
orgId,
|
orgId,
|
||||||
profileId,
|
profileId,
|
||||||
limit
|
limit,
|
||||||
}: GetExploreCoursesOptions): Promise<TBaseCourse[]> => {
|
page = 1
|
||||||
|
}: GetExploreCoursesOptions): Promise<GetExploreCoursesResult> => {
|
||||||
try {
|
try {
|
||||||
const baseQuery = db
|
const whereCondition = and(
|
||||||
.select({
|
eq(schema.group.organizationId, orgId),
|
||||||
course: schema.course,
|
eq(schema.course.status, 'ACTIVE'),
|
||||||
lessonCount: sql<number>`COUNT(DISTINCT ${schema.lesson.id})`.as('total_lessons'),
|
eq(schema.course.isPublished, true),
|
||||||
exerciseCount: sql<number>`(
|
isNull(schema.groupmember.id),
|
||||||
SELECT COUNT(*)::bigint
|
or(
|
||||||
FROM ${schema.exercise} as ex
|
sql`${schema.course.metadata}->>'allowNewStudent' IS NULL`,
|
||||||
LEFT JOIN ${schema.lesson} as el ON el.id = ex.lesson_id
|
sql`${schema.course.metadata}->>'allowNewStudent' != 'false'`
|
||||||
WHERE ${or(eq(sql`ex.course_id`, schema.course.id), eq(sql`el.course_id`, schema.course.id))}
|
)
|
||||||
)`.as('exercise_count')
|
);
|
||||||
})
|
|
||||||
|
const courseSelect = {
|
||||||
|
course: schema.course,
|
||||||
|
lessonCount: sql<number>`COUNT(DISTINCT ${schema.lesson.id})`.as('total_lessons'),
|
||||||
|
exerciseCount: sql<number>`(
|
||||||
|
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)
|
.from(schema.course)
|
||||||
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
|
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
|
||||||
.leftJoin(schema.lesson, eq(schema.course.id, schema.lesson.courseId))
|
.leftJoin(schema.lesson, eq(schema.course.id, schema.lesson.courseId))
|
||||||
@@ -1057,24 +1078,33 @@ export const getExploreCourses = async ({
|
|||||||
schema.groupmember,
|
schema.groupmember,
|
||||||
and(eq(schema.groupmember.groupId, schema.course.groupId), eq(schema.groupmember.profileId, profileId))
|
and(eq(schema.groupmember.groupId, schema.course.groupId), eq(schema.groupmember.profileId, profileId))
|
||||||
)
|
)
|
||||||
.where(
|
.where(whereCondition)
|
||||||
and(
|
|
||||||
eq(schema.group.organizationId, orgId),
|
|
||||||
eq(schema.course.status, 'ACTIVE'),
|
|
||||||
eq(schema.course.isPublished, true),
|
|
||||||
isNull(schema.groupmember.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.groupBy(schema.course.id, schema.group.id)
|
.groupBy(schema.course.id, schema.group.id)
|
||||||
.orderBy(desc(schema.course.createdAt));
|
.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) => ({
|
const [countRows, rows] = await Promise.all([
|
||||||
...row.course,
|
countSelect,
|
||||||
lessonCount: Number(row.lessonCount),
|
limit ? baseSelect.limit(limit).offset((page - 1) * limit) : baseSelect
|
||||||
exerciseCount: Number(row.exerciseCount)
|
]);
|
||||||
}));
|
|
||||||
|
return {
|
||||||
|
data: rows.map((row) => ({
|
||||||
|
...row.course,
|
||||||
|
lessonCount: Number(row.lessonCount),
|
||||||
|
exerciseCount: Number(row.exerciseCount)
|
||||||
|
})),
|
||||||
|
total: Number(countRows[0]?.total ?? 0)
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('getExploreCourses error:', error);
|
console.error('getExploreCourses error:', error);
|
||||||
throw new Error(`Failed to get explore courses: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
throw new Error(`Failed to get explore courses: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { ALLOWED_CONTENT_TYPES, ALLOWED_DOCUMENT_TYPES } from '../constants';
|
|||||||
import { ZCourseCalloutInput } from './callout';
|
import { ZCourseCalloutInput } from './callout';
|
||||||
|
|
||||||
export const ZGetRecommendedCourses = z.object({
|
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<typeof ZGetRecommendedCourses>;
|
export type TGetRecommendedCourses = z.infer<typeof ZGetRecommendedCourses>;
|
||||||
|
|
||||||
@@ -57,7 +58,9 @@ export type TCourseDownloadParam = z.infer<typeof ZCourseDownloadParam>;
|
|||||||
*/
|
*/
|
||||||
export const ZCertificateSignatory = z.object({
|
export const ZCertificateSignatory = z.object({
|
||||||
name: z.string().max(80).default(''),
|
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<typeof ZCertificateSignatory>;
|
export type TCertificateSignatory = z.infer<typeof ZCertificateSignatory>;
|
||||||
|
|
||||||
@@ -82,7 +85,14 @@ export type TCertificateDesign = z.infer<typeof ZCertificateDesign>;
|
|||||||
export const ZCertificateDownloadRequest = z.object({
|
export const ZCertificateDownloadRequest = z.object({
|
||||||
studentName: z.string().min(1).max(120),
|
studentName: z.string().min(1).max(120),
|
||||||
studentId: z.string().min(1).max(64).optional(),
|
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). */
|
/** When true, skips the eligibility check (course owner previewing their own design). */
|
||||||
previewMode: z.boolean().optional()
|
previewMode: z.boolean().optional()
|
||||||
});
|
});
|
||||||
@@ -355,7 +365,8 @@ const ZCourseMetadataFields = z.object({
|
|||||||
lessonDownload: z.boolean().optional(),
|
lessonDownload: z.boolean().optional(),
|
||||||
allowNewStudent: z.boolean(),
|
allowNewStudent: z.boolean(),
|
||||||
sectionDisplay: z.record(z.string(), z.boolean()).optional(),
|
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
|
// Course metadata schema matching the database structure
|
||||||
|
|||||||
Reference in New Issue
Block a user