feat: make explore more visible in lms
This commit is contained in:
@@ -74,6 +74,7 @@ import { searchRouter } from '@api/routes/organization/search';
|
||||
import { tagsRouter } from '@api/routes/organization/tags';
|
||||
import { widgetsRouter } from '@api/routes/organization/widgets';
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { ZGetRecommendedCourses } from '@cio/utils/validation/course';
|
||||
|
||||
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)
|
||||
* Requires authentication and organization membership
|
||||
*/
|
||||
.get('/courses/recommended', authMiddleware, orgMemberMiddleware, async (c) => {
|
||||
try {
|
||||
const user = c.get('user')!;
|
||||
.get(
|
||||
'/courses/recommended',
|
||||
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 result = await getRecommendedCourses(orgId, user.id);
|
||||
const orgId = c.req.header('cio-org-id')!;
|
||||
const result = await getRecommendedCourses(orgId, user.id, limit);
|
||||
|
||||
return c.json(
|
||||
{
|
||||
success: true,
|
||||
data: result
|
||||
},
|
||||
200
|
||||
);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to fetch recommended courses');
|
||||
return c.json(
|
||||
{
|
||||
success: true,
|
||||
data: result
|
||||
},
|
||||
200
|
||||
);
|
||||
} catch (error) {
|
||||
return handleError(c, error, 'Failed to fetch recommended courses');
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
/**
|
||||
* GET /organization/courses
|
||||
* Gets courses for an organization with role-based filtering (used in dashboard)
|
||||
|
||||
@@ -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) {
|
||||
export async function getRecommendedCourses(orgId: string, userId: string, limit?: number) {
|
||||
try {
|
||||
return getExploreCourses({ orgId, profileId: userId });
|
||||
return getExploreCourses({ orgId, profileId: userId, limit });
|
||||
} catch (error) {
|
||||
if (error instanceof AppError) throw error;
|
||||
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
|
||||
* Org ID is automatically added from currentOrg store
|
||||
*/
|
||||
async getRecommendedCourses() {
|
||||
async getRecommendedCourses(options?: { limit?: number }) {
|
||||
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',
|
||||
onSuccess: (response) => {
|
||||
if (response.data) {
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import Card, { type Props as CardProps } from './card.svelte';
|
||||
|
||||
type Props = Omit<CardProps, 'course'> & {
|
||||
type Props = Omit<CardProps, 'course' | 'onExploreClick'> & {
|
||||
courses: CardProps['course'][];
|
||||
onCardClick?: (course: CardProps['course']) => void;
|
||||
};
|
||||
|
||||
let { courses, ...rest }: Props = $props();
|
||||
let { courses, onCardClick, ...rest }: Props = $props();
|
||||
</script>
|
||||
|
||||
<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)}
|
||||
<Card {course} {...rest} />
|
||||
<Card {course} {...rest} onExploreClick={onCardClick ? () => onCardClick(course) : undefined} />
|
||||
{/each}
|
||||
</Item.Group>
|
||||
|
||||
@@ -38,9 +38,10 @@
|
||||
isExplore?: boolean;
|
||||
href?: string;
|
||||
actions?: Snippet;
|
||||
onExploreClick?: () => void;
|
||||
}
|
||||
|
||||
let { course, isOnLandingPage, isLMS, isExplore, href, actions }: Props = $props();
|
||||
let { course, isOnLandingPage, isLMS, isExplore, href, actions, onExploreClick }: Props = $props();
|
||||
|
||||
let {
|
||||
bannerImage,
|
||||
@@ -141,6 +142,10 @@
|
||||
let cost = $derived(calcCourseDiscount(pricingData.discount, pricingData.cost ?? 0, !!pricingData.showDiscount));
|
||||
|
||||
let courseUrl = $derived.by(() => {
|
||||
if (onExploreClick && isLMS && isExplore) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (href) {
|
||||
return href;
|
||||
}
|
||||
@@ -299,7 +304,7 @@
|
||||
</div>
|
||||
|
||||
{#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')}
|
||||
|
||||
<ArrowRightIcon class="custom" />
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
isLoading?: boolean;
|
||||
showSortSelect?: boolean;
|
||||
filterControls?: Snippet;
|
||||
onCardClick?: (course: (OrgCourses | UserEnrolledCourses)[number]) => void;
|
||||
emptyAction?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -50,7 +52,9 @@
|
||||
sortKey = $bindable(DEFAULT_COURSE_SORT),
|
||||
isLoading = false,
|
||||
showSortSelect = true,
|
||||
filterControls
|
||||
filterControls,
|
||||
onCardClick,
|
||||
emptyAction
|
||||
}: Props = $props();
|
||||
|
||||
const filterOptions = $derived(
|
||||
@@ -123,10 +127,12 @@
|
||||
<Empty title={emptyTitle} description={emptyDescription} icon={LibraryBigIcon} variant="page">
|
||||
{#if !isLMS}
|
||||
<CreateCourseButton isResponsive />
|
||||
{:else if emptyAction}
|
||||
{@render emptyAction()}
|
||||
{/if}
|
||||
</Empty>
|
||||
{:else if isLMS || $courseMetaDeta.view === 'grid'}
|
||||
<CourseCardList {courses} {isExplore} {isLMS} />
|
||||
<CourseCardList {courses} {isExplore} {isLMS} {onCardClick} />
|
||||
{:else}
|
||||
<ResourceListRow.Group>
|
||||
{#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 { profile } from '$lib/utils/store/user';
|
||||
import { coursesApi } from '$features/course/api';
|
||||
import { CourseCard } 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';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||
@@ -34,6 +37,8 @@
|
||||
|
||||
let loginStreak = $state<number | null>(null);
|
||||
let hasLoadedLoginStreak = $state(false);
|
||||
let selectedCourse = $state<RecommendedCourses[number] | null>(null);
|
||||
let previewOpen = $state(false);
|
||||
|
||||
let totalCompleted = $derived(
|
||||
coursesApi.enrolledCourses.reduce((acc, course) => acc + getCourseCompletedItems(course), 0)
|
||||
@@ -58,6 +63,7 @@
|
||||
if (!$profile.id || !$currentOrg.id) return;
|
||||
|
||||
coursesApi.getEnrolledCourses();
|
||||
coursesApi.getRecommendedCourses({ limit: 6 });
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -395,4 +401,41 @@
|
||||
{/if}
|
||||
</section>
|
||||
</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>
|
||||
|
||||
@@ -9,9 +9,12 @@
|
||||
import { coursesApi } from '$features/course/api';
|
||||
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';
|
||||
|
||||
let searchValue = $state('');
|
||||
let sortKey = $state<CourseSortBy>(DEFAULT_COURSE_SORT);
|
||||
let selectedCourse = $state<RecommendedCourses[number] | null>(null);
|
||||
let previewOpen = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
@@ -67,4 +70,12 @@
|
||||
isLoading={coursesApi.isLoading}
|
||||
bind:searchValue
|
||||
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">
|
||||
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { CoursesPage } from '$features/course/pages';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
@@ -51,7 +52,11 @@
|
||||
emptyTitle={$t('my_learning.not_in_progress')}
|
||||
isLMS={true}
|
||||
isLoading={coursesApi.isLoading}
|
||||
/>
|
||||
>
|
||||
{#snippet emptyAction()}
|
||||
<Button href="/lms/explore">{$t('my_learning.find_courses')}</Button>
|
||||
{/snippet}
|
||||
</CoursesPage>
|
||||
</UnderlineTabs.Content>
|
||||
<UnderlineTabs.Content value={tabs[1].value}>
|
||||
<CoursesPage
|
||||
|
||||
@@ -3,6 +3,11 @@ import * as z from 'zod';
|
||||
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()
|
||||
});
|
||||
export type TGetRecommendedCourses = z.infer<typeof ZGetRecommendedCourses>;
|
||||
|
||||
export const ZCourseClone = z.object({
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
@@ -392,6 +397,7 @@ export const ZCourseUpdateBase = z.object({
|
||||
logo: z.string().optional(),
|
||||
slug: z.string().optional(),
|
||||
isPublished: z.boolean().optional(),
|
||||
orgOnly: z.boolean().optional(),
|
||||
overview: z.string().optional(),
|
||||
metadata: ZCourseMetadata.optional(),
|
||||
cost: z.number().int().min(0).optional(),
|
||||
|
||||
Reference in New Issue
Block a user