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 { 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)
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user