feat: course types - self paced and live class (#309)

* working on the course type

* trying to mark it persist

* almost done here

* fixed sendig emtpy options bug

* created migration file

* created migration file

* made updates based on PR review

* merge

* update course type card

* add redirect logic with classroomio.com/new

* make new course modal only open/close with url

* feat: page active state (#315)

* finished the active link state

* updated the active link state based on review

* simplify code

---------

Co-authored-by: Rotimi Best <[email protected]>

* add type to course card

---------

Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Em_Dev
2024-05-21 00:14:47 +01:00
committed by GitHub
co-authored by Rotimi Best
parent b9bb568f98
commit 49d8539be9
41 changed files with 894 additions and 393 deletions
@@ -112,11 +112,9 @@
disabled={$isFreePlan}
>
<span slot="labelA" style={$globalStore.isDark ? 'color: white' : 'color: #161616'}
>{$t('course.navItem.certificates.locked')}</span
>
<span slot="labelB" style="color: green"
>{$t('course.navItem.certificates.unlocked')}</span
>{$t('generic.locked')}</span
>
<span slot="labelB" style="color: green">{$t('generic.unlocked')}</span>
</Toggle>
</div>
</section>
@@ -1,140 +0,0 @@
<script>
import UnlockedIcon from 'carbon-icons-svelte/lib/Unlocked.svelte';
import LockedIcon from 'carbon-icons-svelte/lib/Locked.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import Select from '$lib/components/Form/Select.svelte';
import {
lessons,
handleSaveLesson
} from '$lib/components/Course/components/Lesson/store/lessons';
import { course, group } from '$lib/components/Course/store';
import TextChip from '$lib/components/Chip/Text.svelte';
import Modal from '$lib/components/Modal/index.svelte';
import { goto } from '$app/navigation';
import { handleAddLessonWidget } from '../Navigation/store';
import { t } from '$lib/utils/functions/translations';
export let isStudent = false;
let errors = {
title: ''
};
let lesson = {
id: null,
title: '',
profile: undefined,
call_url: undefined,
lesson_at: new Date(),
is_unlocked: false
};
function getLessonOrder(id = '', fallbackIndex) {
const index = fallbackIndex || $lessons.findIndex((lesson) => lesson.id === id);
if (index < 9) {
return '0' + (index + 1);
} else {
return index + 1;
}
}
const handleSave = async () => {
if (lesson.title.trim() === '') {
errors.title = 'title cannot be empty';
return;
}
const savedLesson = await handleSaveLesson(lesson, $course.id);
if (Array.isArray(savedLesson) && savedLesson[0]) {
const newLesson = savedLesson[0];
lesson.id = newLesson.id;
$lessons = [...$lessons, lesson];
goto('/courses/' + $course.id + '/lessons/' + lesson.id);
}
$handleAddLessonWidget.open = false;
};
</script>
<Modal
onClose={() => ($handleAddLessonWidget.open = false)}
bind:open={$handleAddLessonWidget.open}
width="w-[80%] md:w-[65%]"
containerClass="overflow-hidden"
modalHeading={$t('course.navItem.lessons.add_lesson.modal_heading')}
>
<div
class="sm:min-h-[245px] md:min-h-[100px] lg:min-h-[190px] relative m-auto rounded-md border-2 border-gray-200 dark:border-neutral-600 py-2 md:py-3 px-2 md:px-5 mb-2 md:mb-4 flex flex-wrap items-center dark:bg-neutral-800"
>
<div class="mr-5">
<TextChip
value={getLessonOrder('', $lessons.length)}
size="sm"
shape="rounded-full"
className="bg-primary-200 text-primary-600 text-xs"
/>
</div>
<div class="w-full md:w-4/5">
<div class="flex flex-row gap-1 md:gap-2">
<TextField
label={$t('course.navItem.lessons.add_lesson.lesson_title')}
bind:value={lesson.title}
autofocus={true}
className="flex-1 min-w-lg max-w-lg"
isRequired={true}
errorMessage={errors.title}
/>
<IconButton
disabled={isStudent}
toolTipProps={isStudent
? {}
: {
title: `${$t('course.navItem.lessons.add_lesson.click_to')} ${
lesson.is_unlocked
? $t('course.navItem.lessons.add_lesson.lock')
: $t('course.navItem.lessons.add_lesson.unlock')
}`,
direction: 'right'
}}
>
<button
on:click={() => {
lesson.is_unlocked = !lesson.is_unlocked;
handleSaveLesson(lesson, $course.id);
}}
class="p-1"
>
{#if lesson.is_unlocked}
<UnlockedIcon size={24} class="carbon-icon dark:text-white" />
{:else}
<LockedIcon size={24} class="carbon-icon dark:text-white" />
{/if}
</button>
</IconButton>
</div>
<div class="flex items-start justify-between flex-col lg:flex-row lg:items-center mt-2 w-4/5">
<div class="lg:mb-0">
<Select
bind:value={lesson.profile}
options={$group.tutors}
labelKey="fullname"
className="sm:my-1 w-[100%]"
/>
</div>
<div class="flex items-center lg:mb-0">
<input
type="date"
name="lesson-date-picker"
class="p-2 my-2 rounded-md sm:w-[179px] dark:bg-neutral-800 dark:text-white"
/>
</div>
<div class="flex items-center mb-3 lg:mb-0">
<TextField className="w-[179px]" placeholder="https://meet.google.com/mga-dsjs-fmb" />
</div>
</div>
</div>
</div>
<PrimaryButton label={$t('course.navItem.lessons.add_lesson.save')} onClick={handleSave} />
</Modal>
@@ -19,18 +19,18 @@
onClose={() => ($deleteConfirmation.open = false)}
bind:open={$deleteConfirmation.open}
width="w-96"
modalHeading={$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation')}
modalHeading={$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.title')}
>
<div>
<h1 class="dark:text-white text-lg">
{$t('course.navItem.lessons.exercises.all_exercises.sure')}
{$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.sure')}
</h1>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label={$t('course.navItem.lessons.exercises.all_exercises.no')}
label={$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.no')}
onClick={() => {
$deleteConfirmation.open = false;
onCancel();
@@ -39,7 +39,7 @@
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.CONTAINED}
label={$t('course.navItem.lessons.exercises.all_exercises.yes')}
label={$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.yes')}
onClick={() => {
onDelete();
$deleteConfirmation.open = false;
@@ -0,0 +1,13 @@
<script>
import { SkeletonText } from 'carbon-components-svelte';
</script>
<div class="space-y-6">
<SkeletonText width="100%" heading />
<div class="space-y-3">
<SkeletonText width="100%" heading />
<SkeletonText width="100%" heading />
<SkeletonText width="100%" heading />
<SkeletonText width="100%" heading />
</div>
</div>
@@ -17,7 +17,11 @@
import { getPropsForQuestion, filterOutDeleted } from './functions';
import { formatAnswers, getGroupMemberId } from '$lib/components/Course/function';
import { submitExercise } from '$lib/utils/services/courses';
import { fetchSubmission } from '$lib/utils/services/submissions';
import {
fetchSubmission,
updateQuestionAnswer,
updateSubmission
} from '$lib/utils/services/submissions';
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
import {
@@ -26,8 +30,9 @@
} from '$lib/utils/services/notification/notification';
import { lesson } from '../store/lessons';
import { browser } from '$app/environment';
import { CloseFilled } from 'carbon-icons-svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import { useCompletion } from 'ai/svelte';
import { COURSE_TYPE } from '$lib/utils/types';
import GradingLoader from './GradingLoader.svelte';
export let preview: boolean = false;
export let exerciseId = '';
@@ -40,6 +45,9 @@
let isLoadingAutoSavedData = false;
let alreadyCheckedAutoSavedData = false;
let gradedComment = true;
let isLoading = false;
let submissionResponse;
let submissionId;
function handleStart() {
$questionnaireMetaData.currentQuestionIndex += 1;
@@ -80,7 +88,89 @@
});
};
function onSubmit(id, value, moveToNextQuestion = false) {
const { input, handleSubmit, completion } = useCompletion({
api: '/api/completion/gradingprompt',
onFinish: async () => {
try {
const responseData = $completion.replace('```json', '').replace('```', '');
let aiResponses = [];
try {
// Parse the modified response data as JSON
aiResponses = JSON.parse(responseData);
} catch (error) {
console.error('Error parsing AI response', error);
}
$questionnaire.questions.forEach((question) => {
const { id, points, question_type_id } = question;
let questionAnswer = submissionResponse?.res?.data.find(
(answer) => answer.question_id === id
);
console.log('this is the question answer', questionAnswer);
if (question_type_id !== QUESTION_TYPE.TEXTAREA) {
const answer = $questionnaireMetaData.answers[question.name];
let score = points / answer.length;
$questionnaireMetaData.grades = {
...$questionnaireMetaData.grades,
[id]: score
};
updateQuestionAnswer({ point: score }, { id: questionAnswer?.id });
} else if (aiResponses.length) {
const graded = aiResponses.find((res) => res.id === id);
$questionnaireMetaData.grades = {
...$questionnaireMetaData.grades,
[id]: graded.score
};
updateQuestionAnswer({ point: graded.score }, { id: questionAnswer?.id });
}
});
$questionnaireMetaData.finalTotalGrade = Object.values(
$questionnaireMetaData.grades
).reduce((acc, answer): number => {
return acc + answer;
}, 0);
updateSubmission({
id: submissionId,
status_id: STATUS.GRADED,
total: $questionnaireMetaData.finalTotalGrade
});
$questionnaireMetaData.status = STATUS.GRADED;
console.log('grade', $questionnaireMetaData.finalTotalGrade);
} catch (error) {
console.error('Error', error);
} finally {
isLoading = false;
}
}
});
function automaticGrading(answers, questions) {
const paragraphAiInput = questions
.filter((q) => q.question_type_id === QUESTION_TYPE.TEXTAREA)
.map((q) => {
const answer = answers?.answers[q.name]; // { open_answer: '' }
return {
id: q.id,
question: q.title,
answer: answer,
point: q.points
};
});
$input = JSON.stringify(paragraphAiInput);
handleSubmit({ preventDefault: () => {} });
}
async function onSubmit(id, value, moveToNextQuestion = false) {
if ($course.type === COURSE_TYPE.SELF_PACED) {
isLoading = true;
}
const { answers } = $questionnaireMetaData;
const { questions } = $questionnaire;
const prevAnswer = answers[id] || [];
@@ -114,14 +204,24 @@
$questionnaireMetaData.status = 1;
$questionnaireMetaData.totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions);
$questionnaireMetaData.grades = {};
($questionnaireMetaData.comment = ''),
submitExercise(
$questionnaireMetaData.answers,
questions,
exerciseId,
$course.id,
getGroupMemberId($group.people, $profile.id)
);
$questionnaireMetaData.comment = '';
let response = await submitExercise(
$questionnaireMetaData.answers,
questions,
exerciseId,
$course.id,
getGroupMemberId($group.people, $profile.id)
);
if (response) {
submissionResponse = response;
submissionId = submissionResponse.submission[0]?.id;
}
if ($course.type === COURSE_TYPE.SELF_PACED) {
automaticGrading($questionnaireMetaData, $questionnaire.questions);
}
notifyEducator();
}
}
@@ -291,7 +391,9 @@
</div>
</RoleBasedSecurity>
{:else if $questionnaireMetaData.isFinished}
{#if !isLoadingAutoSavedData}
{#if isLoading}
<GradingLoader />
{:else if !isLoading && !isLoadingAutoSavedData}
<div class="flex items-center justify-between">
<div class="flex flex-col lg:flex-row items-start lg:items-center lg:space-x-4 w-full">
<h2 class="text-xl font-normal">{$questionnaire.title}</h2>
@@ -0,0 +1,101 @@
<script lang="ts">
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import Select from '$lib/components/Form/Select.svelte';
import {
lessons,
handleSaveLesson
} from '$lib/components/Course/components/Lesson/store/lessons';
import { course, group } from '$lib/components/Course/store';
import Modal from '$lib/components/Modal/index.svelte';
import { goto } from '$app/navigation';
import { handleAddLessonWidget } from '../Navigation/store';
import { t } from '$lib/utils/functions/translations';
import { COURSE_TYPE } from '$lib/utils/types';
import type { Lesson } from '$lib/utils/types';
let errors = {
title: ''
};
let lesson: Lesson = {
id: '',
course_id: $course.id || '',
title: '',
profile: undefined,
call_url: undefined,
lesson_at: new Date().toDateString(),
is_unlocked: true,
lesson_completion: []
};
const handleSave = async () => {
if (!lesson.title.trim()) {
errors.title = 'title cannot be empty';
return;
}
const savedLesson = await handleSaveLesson(lesson, $course.id);
if (Array.isArray(savedLesson) && savedLesson[0]) {
const newLesson = savedLesson[0];
lesson.id = newLesson.id;
$lessons = [...$lessons, lesson];
goto('/courses/' + $course.id + '/lessons/' + lesson.id);
}
$handleAddLessonWidget.open = false;
};
</script>
<Modal
onClose={() => ($handleAddLessonWidget.open = false)}
bind:open={$handleAddLessonWidget.open}
width="w-[80%] md:w-[65%]"
maxWidth="max-w-2xl"
containerClass="overflow-hidden"
modalHeading={$t('course.navItem.lessons.add_lesson.modal_heading')}
>
<div
class="relative m-auto py-2 md:py-3 px-2 md:px-5 mb-2 md:mb-4 flex flex-wrap items-center dark:bg-neutral-800"
>
<div class="w-full">
<TextField
label={$t('course.navItem.lessons.add_lesson.lesson_title')}
bind:value={lesson.title}
autoFocus={true}
className="flex-1 min-w-lg max-w-lg"
isRequired={true}
errorMessage={errors.title}
/>
{#if $course.type == COURSE_TYPE.LIVE_CLASS}
<div
class="flex items-start justify-evenly gap-1 flex-col lg:flex-row lg:items-center mt-2 w-4/5"
>
<div class="lg:mb-0">
<Select
bind:value={lesson.profile}
options={$group.tutors}
labelKey="fullname"
className="sm:my-1 w-[100%]"
/>
</div>
<div class="flex items-center lg:mb-0">
<input
type="date"
name="lesson-date-picker"
class="p-2 my-2 rounded-md sm:w-[179px] dark:bg-neutral-800 dark:text-white"
/>
</div>
<div class="flex items-center mb-3 lg:mb-0">
<TextField className="w-[179px]" placeholder="https://meet.google.com/mga-dsjs-fmb" />
</div>
</div>
{/if}
</div>
</div>
<div class="flex flex-row-reverse">
<PrimaryButton label={$t('course.navItem.lessons.add_lesson.save')} onClick={handleSave} />
</div>
</Modal>
@@ -39,6 +39,17 @@ export function validateQuestionnaire(questions) {
}
const qErrors = errors[question.id] || {};
if (question.question_type.id !== QUESTION_TYPE.TEXTAREA) {
const hasEmptyOptionLabel = question.options
.filter((option) => !option.deleted_at)
.some((option) => option.label.trim() === '');
if (hasEmptyOptionLabel) {
qErrors.option = 'Please enter a label for all options';
errors[question.id] = { ...qErrors };
}
}
const hasAnswer = question.options
.filter((o) => !o.deleted_at)
.some((option) => option.is_correct);
@@ -87,7 +87,7 @@ export async function handleSaveLesson(lesson: Lesson, course_id: Course['id'])
let newLessonData: any[] | null = null;
if (typeof lesson.id === 'string') {
if (!!lesson.id) {
// No need to get the result of update cause we have all in local state
await updateLesson(newLesson, lesson.id);
} else {
@@ -17,6 +17,7 @@
import { getIsLessonComplete } from '../Lesson/functions';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { COURSE_TYPE } from '$lib/utils/types';
export let path: string;
export let isStudent: boolean = false;
@@ -158,7 +159,12 @@
label: $t('course.navItems.nav_attendance'),
to: getNavItemRoute($course.id, 'attendance'),
isPaidFeature: false,
hideSortIcon: true
hideSortIcon: true,
show() {
if ($course.type !== COURSE_TYPE.LIVE_CLASS) return false;
return true;
}
},
{
label: $t('course.navItems.nav_submissions'),
@@ -166,7 +172,11 @@
hideSortIcon: true,
isPaidFeature: false,
show() {
return !isStudent;
if (isStudent) return false;
if ($course.type !== COURSE_TYPE.LIVE_CLASS) return false;
return true;
}
},
{
@@ -175,16 +185,11 @@
isPaidFeature: false,
hideSortIcon: true,
show() {
return isStudent ? $currentOrg.customization.course.grading : true;
}
},
{
label: $t('course.navItems.nav_people'),
to: getNavItemRoute($course.id, 'people'),
isPaidFeature: false,
hideSortIcon: true,
show() {
return !isStudent;
if ($course.type == COURSE_TYPE.LIVE_CLASS) {
return isStudent ? $currentOrg.customization.course.grading : true;
}
return false;
}
},
{
@@ -209,6 +214,15 @@
return !isStudent;
}
},
{
label: $t('course.navItems.nav_people'),
to: getNavItemRoute($course.id, 'people'),
isPaidFeature: false,
hideSortIcon: true,
show() {
return !isStudent;
}
},
{
label: $t('course.navItems.nav_settings'),
to: getNavItemRoute($course.id, 'settings'),
@@ -1,6 +1,14 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { CodeSnippet, Grid, Row, Column, Toggle } from 'carbon-components-svelte';
import {
CodeSnippet,
Grid,
Row,
Column,
Toggle,
RadioButtonGroup,
RadioButton
} from 'carbon-components-svelte';
import { Restart } from 'carbon-icons-svelte';
import { PUBLIC_SERVER_URL } from '$env/static/public';
@@ -26,6 +34,7 @@
import IconButton from '$lib/components/IconButton/index.svelte';
import generateSlug from '$lib/utils/functions/generateSlug';
import { t } from '$lib/utils/functions/translations';
import { COURSE_TYPE } from '$lib/utils/types';
let isSaving = false;
let isLoading = false;
@@ -124,6 +133,7 @@
const {
course_title,
course_description,
type,
logo,
tabs,
grading,
@@ -134,6 +144,7 @@
await updateCourse($course.id, avatar, {
title: course_title,
description: course_description,
type: type,
logo: logo,
is_published,
metadata: {
@@ -148,6 +159,7 @@
$course.title = course_title;
$course.description = course_description;
$course.type = type;
$course.logo = logo;
$course.is_published = is_published;
$course.metadata = {
@@ -169,6 +181,7 @@
if (course && Object.keys(course).length && $settings.course_title !== course.title) {
$settings = {
course_title: course.title,
type: course.type,
course_description: course.description,
logo: course.logo || '',
tabs: course.metadata.lessonTabsOrder || $settings.tabs,
@@ -317,6 +330,25 @@
</Column>
</Row>
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
<Column sm={8} md={8} lg={8}>
<SectionTitle>{$t('course.navItem.settings.type')}</SectionTitle>
<p>{$t('course.navItem.settings.course_type_desc')}</p>
</Column>
<Column sm={8} md={8} lg={8}>
<RadioButtonGroup hideLegend bind:selected={$settings.type}>
<RadioButton
labelText={$t('course.navItem.settings.live_class')}
value={COURSE_TYPE.LIVE_CLASS}
/>
<RadioButton
labelText={$t('course.navItem.settings.self_paced')}
value={COURSE_TYPE.SELF_PACED}
/>
</RadioButtonGroup>
</Column>
</Row>
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
<Column sm={8} md={8} lg={8}>
<SectionTitle>{$t('course.navItem.settings.allow')}</SectionTitle>
@@ -1,3 +1,4 @@
import { COURSE_TYPE } from '$lib/utils/types';
import { writable } from 'svelte/store';
export const settings = writable({
@@ -5,6 +6,7 @@ export const settings = writable({
course_title: '',
course_description: '',
grading: false,
type: COURSE_TYPE.LIVE_CLASS,
allow_new_students: false,
tabs: [
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
@@ -3,11 +3,13 @@ import type { Writable } from 'svelte/store';
import { lessons } from './components/Lesson/store/lessons';
import { ROLE } from '$lib/utils/constants/roles';
import type { Course, GroupPerson } from '$lib/utils/types';
import { COURSE_TYPE } from '$lib/utils/types';
export const defaultCourse: Course = {
id: '',
title: '',
description: '',
type: COURSE_TYPE.LIVE_CLASS,
cost: 0,
currency: '',
status: 'ACTIVE',
@@ -29,6 +29,7 @@
const { data: _data } = await fetchCourse(courseId);
if (_data) {
$course.type = _data.type;
setCourse(_data);
}
@@ -14,6 +14,11 @@
import Share from 'carbon-icons-svelte/lib/Share.svelte';
import UserFollow from 'carbon-icons-svelte/lib/UserFollow.svelte';
import { t } from '$lib/utils/functions/translations';
import { COURSE_TYPE } from '$lib/utils/types';
import RadioButtonChecked from 'carbon-icons-svelte/lib/RadioButtonChecked.svelte';
import GrowthIcon from 'carbon-icons-svelte/lib/Growth.svelte';
import CourseIcon from 'carbon-icons-svelte/lib/Course.svelte';
import UserProfileIcon from 'carbon-icons-svelte/lib/UserProfile.svelte';
export let bannerImage: string | undefined;
export let id = '';
@@ -28,6 +33,7 @@
export let isOnLandingPage = false;
export let isLMS = false;
export let progressRate = 45;
export let type: COURSE_TYPE;
let target: any;
@@ -58,6 +64,34 @@
? `/course/${slug}`
: `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`;
}
const COURSE_TAG: Record<
string,
{
style: string;
label: string;
icon: any;
iconStyle?: string;
}
> = {
[COURSE_TYPE.LIVE_CLASS]: {
style: '',
label: $t('course.navItem.settings.live_class'),
icon: RadioButtonChecked,
iconStyle: 'text-red-700'
},
[COURSE_TYPE.SELF_PACED]: {
style: '',
label: $t('course.navItem.settings.self_paced'),
icon: UserProfileIcon,
iconStyle: 'text-primary-700'
},
SPECIALIZATION: {
style: '',
label: $t('specialization.course_tag'),
icon: GrowthIcon
}
};
</script>
{#if !isLMS && !isOnLandingPage}
@@ -96,17 +130,26 @@
class="text-black border border-gray dark:border-neutral-600 rounded w-full max-w-[320px] relative hover:scale-95 transition-all ease-in-out"
>
<div class="p-4">
<div class=" mb-5">
<div class="relative mb-5">
<ImageLoader
src={bannerImage}
alt="Course Logo"
class="h-[200px] w-full rounded dark:border dark:border-neutral-600"
class="h-[200px] w-full rounded dark:border dark:border-neutral-600 relative"
>
<svelte:fragment slot="loading">
<SkeletonPlaceholder style="width: 100%; height: 200px;" />
</svelte:fragment>
<svelte:fragment slot="error">{$t('courses.course_card.error_message')}</svelte:fragment>
</ImageLoader>
{#if type}
{@const tag = COURSE_TAG[type]}
<span
class="absolute bottom-2 left-2 z-10 text-xs capitalize bg-primary-50 rounded-sm p-1 flex items-center gap-1 font-mono"
>
<svelte:component this={tag.icon} size={16} class={tag.iconStyle} />
{tag.label}
</span>
{/if}
</div>
<h3 class="text-xl dark:text-white title">{title}</h3>
@@ -12,6 +12,7 @@
export let id = '';
export let title = '';
export let type = '';
export let description = '';
export let isPublished = false;
export let totalLessons = 0;
@@ -48,6 +49,7 @@
<p class="line-clamp-2">{description}</p>
</StructuredListCell>
{#if !$isMobile}
<StructuredListCell>{type}</StructuredListCell>
<StructuredListCell>{totalLessons}</StructuredListCell>
<StructuredListCell>{totalStudents}</StructuredListCell>
<StructuredListCell>
@@ -1,6 +1,10 @@
<script>
<script lang="ts">
import { page } from '$app/stores';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import Modal from '$lib/components/Modal/index.svelte';
import CheckmarkFilledIcon from 'carbon-icons-svelte/lib/CheckmarkFilled.svelte';
import CheckmarkOutlineIcon from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import { courses, createCourseModal } from '$lib/components/Courses/store';
@@ -13,20 +17,41 @@
import { goto } from '$app/navigation';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { t } from '$lib/utils/functions/translations';
import { COURSE_TYPE } from '$lib/utils/types';
import ComingSoon from '$lib/components/ComingSoon/index.svelte';
let isLoading = false;
let errors = {
title: '',
description: ''
};
let step = 0;
let userPrompt = '';
const options = [
{
title: 'Live Class',
subtitle:
'This course type is ideal for bootcamps where lessons are time based and you need attendance and grading',
type: COURSE_TYPE.LIVE_CLASS,
isDisabled: false
},
{
title: 'Self Paced',
subtitle:
'This course type is ideal for courses where students can take lessons on their own pace without a teacher',
type: COURSE_TYPE.SELF_PACED,
isDisabled: false
}
];
let type = options[0].type;
function onClose() {
goto($page.url.pathname);
createCourseModal.update(() => ({
open: false,
title: '',
description: '',
type: '',
emails: '',
tutors: '',
students: ''
@@ -53,15 +78,13 @@
const { id: group_id } = newGroup[0];
// Set userPrompt to the description
userPrompt = description;
// 2. Create course with group_id
const { data: newCourse } = await supabase
.from('course')
.insert({
title,
description,
type: type,
group_id
})
.select();
@@ -109,6 +132,8 @@
onClose();
isLoading = false;
}
$: open = new URLSearchParams($page.url.search).get('create') === 'true';
</script>
<svelte:head>
@@ -117,41 +142,103 @@
<Modal
{onClose}
bind:open={$createCourseModal.open}
width="w-4/5 md:w-2/5"
bind:open
width="w-4/5 md:w-2/5 md:min-w-[600px]"
modalHeading={$t('courses.new_course_modal.heading')}
>
<form on:submit|preventDefault={createCourse}>
<TextField
label={$t('courses.new_course_modal.course_name')}
bind:value={$createCourseModal.title}
placeholder={$t('courses.new_course_modal.course_name_placeholder')}
className="mb-4"
isRequired={true}
errorMessage={errors.title}
autoComplete={false}
/>
{#if step === 0}
<div>
<h2 class="text-xl font-medium my-5">
{$t('courses.new_course_modal.type_selector_title')}
</h2>
<TextArea
label={$t('courses.new_course_modal.short_description')}
bind:value={$createCourseModal.description}
rows={4}
placeholder={$t('courses.new_course_modal.short_description_placeholder')}
className="mb-4"
isRequired={true}
errorMessage={errors.description}
isAIEnabled={true}
initAIPrompt="Write a 30 word description for a course titled: {$createCourseModal.title}"
/>
<div class="flex flex-col md:flex-row gap-2 justify-between items-center my-8">
{#each options as option}
<button
class="w-11/12 md:w-[261px] md:h-[240px] p-5 rounded-md dark:bg-neutral-700 border-2 {option.type ===
type
? 'border-primary-400'
: `border-gray-200 dark:border-neutral-600 ${
!option.isDisabled && 'hover:scale-95'
}`} flex flex-col {option.isDisabled &&
'cursor-not-allowed opacity-60'} transition-all ease-in-out"
type="button"
on:click={!option.isDisabled ? () => (type = option.type) : undefined}
>
<div class="w-full flex flex-row-reverse h-[70%]">
{#if option.type === type}
<CheckmarkFilledIcon
size={16}
class="carbon-icon text-primary-600 dark:text-primary-200"
/>
{:else if !option.isDisabled}
<CheckmarkOutlineIcon size={16} class="carbon-icon" />
{/if}
</div>
<div class="mt-5 flex items-center">
<PrimaryButton
className="px-6 py-3"
label={$t('courses.new_course_modal.button')}
type="submit"
isDisabled={isLoading}
{isLoading}
/>
<div>
<p class="font-bold text-start flex items-center">
<span class="mr-2 text-sm">{option.title}</span>
{#if option.isDisabled}
<ComingSoon />
{/if}
</p>
<p class="text-xs font-light text-start">{option.subtitle}</p>
</div>
</button>
{/each}
</div>
<div class="mt-8 flex items-center flex-row-reverse">
<PrimaryButton
className="px-6 py-3"
label={$t('courses.new_course_modal.next')}
onClick={() => (step = 1)}
isDisabled={!type}
/>
</div>
</div>
</form>
{:else}
<form on:submit|preventDefault={createCourse}>
<div class="flex items-end space-x-2 mb-4">
<TextField
label={$t('courses.new_course_modal.course_name')}
bind:value={$createCourseModal.title}
placeholder={$t('courses.new_course_modal.course_name_placeholder')}
className="w-full "
isRequired={true}
errorMessage={errors.title}
autoComplete={false}
/>
</div>
<TextArea
label={$t('courses.new_course_modal.short_description')}
bind:value={$createCourseModal.description}
rows={4}
placeholder={$t('courses.new_course_modal.short_description_placeholder')}
className="mb-4"
isRequired={true}
errorMessage={errors.description}
isAIEnabled={true}
initAIPrompt="Write a 30 word description for a course titled: {$createCourseModal.title}"
/>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
label={$t('courses.new_course_modal.back')}
variant={VARIANTS.OUTLINED}
onClick={() => (step = 0)}
/>
<PrimaryButton
className="px-6 py-3"
label={$t('courses.new_course_modal.button')}
type="submit"
isDisabled={isLoading}
{isLoading}
/>
</div>
</form>
{/if}
</Modal>
@@ -48,6 +48,9 @@
<StructuredListCell head>
{$t('courses.course_card.list_view.description')}
</StructuredListCell>
<StructuredListCell head>
{$t('courses.course_card.list_view.type')}
</StructuredListCell>
<StructuredListCell head>
{$t('courses.course_card.list_view.lessons')}
</StructuredListCell>
@@ -65,6 +68,7 @@
<List
id={courseData.id}
title={courseData.title}
type={$t(`course.navItem.settings.${courseData.type.toLowerCase()}`)}
description={courseData.description}
isPublished={courseData.is_published}
totalLessons={courseData.total_lessons}
@@ -84,6 +88,7 @@
description={courseData.description}
isPublished={courseData.is_published}
cost={courseData.cost}
type={courseData.type}
currency={courseData.currency}
totalLessons={courseData.total_lessons}
totalStudents={courseData.total_students}
@@ -2,9 +2,14 @@ import { writable, derived } from 'svelte/store';
import type { Course } from '$lib/utils/types';
export const courses = writable<Course[]>([]);
export const view = writable('grid');
export const coursesInProgress = derived(courses, ($courses) =>
$courses.length > 0 ? $courses.slice(0, 3) : []
$courses.length > 0
? $courses.filter((course) => {
return course.total_lessons !== course.progress_rate;
})
: []
);
export const coursesComplete = derived(courses, ($courses) =>
$courses.length > 0
@@ -22,8 +27,8 @@ export const courseMetaDeta = writable<{
});
export const createCourseModal = writable({
open: false,
title: '',
type: '',
description: '',
emails: '',
tutors: '',
@@ -3,13 +3,15 @@
import { goto } from '$app/navigation';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import CoursesEmptyIcon from '$lib/components/Icons/CoursesEmptyIcon.svelte';
import { coursesInProgress } from '$lib/components/Courses/store';
import { courses } from '$lib/components/Courses/store';
import { t } from '$lib/utils/functions/translations';
const gotoCourse = (id: string | undefined) => {
if (!id) return;
goto(`/courses/${id}/lessons?next=true`);
};
$: last3Courses = $courses.length > 0 ? $courses.slice(0, 3) : [];
</script>
<section class="h-full">
@@ -19,9 +21,9 @@
<div
class="flex items-center flex-col border border-[#EAEAEA] dark:bg-neutral-800 gap-2 rounded w-full lg:h-[40vh] p-3"
>
{#if $coursesInProgress.length > 0}
{#if last3Courses.length > 0}
<div class="w-full h-full flex flex-col justify-start overflow-y-auto">
{#each $coursesInProgress as course}
{#each last3Courses as course}
<div class="p-5">
<span class="flex flex-col lg:flex-row gap-3 items-start pb-5">
<img
@@ -309,8 +309,8 @@
slug={courseData.slug}
bannerImage={courseData.logo || '/images/classroomio-course-img-template.jpg'}
title={courseData.title}
type={courseData.type}
description={courseData.description}
role_id={courseData.role_id}
isPublished={courseData.is_published}
cost={courseData.cost}
currency={courseData.currency}
@@ -2,13 +2,12 @@
import TextChip from '$lib/components/Chip/Text.svelte';
import { t } from '$lib/utils/functions/translations';
interface Tab {
export let tabs: {
icon?: any;
label: string;
value: string;
badgeValue?: number;
}
export let tabs: Tab[] = [];
}[] = [];
export let currentTab: string | number;
export let onChange = (v: string | number) => () => {};
</script>