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 <best@classroomio.com>

* add type to course card

---------

Co-authored-by: Rotimi Best <best@classroomio.com>
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
@@ -1,4 +1,4 @@
<script> <script lang="ts">
import ChevronDown from 'carbon-icons-svelte/lib/ChevronDown.svelte'; import ChevronDown from 'carbon-icons-svelte/lib/ChevronDown.svelte';
import TextAlignJustify from 'carbon-icons-svelte/lib/TextAlignJustify.svelte'; import TextAlignJustify from 'carbon-icons-svelte/lib/TextAlignJustify.svelte';
import CloseLarge from 'carbon-icons-svelte/lib/CloseLarge.svelte'; import CloseLarge from 'carbon-icons-svelte/lib/CloseLarge.svelte';
@@ -7,9 +7,11 @@
import ForumIcon from 'carbon-icons-svelte/lib/Forum.svelte'; import ForumIcon from 'carbon-icons-svelte/lib/Forum.svelte';
import CourseIcon from '$lib/Icons/CourseIcon.svelte'; import CourseIcon from '$lib/Icons/CourseIcon.svelte';
import MapCenter from 'carbon-icons-svelte/lib/MapCenter.svelte'; import MapCenter from 'carbon-icons-svelte/lib/MapCenter.svelte';
import { page } from '$app/stores';
let showsubNav = false; let showsubNav = false;
let showNav = false; let showNav = false;
let activeLink = '';
function handleShow() { function handleShow() {
showsubNav = !showsubNav; showsubNav = !showsubNav;
@@ -23,7 +25,7 @@
{ {
key: 'coursemanagement', key: 'coursemanagement',
title: 'Course Management', title: 'Course Management',
subtitle: 'Simple course management tools ' subtitle: 'Simple course management tools'
}, },
{ {
key: 'ai', key: 'ai',
@@ -41,6 +43,10 @@
subtitle: 'Seamlessly collaborate with your students' subtitle: 'Seamlessly collaborate with your students'
} }
]; ];
$: activeLink = $page.url.pathname;
$: activeHash = $page.url.hash;
$: isSuperpowersActive = superpowers.some((sp) => activeHash.includes(sp.key));
</script> </script>
<div <div
@@ -62,20 +68,25 @@
<nav class="w-[40%] hidden md:hidden lg:block"> <nav class="w-[40%] hidden md:hidden lg:block">
<ul class="flex items-center justify-between w-full gap-2"> <ul class="flex items-center justify-between w-full gap-2">
<!-- -->
<li class="text-gray-800 font-semibold text-sm cursor-pointer relative"> <li class="text-gray-800 font-semibold text-sm cursor-pointer relative">
<button class="flex items-center" on:click={() => (showNav = !showNav)} <button
>Our Superpowers <ChevronDown class="ml-2" /></button class="flex items-center hover:bg-gray-100 px-4 py-2 rounded-md"
on:click={() => (showNav = !showNav)}
class:active={isSuperpowersActive}
> >
Our Superpowers <ChevronDown class="ml-2" />
</button>
{#if showNav} {#if showNav}
<div <div
class="absolute w-[24rem] top-10 -left-10 border px-5 py-5 rounded-[30px] shadow-slate-700 z-[3001] bg-white" class="absolute w-[24rem] top-10 -left-10 border px-5 py-5 rounded-[30px] shadow-slate-700 z-[3001] bg-white"
> >
{#each superpowers as superpower} {#each superpowers as superpower}
<a <a
class="flex justify-between items-center w-full rounded-lg hover:bg-slate-100 p-5 mb-4" class="flex justify-between items-center w-full rounded-lg hover:bg-gray-100 p-5 mb-4"
href="/#{superpower.key}" href="/#{superpower.key}"
on:click={() => (showNav = !showNav)} on:click={() => {
showNav = !showNav;
}}
> >
{#if superpower.key === 'coursemanagement'} {#if superpower.key === 'coursemanagement'}
<CourseIcon /> <CourseIcon />
@@ -99,31 +110,33 @@
</div> </div>
{/if} {/if}
</li> </li>
<!-- More Features --> <a
<!-- <a href="/#morefeatures"> href="/pricing"
<li class="text-gray-800 font-semibold text-sm cursor-pointer">More features</li> class="text-gray-800 font-semibold text-sm cursor-pointer"
</a> --> class:active={activeLink.startsWith('/pricing')}
<!-- Pricing --> >
<a href="/pricing"> <li class="hover:bg-gray-100 px-4 py-2 rounded-md">Pricing</li>
<li class="text-gray-800 font-semibold text-sm cursor-pointer">Pricing</li>
</a> </a>
<!-- Docs --> <a
<a href="/docs" target="_blank"> href="/docs"
<li class="text-gray-800 font-semibold text-sm cursor-pointer">Docs</li> target="_blank"
class="text-gray-800 font-semibold text-sm cursor-pointer"
class:active={activeLink.startsWith('/docs')}
>
<li class="hover:bg-gray-100 px-4 py-2 rounded-md">Docs</li>
</a> </a>
<!-- Blog -->
<a href="/blog"> <a
<li class="text-gray-800 font-semibold text-sm cursor-pointer">Blog</li> href="/blog"
class="text-gray-800 font-semibold text-sm cursor-pointer"
class:active={activeLink.startsWith('/blog')}
>
<li class="hover:bg-gray-100 px-4 py-2 rounded-md">Blog</li>
</a> </a>
</ul> </ul>
</nav> </nav>
<div class="justify-between items-center flex-row hidden md:hidden lg:flex"> <div class="justify-between items-center flex-row hidden md:hidden lg:flex">
<!-- <button
class="bg-blue-700 text-white text-sm font-medium px-4 py-1.5 rounded-md mr-5"
>
Sign Up
</button> -->
<a href="/discord" target="_blank"> <a href="/discord" target="_blank">
<img <img
loading="lazy" loading="lazy"
@@ -155,7 +168,6 @@
on:click={handleShowNav}><TextAlignJustify size={24} /></button on:click={handleShowNav}><TextAlignJustify size={24} /></button
> >
<!-- burger menu -->
{#if showNav} {#if showNav}
<div <div
in:fly={{ x: 20, duration: 700 }} in:fly={{ x: 20, duration: 700 }}
@@ -173,20 +185,25 @@
/> />
<button on:click={handleShowNav}><CloseLarge size={24} class="mr-5" /></button> <button on:click={handleShowNav}><CloseLarge size={24} class="mr-5" /></button>
</div> </div>
<nav class=""> <nav>
<ul class="flex items-center flex-col lg:flex-row justify-between w-full"> <ul class="flex items-center flex-col lg:flex-row justify-between w-full">
<!-- -->
<li class="text-gray-800 font-semibold text-sm md:text-lg cursor-pointer w-full"> <li class="text-gray-800 font-semibold text-sm md:text-lg cursor-pointer w-full">
<button <button
class="w-full flex items-center justify-between hover:bg-gray-100 py-3 px-4 rounded-lg" class="w-full flex items-center justify-between hover:bg-gray-100 py-3 px-4 rounded-lg"
on:click={handleShow} on:click={handleShow}
class:active={isSuperpowersActive}
> >
Our Superpowers <ChevronDown /> Our Superpowers <ChevronDown />
</button> </button>
{#if showsubNav} {#if showsubNav}
<div in:fly={{ y: -20, duration: 700 }} out:fly={{ y: 20, duration: 400 }}> <div in:fly={{ y: -20, duration: 700 }} out:fly={{ y: 20, duration: 400 }}>
{#each superpowers as superpower} {#each superpowers as superpower}
<a href="/#{superpower.key}" on:click={handleShowNav}> <a
href="/#{superpower.key}"
on:click={() => {
handleShowNav();
}}
>
<p <p
class="font-normal text-xs text-gray-700 hover:bg-gray-100 rounded-lg py-2.5 pl-5" class="font-normal text-xs text-gray-700 hover:bg-gray-100 rounded-lg py-2.5 pl-5"
> >
@@ -197,36 +214,43 @@
</div> </div>
{/if} {/if}
</li> </li>
<!-- Pricing -->
<a <a
class="text-gray-800 font-semibold text-sm md:text-lg cursor-pointer hover:bg-gray-100 py-3 px-4 rounded-xl w-full" class="text-gray-800 font-semibold text-sm md:text-lg cursor-pointer hover:bg-gray-100 py-3 px-4 rounded-xl w-full"
on:click={handleShowNav} on:click={() => {
handleShowNav();
}}
href="/pricing" href="/pricing"
class:active={activeLink.startsWith('/pricing')}
> >
<li>Pricing</li> <li>Pricing</li>
</a> </a>
<!-- Docs -->
<a <a
class="text-gray-800 font-semibold text-sm md:text-lg cursor-pointer hover:bg-gray-100 py-3 px-4 rounded-md w-full" class="text-gray-800 font-semibold text-sm md:text-lg cursor-pointer hover:bg-gray-100 py-3 px-4 rounded-md w-full"
on:click={handleShowNav} on:click={() => {
handleShowNav();
}}
class:active={activeLink.startsWith('/docs')}
href="/docs" href="/docs"
target="_blank" target="_blank"
> >
<li>Docs</li> <li>Docs</li>
</a> </a>
<!-- Blog -->
<a <a
class="text-gray-800 font-semibold text-sm md:text-lg cursor-pointer hover:bg-gray-100 py-3 px-4 rounded-md w-full" class="text-gray-800 font-semibold text-sm md:text-lg cursor-pointer hover:bg-gray-100 py-3 px-4 rounded-md w-full"
on:click={handleShowNav} on:click={() => {
handleShowNav();
}}
class:active={activeLink.startsWith('/blog')}
href="/blog" href="/blog"
> >
<li>Blog</li> <li>Blog</li>
</a> </a>
<!-- More Features -->
<a <a
class="text-gray-800 font-semibold text-sm md:text-lg cursor-pointer hover:bg-gray-100 py-3 px-4 rounded-md w-full" class="text-gray-800 font-semibold text-sm md:text-lg cursor-pointer hover:bg-gray-100 py-3 px-4 rounded-md w-full"
on:click={handleShowNav} on:click={() => {
handleShowNav();
}}
class:active={activeHash.includes('morefeatures')}
href="/#morefeatures" href="/#morefeatures"
> >
<li>More features</li> <li>More features</li>
@@ -234,10 +258,6 @@
</ul> </ul>
</nav> </nav>
<div class="flex items-start flex-col gap-y-2 mt-5 border-t-[1px] pt-5"> <div class="flex items-start flex-col gap-y-2 mt-5 border-t-[1px] pt-5">
<!-- <button
class="text-black font-semibold rounded-md mr-10 w-full text-left py-4 px-4 hover:bg-gray-100 text-sm md:text-lg"
>Sign Up</button
> -->
<a <a
href="/discord" href="/discord"
target="_blank" target="_blank"
@@ -274,3 +294,10 @@
</div> </div>
{/if} {/if}
</div> </div>
<style>
.active {
background-color: #f3f4f6;
border-radius: 6px;
}
</style>
@@ -0,0 +1,8 @@
import { dev } from '$app/environment';
import { redirect } from '@sveltejs/kit';
export const load = async () => {
const url = dev ? 'http://localhost:5173' : 'https://app.classroomio.com';
throw redirect(307, `${url}/org/*/courses?create=true`);
};
@@ -112,11 +112,9 @@
disabled={$isFreePlan} disabled={$isFreePlan}
> >
<span slot="labelA" style={$globalStore.isDark ? 'color: white' : 'color: #161616'} <span slot="labelA" style={$globalStore.isDark ? 'color: white' : 'color: #161616'}
>{$t('course.navItem.certificates.locked')}</span >{$t('generic.locked')}</span
>
<span slot="labelB" style="color: green"
>{$t('course.navItem.certificates.unlocked')}</span
> >
<span slot="labelB" style="color: green">{$t('generic.unlocked')}</span>
</Toggle> </Toggle>
</div> </div>
</section> </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)} onClose={() => ($deleteConfirmation.open = false)}
bind:open={$deleteConfirmation.open} bind:open={$deleteConfirmation.open}
width="w-96" 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> <div>
<h1 class="dark:text-white text-lg"> <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> </h1>
<div class="mt-5 flex items-center justify-between"> <div class="mt-5 flex items-center justify-between">
<PrimaryButton <PrimaryButton
className="px-6 py-3" className="px-6 py-3"
variant={VARIANTS.OUTLINED} variant={VARIANTS.OUTLINED}
label={$t('course.navItem.lessons.exercises.all_exercises.no')} label={$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.no')}
onClick={() => { onClick={() => {
$deleteConfirmation.open = false; $deleteConfirmation.open = false;
onCancel(); onCancel();
@@ -39,7 +39,7 @@
<PrimaryButton <PrimaryButton
className="px-6 py-3" className="px-6 py-3"
variant={VARIANTS.CONTAINED} variant={VARIANTS.CONTAINED}
label={$t('course.navItem.lessons.exercises.all_exercises.yes')} label={$t('course.navItem.lessons.exercises.all_exercises.delete_confirmation.yes')}
onClick={() => { onClick={() => {
onDelete(); onDelete();
$deleteConfirmation.open = false; $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 { getPropsForQuestion, filterOutDeleted } from './functions';
import { formatAnswers, getGroupMemberId } from '$lib/components/Course/function'; import { formatAnswers, getGroupMemberId } from '$lib/components/Course/function';
import { submitExercise } from '$lib/utils/services/courses'; 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 { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
import { import {
@@ -26,8 +30,9 @@
} from '$lib/utils/services/notification/notification'; } from '$lib/utils/services/notification/notification';
import { lesson } from '../store/lessons'; import { lesson } from '../store/lessons';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { CloseFilled } from 'carbon-icons-svelte'; import { useCompletion } from 'ai/svelte';
import IconButton from '$lib/components/IconButton/index.svelte'; import { COURSE_TYPE } from '$lib/utils/types';
import GradingLoader from './GradingLoader.svelte';
export let preview: boolean = false; export let preview: boolean = false;
export let exerciseId = ''; export let exerciseId = '';
@@ -40,6 +45,9 @@
let isLoadingAutoSavedData = false; let isLoadingAutoSavedData = false;
let alreadyCheckedAutoSavedData = false; let alreadyCheckedAutoSavedData = false;
let gradedComment = true; let gradedComment = true;
let isLoading = false;
let submissionResponse;
let submissionId;
function handleStart() { function handleStart() {
$questionnaireMetaData.currentQuestionIndex += 1; $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 { answers } = $questionnaireMetaData;
const { questions } = $questionnaire; const { questions } = $questionnaire;
const prevAnswer = answers[id] || []; const prevAnswer = answers[id] || [];
@@ -114,14 +204,24 @@
$questionnaireMetaData.status = 1; $questionnaireMetaData.status = 1;
$questionnaireMetaData.totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions); $questionnaireMetaData.totalPossibleGrade = getTotalPossibleGrade($questionnaire.questions);
$questionnaireMetaData.grades = {}; $questionnaireMetaData.grades = {};
($questionnaireMetaData.comment = ''),
submitExercise( $questionnaireMetaData.comment = '';
$questionnaireMetaData.answers, let response = await submitExercise(
questions, $questionnaireMetaData.answers,
exerciseId, questions,
$course.id, exerciseId,
getGroupMemberId($group.people, $profile.id) $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(); notifyEducator();
} }
} }
@@ -291,7 +391,9 @@
</div> </div>
</RoleBasedSecurity> </RoleBasedSecurity>
{:else if $questionnaireMetaData.isFinished} {:else if $questionnaireMetaData.isFinished}
{#if !isLoadingAutoSavedData} {#if isLoading}
<GradingLoader />
{:else if !isLoading && !isLoadingAutoSavedData}
<div class="flex items-center justify-between"> <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"> <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> <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] || {}; 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 const hasAnswer = question.options
.filter((o) => !o.deleted_at) .filter((o) => !o.deleted_at)
.some((option) => option.is_correct); .some((option) => option.is_correct);
@@ -87,7 +87,7 @@ export async function handleSaveLesson(lesson: Lesson, course_id: Course['id'])
let newLessonData: any[] | null = null; 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 // No need to get the result of update cause we have all in local state
await updateLesson(newLesson, lesson.id); await updateLesson(newLesson, lesson.id);
} else { } else {
@@ -17,6 +17,7 @@
import { getIsLessonComplete } from '../Lesson/functions'; import { getIsLessonComplete } from '../Lesson/functions';
import { currentOrg, isFreePlan } from '$lib/utils/store/org'; import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { COURSE_TYPE } from '$lib/utils/types';
export let path: string; export let path: string;
export let isStudent: boolean = false; export let isStudent: boolean = false;
@@ -158,7 +159,12 @@
label: $t('course.navItems.nav_attendance'), label: $t('course.navItems.nav_attendance'),
to: getNavItemRoute($course.id, 'attendance'), to: getNavItemRoute($course.id, 'attendance'),
isPaidFeature: false, isPaidFeature: false,
hideSortIcon: true hideSortIcon: true,
show() {
if ($course.type !== COURSE_TYPE.LIVE_CLASS) return false;
return true;
}
}, },
{ {
label: $t('course.navItems.nav_submissions'), label: $t('course.navItems.nav_submissions'),
@@ -166,7 +172,11 @@
hideSortIcon: true, hideSortIcon: true,
isPaidFeature: false, isPaidFeature: false,
show() { show() {
return !isStudent; if (isStudent) return false;
if ($course.type !== COURSE_TYPE.LIVE_CLASS) return false;
return true;
} }
}, },
{ {
@@ -175,16 +185,11 @@
isPaidFeature: false, isPaidFeature: false,
hideSortIcon: true, hideSortIcon: true,
show() { show() {
return isStudent ? $currentOrg.customization.course.grading : true; if ($course.type == COURSE_TYPE.LIVE_CLASS) {
} return isStudent ? $currentOrg.customization.course.grading : true;
}, }
{
label: $t('course.navItems.nav_people'), return false;
to: getNavItemRoute($course.id, 'people'),
isPaidFeature: false,
hideSortIcon: true,
show() {
return !isStudent;
} }
}, },
{ {
@@ -209,6 +214,15 @@
return !isStudent; 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'), label: $t('course.navItems.nav_settings'),
to: getNavItemRoute($course.id, 'settings'), to: getNavItemRoute($course.id, 'settings'),
@@ -1,6 +1,14 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; 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 { Restart } from 'carbon-icons-svelte';
import { PUBLIC_SERVER_URL } from '$env/static/public'; import { PUBLIC_SERVER_URL } from '$env/static/public';
@@ -26,6 +34,7 @@
import IconButton from '$lib/components/IconButton/index.svelte'; import IconButton from '$lib/components/IconButton/index.svelte';
import generateSlug from '$lib/utils/functions/generateSlug'; import generateSlug from '$lib/utils/functions/generateSlug';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { COURSE_TYPE } from '$lib/utils/types';
let isSaving = false; let isSaving = false;
let isLoading = false; let isLoading = false;
@@ -124,6 +133,7 @@
const { const {
course_title, course_title,
course_description, course_description,
type,
logo, logo,
tabs, tabs,
grading, grading,
@@ -134,6 +144,7 @@
await updateCourse($course.id, avatar, { await updateCourse($course.id, avatar, {
title: course_title, title: course_title,
description: course_description, description: course_description,
type: type,
logo: logo, logo: logo,
is_published, is_published,
metadata: { metadata: {
@@ -148,6 +159,7 @@
$course.title = course_title; $course.title = course_title;
$course.description = course_description; $course.description = course_description;
$course.type = type;
$course.logo = logo; $course.logo = logo;
$course.is_published = is_published; $course.is_published = is_published;
$course.metadata = { $course.metadata = {
@@ -169,6 +181,7 @@
if (course && Object.keys(course).length && $settings.course_title !== course.title) { if (course && Object.keys(course).length && $settings.course_title !== course.title) {
$settings = { $settings = {
course_title: course.title, course_title: course.title,
type: course.type,
course_description: course.description, course_description: course.description,
logo: course.logo || '', logo: course.logo || '',
tabs: course.metadata.lessonTabsOrder || $settings.tabs, tabs: course.metadata.lessonTabsOrder || $settings.tabs,
@@ -317,6 +330,25 @@
</Column> </Column>
</Row> </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"> <Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
<Column sm={8} md={8} lg={8}> <Column sm={8} md={8} lg={8}>
<SectionTitle>{$t('course.navItem.settings.allow')}</SectionTitle> <SectionTitle>{$t('course.navItem.settings.allow')}</SectionTitle>
@@ -1,3 +1,4 @@
import { COURSE_TYPE } from '$lib/utils/types';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
export const settings = writable({ export const settings = writable({
@@ -5,6 +6,7 @@ export const settings = writable({
course_title: '', course_title: '',
course_description: '', course_description: '',
grading: false, grading: false,
type: COURSE_TYPE.LIVE_CLASS,
allow_new_students: false, allow_new_students: false,
tabs: [ tabs: [
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' }, { 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 { lessons } from './components/Lesson/store/lessons';
import { ROLE } from '$lib/utils/constants/roles'; import { ROLE } from '$lib/utils/constants/roles';
import type { Course, GroupPerson } from '$lib/utils/types'; import type { Course, GroupPerson } from '$lib/utils/types';
import { COURSE_TYPE } from '$lib/utils/types';
export const defaultCourse: Course = { export const defaultCourse: Course = {
id: '', id: '',
title: '', title: '',
description: '', description: '',
type: COURSE_TYPE.LIVE_CLASS,
cost: 0, cost: 0,
currency: '', currency: '',
status: 'ACTIVE', status: 'ACTIVE',
@@ -29,6 +29,7 @@
const { data: _data } = await fetchCourse(courseId); const { data: _data } = await fetchCourse(courseId);
if (_data) { if (_data) {
$course.type = _data.type;
setCourse(_data); setCourse(_data);
} }
@@ -14,6 +14,11 @@
import Share from 'carbon-icons-svelte/lib/Share.svelte'; import Share from 'carbon-icons-svelte/lib/Share.svelte';
import UserFollow from 'carbon-icons-svelte/lib/UserFollow.svelte'; import UserFollow from 'carbon-icons-svelte/lib/UserFollow.svelte';
import { t } from '$lib/utils/functions/translations'; 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 bannerImage: string | undefined;
export let id = ''; export let id = '';
@@ -28,6 +33,7 @@
export let isOnLandingPage = false; export let isOnLandingPage = false;
export let isLMS = false; export let isLMS = false;
export let progressRate = 45; export let progressRate = 45;
export let type: COURSE_TYPE;
let target: any; let target: any;
@@ -58,6 +64,34 @@
? `/course/${slug}` ? `/course/${slug}`
: `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`; : `/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> </script>
{#if !isLMS && !isOnLandingPage} {#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" 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="p-4">
<div class=" mb-5"> <div class="relative mb-5">
<ImageLoader <ImageLoader
src={bannerImage} src={bannerImage}
alt="Course Logo" 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"> <svelte:fragment slot="loading">
<SkeletonPlaceholder style="width: 100%; height: 200px;" /> <SkeletonPlaceholder style="width: 100%; height: 200px;" />
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="error">{$t('courses.course_card.error_message')}</svelte:fragment> <svelte:fragment slot="error">{$t('courses.course_card.error_message')}</svelte:fragment>
</ImageLoader> </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> </div>
<h3 class="text-xl dark:text-white title">{title}</h3> <h3 class="text-xl dark:text-white title">{title}</h3>
@@ -12,6 +12,7 @@
export let id = ''; export let id = '';
export let title = ''; export let title = '';
export let type = '';
export let description = ''; export let description = '';
export let isPublished = false; export let isPublished = false;
export let totalLessons = 0; export let totalLessons = 0;
@@ -48,6 +49,7 @@
<p class="line-clamp-2">{description}</p> <p class="line-clamp-2">{description}</p>
</StructuredListCell> </StructuredListCell>
{#if !$isMobile} {#if !$isMobile}
<StructuredListCell>{type}</StructuredListCell>
<StructuredListCell>{totalLessons}</StructuredListCell> <StructuredListCell>{totalLessons}</StructuredListCell>
<StructuredListCell>{totalStudents}</StructuredListCell> <StructuredListCell>{totalStudents}</StructuredListCell>
<StructuredListCell> <StructuredListCell>
@@ -1,6 +1,10 @@
<script> <script lang="ts">
import { page } from '$app/stores';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import Modal from '$lib/components/Modal/index.svelte'; 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 TextField from '$lib/components/Form/TextField.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte'; import TextArea from '$lib/components/Form/TextArea.svelte';
import { courses, createCourseModal } from '$lib/components/Courses/store'; import { courses, createCourseModal } from '$lib/components/Courses/store';
@@ -13,20 +17,41 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { capturePosthogEvent } from '$lib/utils/services/posthog'; import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { t } from '$lib/utils/functions/translations'; 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 isLoading = false;
let errors = { let errors = {
title: '', title: '',
description: '' 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() { function onClose() {
goto($page.url.pathname);
createCourseModal.update(() => ({ createCourseModal.update(() => ({
open: false,
title: '', title: '',
description: '', description: '',
type: '',
emails: '', emails: '',
tutors: '', tutors: '',
students: '' students: ''
@@ -53,15 +78,13 @@
const { id: group_id } = newGroup[0]; const { id: group_id } = newGroup[0];
// Set userPrompt to the description
userPrompt = description;
// 2. Create course with group_id // 2. Create course with group_id
const { data: newCourse } = await supabase const { data: newCourse } = await supabase
.from('course') .from('course')
.insert({ .insert({
title, title,
description, description,
type: type,
group_id group_id
}) })
.select(); .select();
@@ -109,6 +132,8 @@
onClose(); onClose();
isLoading = false; isLoading = false;
} }
$: open = new URLSearchParams($page.url.search).get('create') === 'true';
</script> </script>
<svelte:head> <svelte:head>
@@ -117,41 +142,103 @@
<Modal <Modal
{onClose} {onClose}
bind:open={$createCourseModal.open} bind:open
width="w-4/5 md:w-2/5" width="w-4/5 md:w-2/5 md:min-w-[600px]"
modalHeading={$t('courses.new_course_modal.heading')} modalHeading={$t('courses.new_course_modal.heading')}
> >
<form on:submit|preventDefault={createCourse}> {#if step === 0}
<TextField <div>
label={$t('courses.new_course_modal.course_name')} <h2 class="text-xl font-medium my-5">
bind:value={$createCourseModal.title} {$t('courses.new_course_modal.type_selector_title')}
placeholder={$t('courses.new_course_modal.course_name_placeholder')} </h2>
className="mb-4"
isRequired={true}
errorMessage={errors.title}
autoComplete={false}
/>
<TextArea <div class="flex flex-col md:flex-row gap-2 justify-between items-center my-8">
label={$t('courses.new_course_modal.short_description')} {#each options as option}
bind:value={$createCourseModal.description} <button
rows={4} class="w-11/12 md:w-[261px] md:h-[240px] p-5 rounded-md dark:bg-neutral-700 border-2 {option.type ===
placeholder={$t('courses.new_course_modal.short_description_placeholder')} type
className="mb-4" ? 'border-primary-400'
isRequired={true} : `border-gray-200 dark:border-neutral-600 ${
errorMessage={errors.description} !option.isDisabled && 'hover:scale-95'
isAIEnabled={true} }`} flex flex-col {option.isDisabled &&
initAIPrompt="Write a 30 word description for a course titled: {$createCourseModal.title}" '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"> <div>
<PrimaryButton <p class="font-bold text-start flex items-center">
className="px-6 py-3" <span class="mr-2 text-sm">{option.title}</span>
label={$t('courses.new_course_modal.button')} {#if option.isDisabled}
type="submit" <ComingSoon />
isDisabled={isLoading} {/if}
{isLoading} </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> </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> </Modal>
@@ -48,6 +48,9 @@
<StructuredListCell head> <StructuredListCell head>
{$t('courses.course_card.list_view.description')} {$t('courses.course_card.list_view.description')}
</StructuredListCell> </StructuredListCell>
<StructuredListCell head>
{$t('courses.course_card.list_view.type')}
</StructuredListCell>
<StructuredListCell head> <StructuredListCell head>
{$t('courses.course_card.list_view.lessons')} {$t('courses.course_card.list_view.lessons')}
</StructuredListCell> </StructuredListCell>
@@ -65,6 +68,7 @@
<List <List
id={courseData.id} id={courseData.id}
title={courseData.title} title={courseData.title}
type={$t(`course.navItem.settings.${courseData.type.toLowerCase()}`)}
description={courseData.description} description={courseData.description}
isPublished={courseData.is_published} isPublished={courseData.is_published}
totalLessons={courseData.total_lessons} totalLessons={courseData.total_lessons}
@@ -84,6 +88,7 @@
description={courseData.description} description={courseData.description}
isPublished={courseData.is_published} isPublished={courseData.is_published}
cost={courseData.cost} cost={courseData.cost}
type={courseData.type}
currency={courseData.currency} currency={courseData.currency}
totalLessons={courseData.total_lessons} totalLessons={courseData.total_lessons}
totalStudents={courseData.total_students} totalStudents={courseData.total_students}
@@ -2,9 +2,14 @@ import { writable, derived } from 'svelte/store';
import type { Course } from '$lib/utils/types'; import type { Course } from '$lib/utils/types';
export const courses = writable<Course[]>([]); export const courses = writable<Course[]>([]);
export const view = writable('grid'); export const view = writable('grid');
export const coursesInProgress = derived(courses, ($courses) => 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) => export const coursesComplete = derived(courses, ($courses) =>
$courses.length > 0 $courses.length > 0
@@ -22,8 +27,8 @@ export const courseMetaDeta = writable<{
}); });
export const createCourseModal = writable({ export const createCourseModal = writable({
open: false,
title: '', title: '',
type: '',
description: '', description: '',
emails: '', emails: '',
tutors: '', tutors: '',
@@ -3,13 +3,15 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import CoursesEmptyIcon from '$lib/components/Icons/CoursesEmptyIcon.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'; import { t } from '$lib/utils/functions/translations';
const gotoCourse = (id: string | undefined) => { const gotoCourse = (id: string | undefined) => {
if (!id) return; if (!id) return;
goto(`/courses/${id}/lessons?next=true`); goto(`/courses/${id}/lessons?next=true`);
}; };
$: last3Courses = $courses.length > 0 ? $courses.slice(0, 3) : [];
</script> </script>
<section class="h-full"> <section class="h-full">
@@ -19,9 +21,9 @@
<div <div
class="flex items-center flex-col border border-[#EAEAEA] dark:bg-neutral-800 gap-2 rounded w-full lg:h-[40vh] p-3" 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"> <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"> <div class="p-5">
<span class="flex flex-col lg:flex-row gap-3 items-start pb-5"> <span class="flex flex-col lg:flex-row gap-3 items-start pb-5">
<img <img
@@ -309,8 +309,8 @@
slug={courseData.slug} slug={courseData.slug}
bannerImage={courseData.logo || '/images/classroomio-course-img-template.jpg'} bannerImage={courseData.logo || '/images/classroomio-course-img-template.jpg'}
title={courseData.title} title={courseData.title}
type={courseData.type}
description={courseData.description} description={courseData.description}
role_id={courseData.role_id}
isPublished={courseData.is_published} isPublished={courseData.is_published}
cost={courseData.cost} cost={courseData.cost}
currency={courseData.currency} currency={courseData.currency}
@@ -2,13 +2,12 @@
import TextChip from '$lib/components/Chip/Text.svelte'; import TextChip from '$lib/components/Chip/Text.svelte';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
interface Tab { export let tabs: {
icon?: any; icon?: any;
label: string; label: string;
value: string; value: string;
badgeValue?: number; badgeValue?: number;
} }[] = [];
export let tabs: Tab[] = [];
export let currentTab: string | number; export let currentTab: string | number;
export let onChange = (v: string | number) => () => {}; export let onChange = (v: string | number) => () => {};
</script> </script>
@@ -30,6 +30,7 @@ export async function fetchCourse(courseId?: Course['id'], slug?: Course['slug']
` `
id, id,
title, title,
type,
description, description,
overview, overview,
logo, logo,
@@ -61,6 +62,7 @@ export async function fetchCourse(courseId?: Course['id'], slug?: Course['slug']
const { data, error } = response; const { data, error } = response;
console.log(`error`, error); console.log(`error`, error);
console.log(`data`, data);
if (!data || error) { if (!data || error) {
console.log(`data`, data); console.log(`data`, data);
console.log(`fetchCourse => error`, error); console.log(`fetchCourse => error`, error);
@@ -410,7 +412,11 @@ export async function submitExercise(
} }
const res = await supabase.from('question_answer').insert(questionAnswers).select(); const res = await supabase.from('question_answer').insert(questionAnswers).select();
console.log(`res`, res); console.log(`res`, res, 'submission', submission);
return {
submission,
res
};
} }
export async function deleteExercise(questions: Array<{ id: string }>, exerciseId: Exercise['id']) { export async function deleteExercise(questions: Array<{ id: string }>, exerciseId: Exercise['id']) {
@@ -263,7 +263,11 @@
"unpublished": "Unveröffentlicht", "unpublished": "Unveröffentlicht",
"delete": "Kurs löschen", "delete": "Kurs löschen",
"delete_text": "Löschen Sie diesen Kurs. Diese Aktion kann nicht rückgängig gemacht werden", "delete_text": "Löschen Sie diesen Kurs. Diese Aktion kann nicht rückgängig gemacht werden",
"save": "Änderungen speichern" "save": "Änderungen speichern",
"type": "Kurstyp",
"course_type_desc": "Aktualisieren Sie den Kurstyp für ein persönlicheres Erlebnis",
"live_class": "Live-Klasse",
"self_paced": "Eigenständig"
}, },
"landing_page": { "landing_page": {
"start_course": "Kurs starten", "start_course": "Kurs starten",
@@ -763,7 +767,11 @@
"course_name_placeholder": "Ihr Kursname", "course_name_placeholder": "Ihr Kursname",
"short_description": "kurze Beschreibung", "short_description": "kurze Beschreibung",
"short_description_placeholder": "Eine kleine Beschreibung zu diesem Kurs", "short_description_placeholder": "Eine kleine Beschreibung zu diesem Kurs",
"button": "Beenden" "button": "Beenden",
"type": "Kurstyp",
"type_selector_title": "Welche Art von Kurs suchen Sie?",
"back": "Zurück",
"next": "Nächste"
}, },
"course_card": { "course_card": {
"empty_title": "Keine Kurse erstellt", "empty_title": "Keine Kurse erstellt",
@@ -779,7 +787,8 @@
"description": "Beschreibung", "description": "Beschreibung",
"lessons": "Unterricht", "lessons": "Unterricht",
"students": "Studenten", "students": "Studenten",
"published": "Veröffentlicht" "published": "Veröffentlicht",
"type": "Typ"
}, },
"context_menu": { "context_menu": {
"clone": "Klon", "clone": "Klon",
@@ -1212,5 +1221,12 @@
"resend_in": "Bestätigung erneut senden", "resend_in": "Bestätigung erneut senden",
"seconds": "Sekunden", "seconds": "Sekunden",
"snackbar_error": "Fehler beim Senden des Codes" "snackbar_error": "Fehler beim Senden des Codes"
},
"generic": {
"locked": "Gesperrt",
"unlocked": "Entsperrt"
},
"specialization": {
"course_tag": "Spezialisierung"
} }
} }
@@ -227,7 +227,7 @@
"get_started": "Get Started", "get_started": "Get Started",
"personal_profile": { "personal_profile": {
"title": "Upload a profile picture and update username", "title": "Upload a profile picture and update username",
"desc": "Personalize and a human touch making interactions more personal and memorable", "desc": "Personalize and add a human touch making interactions more personal and memorable",
"button_label": "Update Profile" "button_label": "Update Profile"
}, },
"organization_profile": { "organization_profile": {
@@ -433,16 +433,21 @@
"description": "Description", "description": "Description",
"lessons": "Lessons", "lessons": "Lessons",
"students": "Students", "students": "Students",
"published": "Published" "published": "Published",
"type": "Type"
} }
}, },
"new_course_modal": { "new_course_modal": {
"heading": "Create a Course", "heading": "Create a Course",
"course_name": "Course name", "course_name": "Course name",
"type": "Course type",
"course_name_placeholder": "Your course name", "course_name_placeholder": "Your course name",
"short_description": "Short Description", "short_description": "Short Description",
"short_description_placeholder": "A little description about this course", "short_description_placeholder": "A little description about this course",
"button": "Finish" "type_selector_title": "What type of course are you looking at?",
"button": "Finish",
"back": "Back",
"next": "Next"
} }
}, },
@@ -653,7 +658,7 @@
"yes": "Yes, delete" "yes": "Yes, delete"
}, },
"edit_mode": { "edit_mode": {
"delete_modal": "Delete Modal", "delete_modal": "Delete Exercise",
"sure": "Are you sure you want to delete this exercise?", "sure": "Are you sure you want to delete this exercise?",
"no": "No, cancel", "no": "No, cancel",
"yes": "Yes, delete", "yes": "Yes, delete",
@@ -978,13 +983,17 @@
"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",
"course_avail": "Make the Course available for Download for registered students", "course_avail": "Make the Course available for Download for registered students",
"download": "Downlaod", "download": "Download",
"allow": "Allow New Students", "allow": "Allow New Students",
"access": "Allow new students to access this course", "access": "Allow new students to access this course",
"publish": "Publish Course", "publish": "Publish Course",
"determines": "This determines if your course displays on your landing page", "determines": "This determines if your course displays on your landing page",
"published": "Published", "published": "Published",
"unpublished": "Unpublished", "unpublished": "Unpublished",
"type": "Course type",
"course_type_desc": "Update the course type for a more personalized experience",
"live_class": "Live class",
"self_paced": "Self paced",
"delete": "Delete Course", "delete": "Delete Course",
"delete_text": "Delete this course,this action cannot be undone", "delete_text": "Delete this course,this action cannot be undone",
"save": "Save Changes" "save": "Save Changes"
@@ -1247,8 +1256,17 @@
} }
}, },
"specialization": {
"course_tag": "Specialization"
},
"content": { "content": {
"heading": "Internationalization in SvelteKit", "heading": "Internationalization in SvelteKit",
"toggle_label": "Select Preferred Language" "toggle_label": "Select Preferred Language"
},
"generic": {
"locked": "Locked",
"unlocked": "Unlocked"
} }
} }
@@ -263,7 +263,11 @@
"unpublished": "Inédito", "unpublished": "Inédito",
"delete": "Eliminar curso", "delete": "Eliminar curso",
"delete_text": "Eliminar este curso, esta acción no se puede deshacer", "delete_text": "Eliminar este curso, esta acción no se puede deshacer",
"save": "Guardar cambios" "save": "Guardar cambios",
"type": "Tipo de curso",
"course_type_desc": "Actualiza el tipo de curso para una experiencia más personalizada",
"live_class": "clase en vivo",
"self_paced": "Ritmo propio"
}, },
"landing_page": { "landing_page": {
"start_course": "Iniciar curso", "start_course": "Iniciar curso",
@@ -763,7 +767,11 @@
"course_name_placeholder": "Nombre de tu curso", "course_name_placeholder": "Nombre de tu curso",
"short_description": "Breve descripción", "short_description": "Breve descripción",
"short_description_placeholder": "Una pequeña descripción sobre este curso.", "short_description_placeholder": "Una pequeña descripción sobre este curso.",
"button": "Finalizar" "button": "Finalizar",
"type": "Tipo de curso",
"type_selector_title": "¿Qué tipo de curso estás mirando?",
"back": "Atrás",
"next": "Próximo"
}, },
"course_card": { "course_card": {
"empty_title": "No hay cursos creados", "empty_title": "No hay cursos creados",
@@ -779,7 +787,8 @@
"description": "Descripción", "description": "Descripción",
"lessons": "Lecciones", "lessons": "Lecciones",
"students": "Estudiantes", "students": "Estudiantes",
"published": "Publicado" "published": "Publicado",
"type": "Tipo"
}, },
"context_menu": { "context_menu": {
"clone": "Clon", "clone": "Clon",
@@ -1212,5 +1221,12 @@
"resend_in": "Reenviar verificación en", "resend_in": "Reenviar verificación en",
"seconds": "segundos", "seconds": "segundos",
"snackbar_error": "error al enviar código" "snackbar_error": "error al enviar código"
},
"generic": {
"locked": "bloqueado",
"unlocked": "desbloqueado"
},
"specialization": {
"course_tag": "Especialización"
} }
} }
@@ -263,7 +263,11 @@
"unpublished": "Inédit", "unpublished": "Inédit",
"delete": "Supprimer un cours", "delete": "Supprimer un cours",
"delete_text": "Supprimer ce cours, cette action est irréversible", "delete_text": "Supprimer ce cours, cette action est irréversible",
"save": "Sauvegarder les modifications" "save": "Sauvegarder les modifications",
"type": "Type de cours",
"course_type_desc": "Mettez à jour le type de cours pour une expérience plus personnalisée",
"live_class": "Cours en direct",
"self_paced": "À votre rythme"
}, },
"landing_page": { "landing_page": {
"start_course": "Commencer le cours", "start_course": "Commencer le cours",
@@ -763,7 +767,11 @@
"course_name_placeholder": "Le nom de votre cours", "course_name_placeholder": "Le nom de votre cours",
"short_description": "brève description", "short_description": "brève description",
"short_description_placeholder": "Une petite description de ce cours", "short_description_placeholder": "Une petite description de ce cours",
"button": "Finition" "button": "Finition",
"type": "Type de cours",
"type_selector_title": "Quel type de cours envisagez-vous ?",
"back": "Dos",
"next": "Suivant"
}, },
"course_card": { "course_card": {
"empty_title": "Aucun cours créé", "empty_title": "Aucun cours créé",
@@ -779,7 +787,8 @@
"description": "Description", "description": "Description",
"lessons": "Cours", "lessons": "Cours",
"students": "Étudiants", "students": "Étudiants",
"published": "Publié" "published": "Publié",
"type": "Taper"
}, },
"context_menu": { "context_menu": {
"clone": "Cloner", "clone": "Cloner",
@@ -1212,5 +1221,12 @@
"resend_in": "Renvoyer la vérification dans", "resend_in": "Renvoyer la vérification dans",
"seconds": "secondes", "seconds": "secondes",
"snackbar_error": "erreur lors de l'envoi du code" "snackbar_error": "erreur lors de l'envoi du code"
},
"generic": {
"locked": "Fermé à clé",
"unlocked": "Débloqué"
},
"specialization": {
"course_tag": "Spécialisation"
} }
} }
@@ -263,7 +263,11 @@
"unpublished": "अप्रकाशित", "unpublished": "अप्रकाशित",
"delete": "पाठ्यक्रम हटाएँ", "delete": "पाठ्यक्रम हटाएँ",
"delete_text": "इस कोर्स को हटा दें, इस कार्रवाई को पूर्ववत नहीं किया जा सकता", "delete_text": "इस कोर्स को हटा दें, इस कार्रवाई को पूर्ववत नहीं किया जा सकता",
"save": "परिवर्तनों को सुरक्षित करें" "save": "परिवर्तनों को सुरक्षित करें",
"type": "कोर्स का प्रकार",
"course_type_desc": "अधिक वैयक्तिकृत अनुभव के लिए पाठ्यक्रम प्रकार को अपडेट करें",
"live_class": "लाइव क्लास",
"self_paced": "स्वयं गति"
}, },
"landing_page": { "landing_page": {
"start_course": "कोर्स प्रारंभ करें", "start_course": "कोर्स प्रारंभ करें",
@@ -763,7 +767,11 @@
"course_name_placeholder": "आपके पाठ्यक्रम का नाम", "course_name_placeholder": "आपके पाठ्यक्रम का नाम",
"short_description": "संक्षिप्त वर्णन", "short_description": "संक्षिप्त वर्णन",
"short_description_placeholder": "इस कोर्स के बारे में थोड़ा विवरण", "short_description_placeholder": "इस कोर्स के बारे में थोड़ा विवरण",
"button": "खत्म करना" "button": "खत्म करना",
"type": "कोर्स का प्रकार",
"type_selector_title": "आप किस प्रकार का कोर्स देख रहे हैं?",
"back": "पीछे",
"next": "अगला"
}, },
"course_card": { "course_card": {
"empty_title": "कोई पाठ्यक्रम नहीं बनाया गया", "empty_title": "कोई पाठ्यक्रम नहीं बनाया गया",
@@ -779,7 +787,8 @@
"description": "विवरण", "description": "विवरण",
"lessons": "पाठ", "lessons": "पाठ",
"students": "छात्र", "students": "छात्र",
"published": "प्रकाशित" "published": "प्रकाशित",
"type": "प्रकार"
}, },
"context_menu": { "context_menu": {
"clone": "क्लोन", "clone": "क्लोन",
@@ -1212,5 +1221,12 @@
"resend_in": "सत्यापन पुनः भेजें", "resend_in": "सत्यापन पुनः भेजें",
"seconds": "सेकंड", "seconds": "सेकंड",
"snackbar_error": "कोड भेजते समय त्रुटि" "snackbar_error": "कोड भेजते समय त्रुटि"
},
"generic": {
"locked": "बंद",
"unlocked": "अनलॉक किया"
},
"specialization": {
"course_tag": "विशेषज्ञता"
} }
} }
@@ -263,7 +263,11 @@
"unpublished": "Não publicado", "unpublished": "Não publicado",
"delete": "Excluir curso", "delete": "Excluir curso",
"delete_text": "Exclua este curso, esta ação não pode ser desfeita", "delete_text": "Exclua este curso, esta ação não pode ser desfeita",
"save": "Salvar alterações" "save": "Salvar alterações",
"type": "Tipo de curso",
"course_type_desc": "Atualize o tipo de curso para uma experiência mais personalizada",
"live_class": "Aula ao vivo",
"self_paced": "Individualizado"
}, },
"landing_page": { "landing_page": {
"start_course": "Iniciar curso", "start_course": "Iniciar curso",
@@ -763,7 +767,11 @@
"course_name_placeholder": "Nome do seu curso", "course_name_placeholder": "Nome do seu curso",
"short_description": "Pequena descrição", "short_description": "Pequena descrição",
"short_description_placeholder": "Uma pequena descrição sobre este curso", "short_description_placeholder": "Uma pequena descrição sobre este curso",
"button": "Terminar" "button": "Terminar",
"type": "Tipo de curso",
"type_selector_title": "Que tipo de curso você está procurando?",
"back": "Voltar",
"next": "Próximo"
}, },
"course_card": { "course_card": {
"empty_title": "Nenhum curso criado", "empty_title": "Nenhum curso criado",
@@ -779,7 +787,8 @@
"description": "Descrição", "description": "Descrição",
"lessons": "Lições", "lessons": "Lições",
"students": "Alunos", "students": "Alunos",
"published": "Publicados" "published": "Publicados",
"type": "Tipo"
}, },
"context_menu": { "context_menu": {
"clone": "Clone", "clone": "Clone",
@@ -1212,5 +1221,12 @@
"resend_in": "Reenviar verificação em", "resend_in": "Reenviar verificação em",
"seconds": "segundos", "seconds": "segundos",
"snackbar_error": "erro ao enviar código" "snackbar_error": "erro ao enviar código"
},
"generic": {
"locked": "Bloqueado",
"unlocked": "Desbloqueado"
},
"specialization": {
"course_tag": "Especialização"
} }
} }
@@ -263,7 +263,11 @@
"unpublished": "Неопубликовано", "unpublished": "Неопубликовано",
"delete": "Удалить курс", "delete": "Удалить курс",
"delete_text": "Удалите этот курс, это действие нельзя отменить.", "delete_text": "Удалите этот курс, это действие нельзя отменить.",
"save": "Сохранить изменения" "save": "Сохранить изменения",
"type": "Тип курса",
"course_type_desc": "Обновите тип курса для более персонализированного опыта.",
"live_class": "Живой урок",
"self_paced": "Самостоятельный темп"
}, },
"landing_page": { "landing_page": {
"start_course": "Начать курс", "start_course": "Начать курс",
@@ -763,7 +767,11 @@
"course_name_placeholder": "Название вашего курса", "course_name_placeholder": "Название вашего курса",
"short_description": "Краткое описание", "short_description": "Краткое описание",
"short_description_placeholder": "Небольшое описание этого курса", "short_description_placeholder": "Небольшое описание этого курса",
"button": "Заканчивать" "button": "Заканчивать",
"type": "Тип курса",
"type_selector_title": "Какой тип курса вы рассматриваете?",
"back": "Назад",
"next": "Следующий"
}, },
"course_card": { "course_card": {
"empty_title": "Курсы не созданы", "empty_title": "Курсы не созданы",
@@ -779,7 +787,8 @@
"description": "Описание", "description": "Описание",
"lessons": "Уроки", "lessons": "Уроки",
"students": "Студенты", "students": "Студенты",
"published": "Опубликовано" "published": "Опубликовано",
"type": "Тип"
}, },
"context_menu": { "context_menu": {
"clone": "Клонировать", "clone": "Клонировать",
@@ -1212,5 +1221,12 @@
"resend_in": "Повторно отправить подтверждение в", "resend_in": "Повторно отправить подтверждение в",
"seconds": "секунды", "seconds": "секунды",
"snackbar_error": "ошибка при отправке кода" "snackbar_error": "ошибка при отправке кода"
},
"generic": {
"locked": "Заблокировано",
"unlocked": "Разблокировано"
},
"specialization": {
"course_tag": "Специализация"
} }
} }
@@ -263,7 +263,11 @@
"unpublished": "Chưa được xuất bản", "unpublished": "Chưa được xuất bản",
"delete": "Xóa khóa học", "delete": "Xóa khóa học",
"delete_text": "Xóa khóa học này, hành động này không thể hoàn tác", "delete_text": "Xóa khóa học này, hành động này không thể hoàn tác",
"save": "Lưu thay đổi" "save": "Lưu thay đổi",
"type": "Loại khóa học",
"course_type_desc": "Cập nhật loại khóa học để có trải nghiệm cá nhân hóa hơn",
"live_class": "Lớp học trực tiếp",
"self_paced": "Tự nhịp độ"
}, },
"landing_page": { "landing_page": {
"start_course": "Bắt đầu khóa học", "start_course": "Bắt đầu khóa học",
@@ -763,7 +767,11 @@
"course_name_placeholder": "Tên khóa học của bạn", "course_name_placeholder": "Tên khóa học của bạn",
"short_description": "Mô tả ngắn", "short_description": "Mô tả ngắn",
"short_description_placeholder": "Một chút mô tả về khóa học này", "short_description_placeholder": "Một chút mô tả về khóa học này",
"button": "Hoàn thành" "button": "Hoàn thành",
"type": "Loại khóa học",
"type_selector_title": "Bạn đang xem loại khóa học nào?",
"back": "Mặt sau",
"next": "Kế tiếp"
}, },
"course_card": { "course_card": {
"empty_title": "Không có khóa học nào được tạo", "empty_title": "Không có khóa học nào được tạo",
@@ -779,7 +787,8 @@
"description": "Sự miêu tả", "description": "Sự miêu tả",
"lessons": "Những bài học", "lessons": "Những bài học",
"students": "Sinh viên", "students": "Sinh viên",
"published": "Được phát hành" "published": "Được phát hành",
"type": "Kiểu"
}, },
"context_menu": { "context_menu": {
"clone": "Dòng vô tính", "clone": "Dòng vô tính",
@@ -1212,5 +1221,12 @@
"resend_in": "Gửi lại xác minh trong", "resend_in": "Gửi lại xác minh trong",
"seconds": "giây", "seconds": "giây",
"snackbar_error": "lỗi khi gửi mã" "snackbar_error": "lỗi khi gửi mã"
},
"generic": {
"locked": "Đã khóa",
"unlocked": "Đã mở khóa"
},
"specialization": {
"course_tag": "Chuyên môn"
} }
} }
@@ -181,9 +181,14 @@ export interface Waitinglist {
created_at?: string; created_at?: string;
} }
export enum COURSE_TYPE {
SELF_PACED = 'SELF_PACED',
LIVE_CLASS = 'LIVE_CLASS'
}
export interface Course { export interface Course {
title?: any; // type unknown; title?: any; // type unknown;
description: string; // type unknown; description: string; // type unknown;
type: COURSE_TYPE;
overview?: any; // type unknown; overview?: any; // type unknown;
id?: string /* primary key */; id?: string /* primary key */;
created_at: string; created_at: string;
@@ -225,10 +225,10 @@
} }
function getPageRoles(org: CurrentOrg) { function getPageRoles(org: CurrentOrg) {
const roles = [1, 2]; const roles: number[] = [1, 2];
if (org.customization.course.newsfeed) { if (org.customization.course.newsfeed) {
roles.push(3); roles.concat([3]);
} }
return roles; return roles;
@@ -14,7 +14,7 @@
} from '$lib/components/Course/components/Lesson/store/lessons'; } from '$lib/components/Course/components/Lesson/store/lessons';
import PageBody from '$lib/components/PageBody/index.svelte'; import PageBody from '$lib/components/PageBody/index.svelte';
import AddLessonModal from '$lib/components/Course/components/Lesson/AddLessonModal.svelte'; import NewLessonModal from '$lib/components/Course/components/Lesson/NewLessonModal.svelte';
import DeleteLessonConfirmation from '$lib/components/Course/components/Lesson/DeleteLessonConfirmation.svelte'; import DeleteLessonConfirmation from '$lib/components/Course/components/Lesson/DeleteLessonConfirmation.svelte';
import { handleAddLessonWidget } from '$lib/components/Course/components/Navigation/store'; import { handleAddLessonWidget } from '$lib/components/Course/components/Navigation/store';
import { Video, Calendar } from 'carbon-icons-svelte'; import { Video, Calendar } from 'carbon-icons-svelte';
@@ -28,6 +28,7 @@
import type { Course, Lesson } from '$lib/utils/types'; import type { Course, Lesson } from '$lib/utils/types';
import { t } from '$lib/utils/functions/translations.js'; import { t } from '$lib/utils/functions/translations.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { COURSE_TYPE } from '$lib/utils/types';
export let data; export let data;
@@ -126,7 +127,7 @@
</script> </script>
{#if $handleAddLessonWidget} {#if $handleAddLessonWidget}
<AddLessonModal {isStudent} /> <NewLessonModal {isStudent} />
{/if} {/if}
<DeleteLessonConfirmation <DeleteLessonConfirmation
@@ -217,84 +218,86 @@
{/if} {/if}
<!-- Lesson Educator --> <!-- Lesson Educator -->
<div {#if $course.type === COURSE_TYPE.LIVE_CLASS}
class="mt-2 flex w-4/5 flex-col items-start justify-between lg:flex-row lg:items-center" <div
> class="mt-2 flex w-4/5 flex-col items-start justify-between lg:flex-row lg:items-center"
<div class="lg:mb-0"> >
{#if lessonEditing === lesson.id} <div class="lg:mb-0">
<Select {#if lessonEditing === lesson.id}
bind:value={lesson.profile} <Select
options={$group.tutors} bind:value={lesson.profile}
labelKey="fullname" options={$group.tutors}
className="sm:my-1 w-[100%]" labelKey="fullname"
/> className="sm:my-1 w-[100%]"
{:else if !lesson.profile}
<p class="dark:text-white ml-2 text-sm mb-3">
{$t('course.navItem.lessons.no_tutor')}
</p>
{:else}
<a href="." class="mb-2 flex items-center hover:underline">
<Avatar
className="w-6 h-6"
src={lesson.profile.avatar_url}
name="Avatar placeholder"
/> />
<p class="ml-2 text-sm dark:text-white"> {:else if !lesson.profile}
{lesson.profile.fullname} <p class="dark:text-white ml-2 text-sm mb-3">
{$t('course.navItem.lessons.no_tutor')}
</p> </p>
</a> {:else}
{/if} <a href="." class="mb-2 flex items-center hover:underline">
</div> <Avatar
className="w-6 h-6"
<!-- Lesson Date --> src={lesson.profile.avatar_url}
<div class="flex items-center lg:mb-0"> name="Avatar placeholder"
{#if lessonEditing === lesson.id} />
<DateField <p class="ml-2 text-sm dark:text-white">
value={formatDate(lesson.lesson_at)} {lesson.profile.fullname}
className="p-2 my-2 rounded-md sm:w-[179px] dark:bg-neutral-800 dark:text-white" </p>
onChange={(e) => (lesson.lesson_at = e.target.value)}
/>
{:else}
<div class="mb-2 flex">
<Calendar size={20} class="carbon-icon text-gray-400 dark:text-white" />
<p class="ml-2 text-sm dark:text-white">
{new Date(lesson.lesson_at || '').toDateString()}
</p>
</div>
{/if}
</div>
<!-- Lesson Call url -->
<div class="mb-3 flex items-center lg:mb-0">
{#if lessonEditing === lesson.id}
<TextField
bind:value={lesson.call_url}
autoFocus={true}
className="w-[179px]"
placeholder="https://meet.google.com/mga-dsjs-fmb"
/>
{:else}
<div class="flex">
<Video size={20} class="carbon-icon ml-0.5 text-gray-400 dark:text-white" />
<a
class="text-primary-600 ml-2 text-sm"
href={lesson.call_url || '#'}
target="_blank"
>
{lesson.call_url
? $t('course.navItem.lessons.join_lesson')
: $t('course.navItem.lessons.no_link')}
</a> </a>
</div> {/if}
{/if} </div>
<!-- Lesson Date -->
<div class="flex items-center lg:mb-0">
{#if lessonEditing === lesson.id}
<DateField
value={formatDate(lesson.lesson_at)}
className="p-2 my-2 rounded-md sm:w-[179px] dark:bg-neutral-800 dark:text-white"
onChange={(e) => (lesson.lesson_at = e.target.value)}
/>
{:else}
<div class="mb-2 flex">
<Calendar size={20} class="carbon-icon text-gray-400 dark:text-white" />
<p class="ml-2 text-sm dark:text-white">
{new Date(lesson.lesson_at || '').toDateString()}
</p>
</div>
{/if}
</div>
<!-- Lesson Call url -->
<div class="mb-3 flex items-center lg:mb-0">
{#if lessonEditing === lesson.id}
<TextField
bind:value={lesson.call_url}
autoFocus={true}
className="w-[179px]"
placeholder="https://meet.google.com/mga-dsjs-fmb"
/>
{:else}
<div class="flex">
<Video size={20} class="carbon-icon ml-0.5 text-gray-400 dark:text-white" />
<a
class="text-primary-600 ml-2 text-sm"
href={lesson.call_url || '#'}
target="_blank"
>
{lesson.call_url
? $t('course.navItem.lessons.join_lesson')
: $t('course.navItem.lessons.no_link')}
</a>
</div>
{/if}
</div>
</div> </div>
</div> {/if}
</div> </div>
<!-- 3 dot edit --> <!-- 3 dot edit -->
<div class="flex flex-row"> <div class="flex flex-row">
<RoleBasedSecurity allowedRoles={[1, 2]}> <RoleBasedSecurity allowedRoles={[1, 2]}>
<OverflowMenu size="xl" class="absolute right-0 top-0"> <OverflowMenu size="xl" class={`absolute right-0 top-0`}>
<OverflowMenuItem <OverflowMenuItem
disabled={isStudent} disabled={isStudent}
text={lesson.is_unlocked text={lesson.is_unlocked
@@ -83,6 +83,7 @@
} }
}); });
} }
// TODO WRITE A REDIRECT FUNCTION FOR THIS THIS PAGE
function getPageRoles(org: CurrentOrg) { function getPageRoles(org: CurrentOrg) {
const roles = [1, 2]; const roles = [1, 2];
@@ -245,7 +245,7 @@
const questionAnswer = questionAnswers.find( const questionAnswer = questionAnswers.find(
(answer: { question_id: string }) => answer.question_id == questionId (answer: { question_id: string }) => answer.question_id == questionId
); );
console.log('this is the question answer in the admin side', questionAnswer);
const point = questionAnswerByPoint[questionId]; const point = questionAnswerByPoint[questionId];
totalPoints += parseInt(point, 10); totalPoints += parseInt(point, 10);
@@ -6,24 +6,18 @@
import { fetchCourses } from '$lib/components/Courses/api'; import { fetchCourses } from '$lib/components/Courses/api';
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';
import { courses, courseMetaDeta, coursesComplete } from '$lib/components/Courses/store'; import {
courses,
courseMetaDeta,
coursesComplete,
coursesInProgress
} from '$lib/components/Courses/store';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
const tabs = [
{
label: $t('my_learning.progress'),
value: 1
},
{
label: $t('my_learning.complete'),
value: 2
}
];
let currentTab = tabs[0].value;
let hasFetched = false; let hasFetched = false;
function onChange(tab: 0) { function onChange(tab) {
return () => (currentTab = tab); return () => (currentTab = tab);
} }
@@ -51,6 +45,18 @@
$: if (browser && $profile.id && $currentOrg.id) { $: if (browser && $profile.id && $currentOrg.id) {
getCourses($profile.id, $currentOrg.id); getCourses($profile.id, $currentOrg.id);
} }
$: tabs = [
{
label: `${$t('my_learning.progress')} (${$coursesInProgress.length})`,
value: '1'
},
{
label: `${$t('my_learning.complete')} (${$coursesComplete.length})`,
value: '2'
}
];
$: currentTab = tabs[0].value;
</script> </script>
<section class="max-w-6xl mx-auto"> <section class="max-w-6xl mx-auto">
@@ -63,7 +69,7 @@
<slot:fragment slot="content"> <slot:fragment slot="content">
<TabContent value={tabs[0].value} index={currentTab}> <TabContent value={tabs[0].value} index={currentTab}>
<Courses <Courses
courses={$courses} courses={$coursesInProgress}
emptyTitle={$t('my_learning.not_in_progress')} emptyTitle={$t('my_learning.not_in_progress')}
emptyDescription={$t('my_learning.any_progress')} emptyDescription={$t('my_learning.any_progress')}
/> />
@@ -11,7 +11,8 @@
export let data; export let data;
$: if ($currentOrg.id && data.orgName === '*') { $: if ($currentOrg.id && data.orgName === '*') {
goto(`/org/${$currentOrg.siteName}`); const newUrl = $page.url.pathname.replace('*', $currentOrg.siteName);
goto(newUrl + $page.url.search);
} }
</script> </script>
@@ -1,13 +1,12 @@
<script lang="ts"> <script lang="ts">
import { Search, Dropdown } from 'carbon-components-svelte'; import { Search, Dropdown } from 'carbon-components-svelte';
import { page } from '$app/stores';
import { profile } from '$lib/utils/store/user'; import { profile } from '$lib/utils/store/user';
import { fetchCourses } from '$lib/components/Courses/api'; import { fetchCourses } from '$lib/components/Courses/api';
import Courses from '$lib/components/Courses/index.svelte'; import Courses from '$lib/components/Courses/index.svelte';
import NewCourseModal from '$lib/components/Courses/components/NewCourseModal/index.svelte'; import NewCourseModal from '$lib/components/Courses/components/NewCourseModal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { courses, createCourseModal, courseMetaDeta } from '$lib/components/Courses/store'; import { courses, courseMetaDeta } from '$lib/components/Courses/store';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import { Add } from 'carbon-icons-svelte'; import { Add } from 'carbon-icons-svelte';
import { isMobile } from '$lib/utils/store/useMobile'; import { isMobile } from '$lib/utils/store/useMobile';
import { isOrgAdmin } from '$lib/utils/store/org'; import { isOrgAdmin } from '$lib/utils/store/org';
@@ -19,6 +18,7 @@
import Grid from 'carbon-icons-svelte/lib/Grid.svelte'; import Grid from 'carbon-icons-svelte/lib/Grid.svelte';
import List from 'carbon-icons-svelte/lib/List.svelte'; import List from 'carbon-icons-svelte/lib/List.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation';
export let data; export let data;
@@ -28,12 +28,6 @@
let filteredCourses: Course[]; let filteredCourses: Course[];
let hasFetched = false; let hasFetched = false;
const urlParams = new URLSearchParams($page.url.search);
if (urlParams.get('create') === 'true') {
$createCourseModal.open = true;
}
async function getCourses(userId: string | undefined, orgId: string) { async function getCourses(userId: string | undefined, orgId: string) {
if (cantFetch && typeof cantFetch === 'boolean' && orgId && !hasFetched) { if (cantFetch && typeof cantFetch === 'boolean' && orgId && !hasFetched) {
// only show is loading when fetching for the first time // only show is loading when fetching for the first time
@@ -86,6 +80,10 @@
localStorage.setItem('courseView', preference); localStorage.setItem('courseView', preference);
}; };
const openNewCourseModal = () => {
goto($currentOrgPath + '/courses?create=true');
};
onMount(() => { onMount(() => {
const courseView = localStorage.getItem('courseView') as 'grid' | 'list' | null; const courseView = localStorage.getItem('courseView') as 'grid' | 'list' | null;
@@ -107,7 +105,7 @@
<div class="flex items-center justify-between mb-5"> <div class="flex items-center justify-between mb-5">
<h1 class="dark:text-white text-2xl md:text-3xl font-bold">{$t('courses.heading')}</h1> <h1 class="dark:text-white text-2xl md:text-3xl font-bold">{$t('courses.heading')}</h1>
{#if $isMobile} {#if $isMobile}
<PrimaryButton isDisabled={!$isOrgAdmin} onClick={() => ($createCourseModal.open = true)}> <PrimaryButton isDisabled={!$isOrgAdmin} onClick={openNewCourseModal}>
<Add size={24} /> <Add size={24} />
</PrimaryButton> </PrimaryButton>
{:else} {:else}
@@ -115,7 +113,7 @@
label={$t('courses.heading_button')} label={$t('courses.heading_button')}
variant={VARIANTS.CONTAINED_DARK} variant={VARIANTS.CONTAINED_DARK}
isDisabled={!$isOrgAdmin} isDisabled={!$isOrgAdmin}
onClick={() => ($createCourseModal.open = true)} onClick={openNewCourseModal}
/> />
{/if} {/if}
</div> </div>
@@ -0,0 +1,37 @@
create type "public"."COURSE_TYPE" as enum ('SELF_PACED', 'LIVE_CLASS');
alter table "public"."course" add column "type" "COURSE_TYPE" default 'LIVE_CLASS'::"COURSE_TYPE";
drop function if exists "public"."get_courses"(org_id_arg uuid, profile_id_arg uuid);
create
or replace function public.get_courses (org_id_arg uuid, profile_id_arg uuid) returns table (
id uuid,
org_id uuid,
title character varying,
slug character varying,
description character varying,
logo text,
banner_image text,
cost bigint,
currency character varying,
is_published boolean,
profile_id uuid,
role_id bigint,
total_lessons bigint,
total_students bigint,
progress_rate bigint,
type "COURSE_TYPE"
) language plpgsql as $function$
BEGIN
Return query
select course.id, organization.id AS org_id, course.title, course.slug, course.description, course.logo, course.banner_image, course.cost, course.currency, course.is_published, groupmember.profile_id, groupmember.role_id, (select COUNT(*) from lesson as l where l.course_id = course.id) AS total_lessons, (select COUNT(*) from groupmember as gm where gm.group_id = course.group_id AND gm.role_id = 3) as total_students, (select COUNT(*) from lesson_completion as lc join lesson as l on l.id = lc.lesson_id where l.course_id = course.id and lc.is_complete = true and lc.profile_id = profile_id_arg) AS progress_rate, course.type as type
from course
join "group" on "group".id = course.group_id
join groupmember on groupmember.group_id = course.group_id
join organization on organization.id = "group".organization_id
where course.status = 'ACTIVE' AND organization.id = org_id_arg
-- GROUP BY course.id, groupmember.profile_id
ORDER BY course.created_at DESC;
END;
$function$;