feat: make explore more visible in lms

This commit is contained in:
Rotimi Best
2026-06-10 09:18:20 +01:00
parent 02ae9b81b8
commit dcb5cfbc7b
11 changed files with 223 additions and 27 deletions
@@ -74,6 +74,7 @@ import { searchRouter } from '@api/routes/organization/search';
import { tagsRouter } from '@api/routes/organization/tags'; import { tagsRouter } from '@api/routes/organization/tags';
import { widgetsRouter } from '@api/routes/organization/widgets'; import { widgetsRouter } from '@api/routes/organization/widgets';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
import { ZGetRecommendedCourses } from '@cio/utils/validation/course';
export const organizationRouter = new Hono() export const organizationRouter = new Hono()
/** /**
@@ -433,24 +434,31 @@ export const organizationRouter = new Hono()
* Gets recommended courses (published courses user isn't enrolled in) for a user in an organization (used in lms) * Gets recommended courses (published courses user isn't enrolled in) for a user in an organization (used in lms)
* Requires authentication and organization membership * Requires authentication and organization membership
*/ */
.get('/courses/recommended', authMiddleware, orgMemberMiddleware, async (c) => { .get(
try { '/courses/recommended',
const user = c.get('user')!; authMiddleware,
orgMemberMiddleware,
zValidator('query', ZGetRecommendedCourses),
async (c) => {
try {
const user = c.get('user')!;
const { limit } = 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); const result = await getRecommendedCourses(orgId, user.id, limit);
return c.json( return c.json(
{ {
success: true, success: true,
data: result data: result
}, },
200 200
); );
} catch (error) { } catch (error) {
return handleError(c, error, 'Failed to fetch recommended courses'); return handleError(c, error, 'Failed to fetch recommended courses');
}
} }
}) )
/** /**
* GET /organization/courses * GET /organization/courses
* Gets courses for an organization with role-based filtering (used in dashboard) * Gets courses for an organization with role-based filtering (used in dashboard)
+2 -2
View File
@@ -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) { export async function getRecommendedCourses(orgId: string, userId: string, limit?: number) {
try { try {
return getExploreCourses({ orgId, profileId: userId }); return getExploreCourses({ orgId, profileId: userId, limit });
} catch (error) { } catch (error) {
if (error instanceof AppError) throw error; if (error instanceof AppError) throw error;
throw new AppError( throw new AppError(
@@ -135,9 +135,12 @@ 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() { async getRecommendedCourses(options?: { limit?: number }) {
await this.execute<GetRecommendedCoursesRequest>({ await this.execute<GetRecommendedCoursesRequest>({
requestFn: () => classroomio.organization.courses.recommended.$get({}), requestFn: () =>
classroomio.organization.courses.recommended.$get({
query: options?.limit ? { limit: String(options.limit) } : {}
}),
logContext: 'fetching recommended courses', logContext: 'fetching recommended courses',
onSuccess: (response) => { onSuccess: (response) => {
if (response.data) { if (response.data) {
@@ -2,15 +2,16 @@
import * as Item from '@cio/ui/base/item'; import * as Item from '@cio/ui/base/item';
import Card, { type Props as CardProps } from './card.svelte'; import Card, { type Props as CardProps } from './card.svelte';
type Props = Omit<CardProps, 'course'> & { type Props = Omit<CardProps, 'course' | 'onExploreClick'> & {
courses: CardProps['course'][]; courses: CardProps['course'][];
onCardClick?: (course: CardProps['course']) => void;
}; };
let { courses, ...rest }: Props = $props(); let { courses, onCardClick, ...rest }: Props = $props();
</script> </script>
<Item.Group class="grid! w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"> <Item.Group class="grid! w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{#each courses as course (course.id)} {#each courses as course (course.id)}
<Card {course} {...rest} /> <Card {course} {...rest} onExploreClick={onCardClick ? () => onCardClick(course) : undefined} />
{/each} {/each}
</Item.Group> </Item.Group>
@@ -38,9 +38,10 @@
isExplore?: boolean; isExplore?: boolean;
href?: string; href?: string;
actions?: Snippet; actions?: Snippet;
onExploreClick?: () => void;
} }
let { course, isOnLandingPage, isLMS, isExplore, href, actions }: Props = $props(); let { course, isOnLandingPage, isLMS, isExplore, href, actions, onExploreClick }: Props = $props();
let { let {
bannerImage, bannerImage,
@@ -141,6 +142,10 @@
let cost = $derived(calcCourseDiscount(pricingData.discount, pricingData.cost ?? 0, !!pricingData.showDiscount)); let cost = $derived(calcCourseDiscount(pricingData.discount, pricingData.cost ?? 0, !!pricingData.showDiscount));
let courseUrl = $derived.by(() => { let courseUrl = $derived.by(() => {
if (onExploreClick && isLMS && isExplore) {
return undefined;
}
if (href) { if (href) {
return href; return href;
} }
@@ -299,7 +304,7 @@
</div> </div>
{#if isLMS} {#if isLMS}
<Button variant="outline"> <Button variant="outline" onclick={isExplore && onExploreClick ? onExploreClick : undefined}>
{isExplore ? $t('courses.course_card.learn_more') : $t('courses.course_card.continue_course')} {isExplore ? $t('courses.course_card.learn_more') : $t('courses.course_card.continue_course')}
<ArrowRightIcon class="custom" /> <ArrowRightIcon class="custom" />
@@ -38,6 +38,8 @@
isLoading?: boolean; isLoading?: boolean;
showSortSelect?: boolean; showSortSelect?: boolean;
filterControls?: Snippet; filterControls?: Snippet;
onCardClick?: (course: (OrgCourses | UserEnrolledCourses)[number]) => void;
emptyAction?: Snippet;
} }
let { let {
@@ -50,7 +52,9 @@
sortKey = $bindable(DEFAULT_COURSE_SORT), sortKey = $bindable(DEFAULT_COURSE_SORT),
isLoading = false, isLoading = false,
showSortSelect = true, showSortSelect = true,
filterControls filterControls,
onCardClick,
emptyAction
}: Props = $props(); }: Props = $props();
const filterOptions = $derived( const filterOptions = $derived(
@@ -123,10 +127,12 @@
<Empty title={emptyTitle} description={emptyDescription} icon={LibraryBigIcon} variant="page"> <Empty title={emptyTitle} description={emptyDescription} icon={LibraryBigIcon} variant="page">
{#if !isLMS} {#if !isLMS}
<CreateCourseButton isResponsive /> <CreateCourseButton isResponsive />
{:else if emptyAction}
{@render emptyAction()}
{/if} {/if}
</Empty> </Empty>
{:else if isLMS || $courseMetaDeta.view === 'grid'} {:else if isLMS || $courseMetaDeta.view === 'grid'}
<CourseCardList {courses} {isExplore} {isLMS} /> <CourseCardList {courses} {isExplore} {isLMS} {onCardClick} />
{:else} {:else}
<ResourceListRow.Group> <ResourceListRow.Group>
{#each courses as courseData (courseData.id)} {#each courses as courseData (courseData.id)}
@@ -0,0 +1,108 @@
<script lang="ts">
import { goto } from '$app/navigation';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { DEFAULT_COURSE_BANNER_IMAGE } from '@cio/ui';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import { t } from '$lib/utils/functions/translations';
import { calcCourseDiscount, isCourseFree } from '$lib/utils/functions/course';
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
import type { RecommendedCourses } from '$features/course/types';
import pluralize from 'pluralize';
interface Props {
course: RecommendedCourses[number];
open: boolean;
}
let { course, open = $bindable(false) }: Props = $props();
type CourseMetadata = {
allowNewStudent?: boolean;
discount?: number;
showDiscount?: boolean;
requirements?: string;
};
const metadata = $derived(course.metadata as CourseMetadata | null);
const discount = $derived(metadata?.discount ?? 0);
const calculatedCost = $derived(calcCourseDiscount(discount, course.cost || 0, !!metadata?.showDiscount));
const isFree = $derived(isCourseFree(calculatedCost));
const allowNewStudent = $derived(metadata?.allowNewStudent !== false);
const requirements = $derived(metadata?.requirements?.trim() || null);
const formatter = $derived(getCurrencyFormatter(course.currency ?? 'USD'));
function handleJoinCourse() {
if (!course.slug) return;
if (isFree) {
goto(`/course/${course.slug}/enroll`);
} else {
goto(`/course/${course.slug}`);
}
}
</script>
<Dialog.Root bind:open>
<Dialog.Content class="overflow-hidden p-0 sm:max-w-2xl">
<div class="aspect-video w-full overflow-hidden">
<img src={course.logo || DEFAULT_COURSE_BANNER_IMAGE} alt={course.title} class="h-full w-full object-cover" />
</div>
<div class="max-h-80 overflow-y-auto px-6 py-4">
<h2 class="text-xl font-semibold tracking-tight">{course.title}</h2>
{#if course.description}
<p class="ui:text-muted-foreground mt-2 text-sm leading-relaxed">{course.description}</p>
{/if}
<div class="ui:text-muted-foreground mt-4 flex items-center gap-4 text-sm">
<span class="flex items-center gap-1.5">
<BookOpenIcon class="size-4" />
{course.lessonCount ?? 0}
{$t('courses.course_card.lessons_number')}
</span>
<span>
{pluralize($t('courses.course_card.exercise'), course.exerciseCount ?? 0, true)}
</span>
</div>
{#if requirements}
<div class="mt-4">
<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>
</div>
{/if}
</div>
<div class="ui:border-t ui:bg-card sticky bottom-0 flex items-center justify-between px-6 py-4">
<div>
{#if allowNewStudent}
<p class="text-lg font-bold">
{formatter.format(calculatedCost)}
{#if isFree}
<span class="text-sm font-normal">
({$t('course.navItem.landing_page.pricing_section.free')})
</span>
{/if}
</p>
{#if metadata?.showDiscount && !isFree}
<p class="ui:text-muted-foreground text-sm line-through">
{formatter.format(course.cost ?? 0)}
</p>
{/if}
{:else}
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.landing_page.pricing_section.not_accepting')}
</p>
{/if}
</div>
<Button onclick={handleJoinCourse} disabled={!allowNewStudent}>
{isFree
? $t('course.navItem.landing_page.pricing_section.enroll')
: $t('course.navItem.landing_page.pricing_section.buy')}
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -17,7 +17,10 @@
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 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 type { RecommendedCourses } from '$features/course/types';
import { IconButton } from '@cio/ui/custom/icon-button'; import { IconButton } from '@cio/ui/custom/icon-button';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'; import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import CopyIcon from '@lucide/svelte/icons/copy'; import CopyIcon from '@lucide/svelte/icons/copy';
@@ -34,6 +37,8 @@
let loginStreak = $state<number | null>(null); let loginStreak = $state<number | null>(null);
let hasLoadedLoginStreak = $state(false); let hasLoadedLoginStreak = $state(false);
let selectedCourse = $state<RecommendedCourses[number] | null>(null);
let previewOpen = $state(false);
let totalCompleted = $derived( let totalCompleted = $derived(
coursesApi.enrolledCourses.reduce((acc, course) => acc + getCourseCompletedItems(course), 0) coursesApi.enrolledCourses.reduce((acc, course) => acc + getCourseCompletedItems(course), 0)
@@ -58,6 +63,7 @@
if (!$profile.id || !$currentOrg.id) return; if (!$profile.id || !$currentOrg.id) return;
coursesApi.getEnrolledCourses(); coursesApi.getEnrolledCourses();
coursesApi.getRecommendedCourses({ limit: 6 });
}); });
$effect(() => { $effect(() => {
@@ -395,4 +401,41 @@
{/if} {/if}
</section> </section>
</div> </div>
{#if coursesApi.isLoading || coursesApi.recommendedCourses.length > 0}
<section class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="ui:text-muted-foreground text-sm font-semibold tracking-[0.14em] uppercase">
{$t('dashboard.explore_more_courses')}
</h2>
<Button variant="outline" size="sm" onclick={() => goto('/lms/explore')}>
{$t('dashboard.view_more')}
</Button>
</div>
{#if coursesApi.isLoading}
<div class="ui:text-muted-foreground flex items-center justify-center py-8">
<Spinner class="size-6" />
</div>
{:else}
<div class="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{#each coursesApi.recommendedCourses as course (course.id)}
<CourseCard
{course}
isExplore={true}
isLMS={true}
onExploreClick={() => {
selectedCourse = course;
previewOpen = true;
}}
/>
{/each}
</div>
{/if}
</section>
{/if}
{#if selectedCourse}
<CoursePreviewModal course={selectedCourse} bind:open={previewOpen} />
{/if}
</div> </div>
@@ -9,9 +9,12 @@
import { coursesApi } from '$features/course/api'; import { coursesApi } from '$features/course/api';
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';
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 previewOpen = $state(false);
$effect(() => { $effect(() => {
if (!browser) return; if (!browser) return;
@@ -67,4 +70,12 @@
isLoading={coursesApi.isLoading} isLoading={coursesApi.isLoading}
bind:searchValue bind:searchValue
bind:sortKey bind:sortKey
onCardClick={(course) => {
selectedCourse = course as RecommendedCourses[number];
previewOpen = true;
}}
/> />
{#if selectedCourse}
<CoursePreviewModal course={selectedCourse} bind:open={previewOpen} />
{/if}
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs'; import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
import { Button } from '@cio/ui/base/button';
import { CoursesPage } from '$features/course/pages'; import { CoursesPage } from '$features/course/pages';
import { profile } from '$lib/utils/store/user'; import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
@@ -51,7 +52,11 @@
emptyTitle={$t('my_learning.not_in_progress')} emptyTitle={$t('my_learning.not_in_progress')}
isLMS={true} isLMS={true}
isLoading={coursesApi.isLoading} isLoading={coursesApi.isLoading}
/> >
{#snippet emptyAction()}
<Button href="/lms/explore">{$t('my_learning.find_courses')}</Button>
{/snippet}
</CoursesPage>
</UnderlineTabs.Content> </UnderlineTabs.Content>
<UnderlineTabs.Content value={tabs[1].value}> <UnderlineTabs.Content value={tabs[1].value}>
<CoursesPage <CoursesPage
@@ -3,6 +3,11 @@ import * as z from 'zod';
import { ALLOWED_CONTENT_TYPES, ALLOWED_DOCUMENT_TYPES } from '../constants'; import { ALLOWED_CONTENT_TYPES, ALLOWED_DOCUMENT_TYPES } from '../constants';
import { ZCourseCalloutInput } from './callout'; import { ZCourseCalloutInput } from './callout';
export const ZGetRecommendedCourses = z.object({
limit: z.string().transform(Number).pipe(z.number().min(1).max(50)).optional()
});
export type TGetRecommendedCourses = z.infer<typeof ZGetRecommendedCourses>;
export const ZCourseClone = z.object({ export const ZCourseClone = z.object({
title: z.string().min(1), title: z.string().min(1),
description: z.string().optional(), description: z.string().optional(),
@@ -392,6 +397,7 @@ export const ZCourseUpdateBase = z.object({
logo: z.string().optional(), logo: z.string().optional(),
slug: z.string().optional(), slug: z.string().optional(),
isPublished: z.boolean().optional(), isPublished: z.boolean().optional(),
orgOnly: z.boolean().optional(),
overview: z.string().optional(), overview: z.string().optional(),
metadata: ZCourseMetadata.optional(), metadata: ZCourseMetadata.optional(),
cost: z.number().int().min(0).optional(), cost: z.number().int().min(0).optional(),