This commit is contained in:
Rotimi Best
2023-09-16 22:27:21 +01:00
78 changed files with 1838 additions and 819 deletions
+5
View File
@@ -14,6 +14,11 @@ html {
#calendar .grid { #calendar .grid {
--sc-theme-calendar-colors-background-highlight: var(--main-primary-color); --sc-theme-calendar-colors-background-highlight: var(--main-primary-color);
} }
html .dark {
--scrollbarBG: #1f2937;
}
* { * {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--thumbBG) var(--scrollbarBG); scrollbar-color: var(--thumbBG) var(--scrollbarBG);
+40 -3
View File
@@ -6,12 +6,15 @@
import SendAlt from 'carbon-icons-svelte/lib/SendAlt.svelte'; import SendAlt from 'carbon-icons-svelte/lib/SendAlt.svelte';
import ChartPie from 'carbon-icons-svelte/lib/ChartPie.svelte'; import ChartPie from 'carbon-icons-svelte/lib/ChartPie.svelte';
import IconButton from '$lib/components/IconButton/index.svelte'; import IconButton from '$lib/components/IconButton/index.svelte';
import Settings from 'carbon-icons-svelte/lib/Settings.svelte';
import { apps } from './store';
import QandA from './components/QandA/index.svelte'; import QandA from './components/QandA/index.svelte';
import LiveChat from './components/LiveChat/index.svelte'; import LiveChat from './components/LiveChat/index.svelte';
import Notes from './components/Notes/index.svelte'; import Notes from './components/Notes/index.svelte';
import Poll from './components/Poll/index.svelte'; import Poll from './components/Poll/index.svelte';
import APPS_CONSTANTS from './constants'; import APPS_CONSTANTS from './constants';
import { OverflowMenu, OverflowMenuItem } from 'carbon-components-svelte';
let selectedApp; let selectedApp;
@@ -48,8 +51,21 @@
}); });
</script> </script>
<div class="root"> <div class={`root ${$apps.open ? 'open dark:bg-slate-800' : 'close dark:bg-slate-800'}`}>
<div class="apps"> <div class={`apps`}>
<div class="lg:hidden">
<IconButton
buttonClassName="lg:hidden"
toolTipProps={{ title: 'Settings', hotkeys: ['A', '0'] }}
onClick={() => {
handleAppClick;
$apps.dropdown = !$apps.dropdown;
}}
>
<Settings size={24} class="carbon-icon dark:text-white lg:hidden" />
</IconButton>
</div>
<IconButton <IconButton
toolTipProps={{ title: 'Live Chat', hotkeys: ['A', '1'] }} toolTipProps={{ title: 'Live Chat', hotkeys: ['A', '1'] }}
value={APPS_CONSTANTS.APPS.LIVE_CHAT} value={APPS_CONSTANTS.APPS.LIVE_CHAT}
@@ -104,7 +120,7 @@
<style lang="scss"> <style lang="scss">
.root { .root {
height: 93vh; height: calc(100vh - 48px);
position: sticky; position: sticky;
top: 0; top: 0;
border-left: 1px solid var(--border-color); border-left: 1px solid var(--border-color);
@@ -129,4 +145,25 @@
overflow: auto; overflow: auto;
} }
} }
@media screen and (max-width: 1024px) {
.root {
position: absolute;
height: calc(100vh - 109px);
top: 109px;
right: 0;
z-index: 999;
}
.open {
position: absolute;
transform: translateX(0);
transition: all 0.2s ease-in-out;
}
.close {
position: absolute;
transform: translateX(100%);
display: none;
transition: all 0.2s ease-in-out;
}
}
</style> </style>
+6
View File
@@ -0,0 +1,6 @@
import { writable } from 'svelte/store';
export const apps = writable({
open: false,
dropdown: false
});
+16 -10
View File
@@ -1,17 +1,20 @@
<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 { VARIANTS } from '$lib/components/PrimaryButton/constants';
import GoogleIconColored from '../Icons/GoogleIconColored.svelte'; import GoogleIconColored from '../Icons/GoogleIconColored.svelte';
import Avatar from '$lib/components/Avatar/index.svelte'; import Avatar from '$lib/components/Avatar/index.svelte';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
import type { SupabaseClient } from '@supabase/supabase-js';
export let supabase; export let supabase: SupabaseClient;
export let handleSubmit; export let handleSubmit = () => {};
export let isLogin = true; export let isLogin = true;
export let showOnlyContent = false; export let showOnlyContent = false;
export let isLoading = false; export let isLoading = false;
export let showLogo = false; export let showLogo = false;
export let formRef; export let formRef;
export let hideGoogleAuth = false;
async function signInWithGoogle() { async function signInWithGoogle() {
if (isLoading) { if (isLoading) {
@@ -19,12 +22,11 @@
} }
try { try {
console.log('signInWithGoogle'); console.log('signInWithGoogle');
const { user, session, error } = await supabase.auth.signInWithOAuth({ const { error, data } = await supabase.auth.signInWithOAuth({
provider: 'google' provider: 'google'
}); });
console.log('user', user); console.log('data', data);
console.log('session', session);
console.log('error', error); console.log('error', error);
} catch (error) { } catch (error) {
console.log('catch error', error); console.log('catch error', error);
@@ -57,7 +59,7 @@
> >
<slot /> <slot />
</form> </form>
{#if !showOnlyContent} {#if !showOnlyContent && !hideGoogleAuth}
<div class="w-10/12 mb-3"> <div class="w-10/12 mb-3">
<p class="dark:text-white text-sm mb-5">or sign up with:</p> <p class="dark:text-white text-sm mb-5">or sign up with:</p>
<PrimaryButton <PrimaryButton
@@ -75,10 +77,14 @@
{#if !showOnlyContent} {#if !showOnlyContent}
<div class="w-full p-6 border-t border-grey text-center"> <div class="w-full p-6 border-t border-grey text-center">
{#if isLogin} {#if isLogin}
Not registered yet? <a class="hover:underline text-primary-700" href="/signup">Sign up</a> Not registered yet? <a
class="hover:underline text-primary-700"
href="/signup{$page.url.search}">Sign up</a
>
{:else} {:else}
Already have an account? <a class="hover:underline text-primary-700" href="/login" Already have an account? <a
>Log In</a class="hover:underline text-primary-700"
href="/login{$page.url.search}">Log In</a
> >
{/if} {/if}
</div> </div>
@@ -2,6 +2,7 @@
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import ArrowUpRightIcon from '$lib/components/Icons/ArrowTopRight.svelte'; import ArrowUpRightIcon from '$lib/components/Icons/ArrowTopRight.svelte';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
import { isMobile } from '$lib/utils/store/useMobile';
export let className = ''; export let className = '';
</script> </script>
@@ -12,7 +13,9 @@
class="{className} ml-2 hover:no-underline" class="{className} ml-2 hover:no-underline"
> >
<PrimaryButton> <PrimaryButton>
View site {#if !$isMobile}
View site
{/if}
<ArrowUpRightIcon /> <ArrowUpRightIcon />
</PrimaryButton> </PrimaryButton>
</a> </a>
@@ -45,8 +45,8 @@
</svelte:head> </svelte:head>
<main class="md:-ml-4 md:-mr-4 px-2"> <main class="md:-ml-4 md:-mr-4 px-2">
<div class="flex-1 flex flex-col md:flex-row justify-between gap-3 w-full mb-3 h-4/5"> <div class="flex-1 flex flex-col lg:flex-row justify-between gap-3 w-full mb-3 h-4/5">
<section class="w-full md:w-2/5 h-full"> <section class="w-full lg:w-2/5 h-full">
<strong class="my-2 text-base font-semibold text-black dark:text-gray-100" <strong class="my-2 text-base font-semibold text-black dark:text-gray-100"
>Certificate settings</strong >Certificate settings</strong
> >
@@ -99,7 +99,7 @@
</div> </div>
</section> </section>
<section <section
class="bg-gray-100 dark:bg-gray-700 flex justify-center items-center rounded-md w-full md:w-3/5" class="bg-gray-100 dark:bg-gray-700 flex justify-center items-center rounded-md w-full lg:w-3/5"
> >
<div class="certificate-container flex justify-center items-center h-5/6"> <div class="certificate-container flex justify-center items-center h-5/6">
{#if $course.certificate_theme === 'professional'} {#if $course.certificate_theme === 'professional'}
@@ -55,7 +55,7 @@
<Modal <Modal
onClose={() => (openModal = false)} onClose={() => (openModal = false)}
bind:open={openModal} bind:open={openModal}
width="w-[60%]" width="w-[80%] md:w-[60%]"
containerClass="overflow-hidden" containerClass="overflow-hidden"
modalHeading="Add New Lesson" modalHeading="Add New Lesson"
> >
@@ -165,8 +165,8 @@
<p class="dark:text-white mt-4 text-sm">Created Jul 3, 2021</p> <p class="dark:text-white mt-4 text-sm">Created Jul 3, 2021</p>
</a> </a>
{:else} {:else}
<Box className="mt-3"> <Box className="mt-3 text-center">
<div class="flex justify-between flex-col items-center w-96"> <div class="flex justify-between flex-col items-center w-[80%] md:w-96">
<img src="/images/empty-exercise-icon.svg" alt="Exercise" class="my-2.5 mx-auto" /> <img src="/images/empty-exercise-icon.svg" alt="Exercise" class="my-2.5 mx-auto" />
<h2 class="text-xl my-1.5 font-normal">No exercises added for this lesson</h2> <h2 class="text-xl my-1.5 font-normal">No exercises added for this lesson</h2>
<p class="text-sm text-center text-slate-500"> <p class="text-sm text-center text-slate-500">
@@ -11,13 +11,15 @@
const onChange = (tab) => () => (currentTab = tab); const onChange = (tab) => () => (currentTab = tab);
</script> </script>
<section class="w-full h-full flex flex-row items-start gap-3"> <section class="w-full h-full flex flex-col md:flex-row items-start gap-3">
<div class="flex-1 flex-col"> <div class="flex flex-row md:flex-col gap-2 items-center">
<p class="text-sm font-normal text-start text-[#4F4B4B] dark:text-[#b0a9a9] mb-3">Add By</p> <p class="text-sm font-normal text-start text-[#4F4B4B] dark:text-[#b0a9a9] mb-3 w-full">
Add By
</p>
{#each tabs as item (item.value)} {#each tabs as item (item.value)}
<button <button
on:click={onChange(item.value)} on:click={onChange(item.value)}
class={`w-full px-4 py-3 my-5 border ${ class={`w-full px-4 py-3 my-1 border ${
currentTab === item.value currentTab === item.value
? 'border border-[#0233BD] bg-[#F5F8FE] dark:text-black' ? 'border border-[#0233BD] bg-[#F5F8FE] dark:text-black'
: 'border border-gray-200' : 'border border-gray-200'
@@ -197,13 +197,13 @@
<Modal <Modal
{onClose} {onClose}
bind:open={$uploadCourseVideoStore.isModalOpen} bind:open={$uploadCourseVideoStore.isModalOpen}
width="w-4/5 h-[566px]" width="w-4/5 w-[90%] h-[80%] md:h-[566px]"
modalHeading="Add a Video" modalHeading="Add a Video"
> >
<VideoUploader {lessonId} /> <VideoUploader {lessonId} />
</Modal> </Modal>
<h1 class="text-4xl mt-0"> <h1 class="text-4xl mt-0 capitalize">
{lessonTitle} {lessonTitle}
</h1> </h1>
@@ -386,7 +386,7 @@
</article> </article>
{/if} {/if}
{:else} {:else}
<Box> <Box className="text-center">
<img src="/no-video.svg" alt="Video not found" /> <img src="/no-video.svg" alt="Video not found" />
<h3 class="text-xl font-normal dark:text-white py-2"> <h3 class="text-xl font-normal dark:text-white py-2">
No note, video or slide added for this lesson yet No note, video or slide added for this lesson yet
@@ -24,6 +24,7 @@
import { updateCourse } from '$lib/utils/services/courses'; import { updateCourse } from '$lib/utils/services/courses';
import { NavClasses } from '$lib/utils/constants/reusableClass'; import { NavClasses } from '$lib/utils/constants/reusableClass';
import { isMobile } from '$lib/utils/store/useMobile'; import { isMobile } from '$lib/utils/store/useMobile';
import { menu } from '$lib/components/Org/store';
// export let lessonId; // export let lessonId;
export let path; export let path;
@@ -32,12 +33,14 @@
let show = null; let show = null;
let isLessonActive = false; let isLessonActive = false;
const toggleSidebar = () => {
$menu.hidden = !$menu.hidden;
};
function handleMainGroupClick(href) { function handleMainGroupClick(href) {
return () => { return () => {
if ($isMobile) {
setTimeout(() => (show = false), 500);
}
goto(href); goto(href);
toggleSidebar();
}; };
} }
@@ -146,145 +149,66 @@
<Settings /> <Settings />
<div class="root z-10 {!show && 'hide'} {$isMobile ? 'fixed shadow-xl' : 'sticky'}"> <aside
{#if show} class={`${
<div class="relative h-full dark:bg-gray-800"> $menu.hidden
<!-- <PageNav title="Back to courses" paddingClass="pl-2" titleSize="text-md"> ? '-translate-x-[100%] absolute md:translate-x-0 md:relative z-[40]'
<slot:fragment slot="image"> : 'translate-x-0 absolute md:relative z-[40]'
<Avatar src={$course.logo} /> } transition w-[350px] min-w-[350px] md:max-w-[350px] bg-gray-100 dark:bg-gray-800 h-[calc(100vh-48px)] overflow-y-auto border border-l-0 border-t-0 border-b-0 border-r-1`}
<IconButton >
value="toggle" <div class="h-full flex flex-col">
onClick={() => goto(`${$currentOrgPath}/courses`)} <ul class="my-5">
size="small" {#each navItems as navItem}
contained={true} {#if !navItem.show || (typeof navItem.show === 'function' && navItem.show())}
<NavExpandable
label={navItem.label}
handleClick={handleMainGroupClick(navItem.to)}
isGroupActive={(path || $page.url.pathname) === navItem.to}
isExpanded={isLessonActive}
total={navItem.isLecture ? ($lessons || []).length : 0}
> >
<ArrowLeftIcon size={20} class="carbon-icon dark:text-white" /> {#if navItem.isLecture}
</IconButton> {#each $lessons as item, index}
</slot:fragment> <a
<slot:fragment slot="widget"> class="pl-7 w-[95%] text-[0.80rem] mb-1 text-black dark:text-white {isStudent &&
<RoleBasedSecurity allowedRoles={[1, 2]}> !item.is_unlocked
<IconButton onClick={() => ($settingsDialog.open = !$settingsDialog.open)}> ? 'cursor-not-allowed'
<SettingsIcon size={20} class="carbon-icon dark:text-white" /> : ''}"
</IconButton> href={isStudent && !item.is_unlocked
</RoleBasedSecurity> ? $page.url.pathname
</slot:fragment> : getLessonsRoute($course.id, item.id)}
</PageNav> --> on:click={toggleSidebar}
<div class="h-full py-3 overflow-y-auto"> aria-disabled={!item.is_unlocked}
{#each navItems as navItem} >
{#if !navItem.show || (typeof navItem.show === 'function' && navItem.show())} <div
<NavExpandable class="flex items-center py-3 px-4 {NavClasses.item} {(
label={navItem.label} path || $page.url.pathname
handleClick={handleMainGroupClick(navItem.to)} ).includes(item.id) && NavClasses.active}"
isGroupActive={(path || $page.url.pathname) === navItem.to}
isExpanded={isLessonActive}
total={navItem.isLecture ? ($lessons || []).length : 0}
>
{#if navItem.isLecture}
{#each $lessons as item, index}
<a
class="pl-7 w-[95%] text-[0.80rem] mb-1 text-black dark:text-white {isStudent &&
!item.is_unlocked
? 'cursor-not-allowed'
: ''}"
href={isStudent && !item.is_unlocked
? $page.url.pathname
: getLessonsRoute($course.id, item.id)}
on:click={() => {
if ($isMobile) {
setTimeout(() => (show = false), 500);
}
}}
aria-disabled={!item.is_unlocked}
> >
<div <TextChip
class="flex items-center py-3 px-4 {NavClasses.item} {( value={getLectureNo(index + 1)}
path || $page.url.pathname className="bg-primary-200 text-primary-600 text-xs mr-2"
).includes(item.id) && NavClasses.active}" size="sm"
> shape="rounded-full"
<TextChip />
value={getLectureNo(index + 1)} <span>{item.title}</span>
className="bg-primary-200 text-primary-600 text-xs mr-2" <span class="grow" />
size="sm" {#if !item.is_unlocked}
shape="rounded-full" <span class="text-md ml-2" title="This lesson is locked.">
/> <LockedIcon class="carbon-icon dark:text-white" />
<span>{item.title}</span> </span>
<span class="grow" /> {:else if item.is_complete}
{#if !item.is_unlocked} <span class="ml-2" title="You have completed this lesson">
<span class="text-md ml-2" title="This lesson is locked."> <CheckmarkFilled class="carbon-icon dark:text-white" />
<LockedIcon class="carbon-icon dark:text-white" /> </span>
</span> {/if}
{:else if item.is_complete} </div>
<span class="ml-2" title="You have completed this lesson"> </a>
<CheckmarkFilled class="carbon-icon dark:text-white" /> {/each}
</span> {/if}
{/if} </NavExpandable>
</div> {/if}
</a> {/each}
{/each} </ul>
{/if}
</NavExpandable>
{/if}
{/each}
</div>
</div>
{/if}
<div class="toggler rounded-full shadow-lg absolute bottom-0">
<IconButton
value="toggle"
onClick={() => (show = !show)}
size={$isMobile ? 'large' : 'small'}
color="text-black"
toolTipProps={$isMobile
? {}
: {
title: 'Toggle sidebar',
hotkeys: ['B'],
direction: 'right'
}}
>
{#if show}
<ChevronLeftIcon size={16} />
{:else}
<ChevronRightIcon size={16} />
{/if}
</IconButton>
</div> </div>
</div> </aside>
<style lang="scss">
.root {
height: calc(100vh - 48px);
display: flex;
flex-direction: column;
min-width: 360px;
max-width: 360px;
top: 0;
border-right: 1px solid var(--border-color);
background-color: rgb(250, 251, 252);
&.hide {
min-width: 15px;
max-width: 15px;
}
}
.footer {
border-top: 1px solid var(--border-color);
}
.toggler {
right: -15px;
z-index: 10;
border: 1px solid var(--border-color);
top: 50px;
height: fit-content;
background: var(--border-color);
}
@media (max-width: 760px) {
.root {
width: 98%;
min-width: unset;
max-width: unset;
}
}
</style>
@@ -1,27 +1,21 @@
<script lang="ts"> <script lang="ts">
import { Popover } from 'carbon-components-svelte';
import Link from 'carbon-icons-svelte/lib/Link.svelte';
import Modal from '$lib/components/Modal/index.svelte'; import Modal from '$lib/components/Modal/index.svelte';
import copy from 'copy-to-clipboard';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import { ROLE } from '$lib/utils/constants/roles'; import { ROLE } from '$lib/utils/constants/roles';
import { addGroupMember, fetchGroup } from '$lib/utils/services/courses'; import { addGroupMember, fetchGroup } from '$lib/utils/services/courses';
import { invitationModal, resetForm } from './store'; import { invitationModal, resetForm } from './store';
import { validateForm } from '$lib/components/Courses/functions';
import { course, setCourse } from '$lib/components/Course/store'; import { course, setCourse } from '$lib/components/Course/store';
import { MultiSelect, Loading } from 'carbon-components-svelte'; import { MultiSelect, Loading } from 'carbon-components-svelte';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
import { getOrgTeam } from '$lib/utils/services/org'; import { getOrgTeam } from '$lib/utils/services/org';
import type { OrgTeamMember } from '$lib/utils/types/org'; import type { OrgTeamMember } from '$lib/utils/types/org';
import { sendTeacherCourseWelcome } from './utils'; import { sendTeacherCourseWelcome } from './utils';
import type { Course } from '$lib/utils/types';
let errors: { import { VARIANTS } from '$lib/components/PrimaryButton/constants';
students: string; import { getStudentInviteLink } from '$lib/utils/functions/course';
tutors: string;
title?: string;
description?: string;
} = {
students: '',
tutors: ''
};
interface Tutor { interface Tutor {
id: number; id: number;
@@ -33,6 +27,8 @@
let selectedIds: Array<number> = []; let selectedIds: Array<number> = [];
let selectedTutors: Tutor[] = []; let selectedTutors: Tutor[] = [];
let isLoadingTutors = false; let isLoadingTutors = false;
let link = '';
let copied = false;
function notEmpty<TValue>(value: TValue | null | undefined): value is TValue { function notEmpty<TValue>(value: TValue | null | undefined): value is TValue {
return value !== null && value !== undefined; return value !== null && value !== undefined;
@@ -41,28 +37,14 @@
i.length === 0 ? [] : i.map((id) => tutors.find((tutor) => tutor.id === id)).filter(notEmpty); i.length === 0 ? [] : i.map((id) => tutors.find((tutor) => tutor.id === id)).filter(notEmpty);
function onSubmit() { function onSubmit() {
const { hasError, fieldErrors, students } = validateForm({ if (!selectedTutors.length) {
...$invitationModal, resetForm();
title: 'sfdsd', return;
description: 'dsfdsf', }
tutors: 'sgsg'
});
errors = fieldErrors;
if (hasError) return;
let membersStack = []; let membersStack = [];
console.log(`$course`, $course); console.log(`$course`, $course);
// Create groupmemebers from group_id and add teachers and students
for (const studentEmail of students) {
membersStack.push({
email: studentEmail,
group_id: $course.group?.id,
role_id: ROLE.STUDENT
});
}
for (const tutor of selectedTutors) { for (const tutor of selectedTutors) {
membersStack.push({ membersStack.push({
profile_id: tutor.profileId, profile_id: tutor.profileId,
@@ -128,24 +110,34 @@
isLoadingTutors = false; isLoadingTutors = false;
} }
function copyLink() {
copy(link);
copied = true;
setTimeout(() => {
copied = false;
}, 1000);
}
$: { $: {
selectedTutors = formatSelected(selectedIds); selectedTutors = formatSelected(selectedIds);
console.log('selectedTutors', selectedTutors); console.log('selectedTutors', selectedTutors);
} }
$: setTutors($currentOrg.id, $invitationModal.open); $: setTutors($currentOrg.id, $invitationModal.open);
$: link = getStudentInviteLink($course, $currentOrg.siteName);
</script> </script>
<Modal <Modal
onClose={resetForm} onClose={resetForm}
bind:open={$invitationModal.open} bind:open={$invitationModal.open}
width="w-2/5" width="w-4/5 md:w-2/5"
maxWidth="max-w-lg" maxWidth="max-w-lg"
modalHeading="Invite people" modalHeading="Invite people"
> >
<form on:submit|preventDefault={onSubmit}> <form on:submit|preventDefault={onSubmit}>
<div class="mb-4"> <div class="mb-8">
<p class="text-base mb-1">Invite Tutors</p> <p class="text-base mb-1 font-semibold">Invite Tutors</p>
<MultiSelect <MultiSelect
disabled={isLoadingTutors} disabled={isLoadingTutors}
label="Select tutors in organization..." label="Select tutors in organization..."
@@ -169,19 +161,29 @@
{/if} {/if}
</div> </div>
<TextArea <div class="mb-8 w-[90%] flex justify-between items-center">
label="Invite Students" <div class="w-3/5">
labelClassName="text-base" <p class="text-base mb-1 font-semibold">Invite Students</p>
bind:value={$invitationModal.students} <p class=" text-sm">You can invite students via an invite link</p>
rows="2" </div>
maxRows={3}
placeholder="[email protected], john@gmail.com"
helperMessage="To invite people, add their emails separated by a comma"
errorMessage={errors.students}
className="mb-4"
/>
<div class="mt-5 flex items-center"> <div class="relative">
<PrimaryButton
disablePadding={true}
className="text-sm py-2 px-3"
variant={VARIANTS.OUTLINED}
onClick={copyLink}
>
<Link size={16} class="mr-1" />
Copy link
</PrimaryButton>
<Popover caret open={copied} align="bottom">
<div style="padding: 5px">Copied Successfully</div>
</Popover>
</div>
</div>
<div class="mt-5 flex items-center flex-row-reverse">
<PrimaryButton className="px-6 py-3" label="Finish" type="submit" /> <PrimaryButton className="px-6 py-3" label="Finish" type="submit" />
</div> </div>
</form> </form>
@@ -5,12 +5,10 @@ export const deleteMemberModal = writable({
}); });
export const invitationModal = writable({ export const invitationModal = writable({
open: false, open: false
students: ''
}); });
export const resetForm = () => export const resetForm = () =>
invitationModal.update(() => ({ invitationModal.update(() => ({
open: false, open: false
students: ''
})); }));
@@ -11,7 +11,7 @@ export async function sendTeacherCourseWelcome(params: {
const accessToken = data.session?.access_token || ''; const accessToken = data.session?.access_token || '';
try { try {
const response = await fetch('/api/email/course/welcome', { const response = await fetch('/api/email/course/teacher_welcome', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
+2
View File
@@ -11,6 +11,7 @@ export const course: Writable<Course> = writable({
description: '', description: '',
cost: 0, cost: 0,
currency: '', currency: '',
status: 'ACTIVE',
is_certificate_downloadable: false, is_certificate_downloadable: false,
certificate_theme: 'professional', certificate_theme: 'professional',
metadata: { metadata: {
@@ -20,6 +21,7 @@ export const course: Writable<Course> = writable({
videoUrl: '', videoUrl: '',
showDiscount: false, showDiscount: false,
discount: 0, discount: 0,
paymentLink: '',
reward: { reward: {
show: false, show: false,
description: '' description: ''
@@ -16,9 +16,7 @@
export let isFetching = false; export let isFetching = false;
$: { $: {
const user = $group.people.find( const user = $group.people.find((person) => person.profile_id === $profile.id);
(person) => person.profile_id === $profile.id
);
if (user) { if (user) {
isStudent = user.role_id === 3; isStudent = user.role_id === 3;
@@ -58,6 +56,6 @@
} }
.rightBar.isMobile { .rightBar.isMobile {
margin-left: 10px; /* margin-left: 10px; */
} }
</style> </style>
@@ -7,36 +7,12 @@
export let show = true; export let show = true;
</script> </script>
<section <section class="root z-10">
class="root z-10 {!show && 'hide'} {$isMobile ? 'fixed shadow-xl' : 'sticky'}"
>
{#if show} {#if show}
<div class="relative h-full bg-white dark:bg-gray-800"> <div class="relative h-full bg-white dark:bg-gray-800">
<slot /> <slot />
</div> </div>
{/if} {/if}
<div class="toggler rounded-full shadow-lg absolute bottom-0">
<IconButton
value="toggle"
onClick={() => (show = !show)}
size={$isMobile ? 'large' : 'small'}
color="text-black"
toolTipProps={$isMobile
? {}
: {
title: 'Toggle editor',
hotkeys: ['B'],
direction: 'right',
}}
>
{#if show}
<ChevronLeftIcon size={16} />
{:else}
<ChevronRightIcon size={16} />
{/if}
</IconButton>
</div>
</section> </section>
<style lang="scss"> <style lang="scss">
@@ -50,11 +26,6 @@
left: 0; left: 0;
border-right: 1px solid var(--border-color); border-right: 1px solid var(--border-color);
background-color: rgb(250, 251, 252); background-color: rgb(250, 251, 252);
&.hide {
min-width: 15px;
max-width: 15px;
}
} }
.toggler { .toggler {
@@ -67,10 +38,5 @@
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.root {
width: 98%;
min-width: unset;
max-width: unset;
}
} }
</style> </style>
@@ -1,32 +1,36 @@
<script> <script lang="ts">
import cloneDeep from 'lodash/cloneDeep'; import cloneDeep from 'lodash/cloneDeep';
import set from 'lodash/set'; import set from 'lodash/set';
import get from 'lodash/get'; import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty'; import isEmpty from 'lodash/isEmpty';
import { Toggle } from 'carbon-components-svelte'; import { Toggle, Select, SelectItem } from 'carbon-components-svelte';
import TextField from '$lib/components/Form/TextField.svelte'; import TextField from '$lib/components/Form/TextField.svelte';
import TextEditor from '$lib/components/TextEditor/index.svelte'; import TextEditor from '$lib/components/TextEditor/index.svelte';
import { isCourseFree } from '$lib/utils/functions/course';
import type { Course } from '$lib/utils/types';
export let course = {}; export let course: Course;
let discount = 0; let discount = 0;
let showDiscount; let paymentLink = '';
let giftToggled; let showDiscount = false;
let giftToggled = false;
let hasBeenSet = false; let hasBeenSet = false;
function setDefaults(course) { function setDefaults(course: Course) {
console.log('\n\nsetDefaults course', course); console.log('\n\nsetDefaults course', course);
if (isEmpty(course) || hasBeenSet) return; if (isEmpty(course) || hasBeenSet) return;
hasBeenSet = true; hasBeenSet = true;
paymentLink = get(course, 'metadata.paymentLink', '');
discount = get(course, 'metadata.discount', 0); discount = get(course, 'metadata.discount', 0);
showDiscount = get(course, 'metadata.showDiscount', false); showDiscount = get(course, 'metadata.showDiscount', false);
giftToggled = get(course, 'metadata.reward.show', false); giftToggled = get(course, 'metadata.reward.show', false);
} }
function setter(value, setterKey) { function setter(value: string | number | boolean, setterKey: string) {
console.log('\n\nsetter course', value);
if (typeof value !== 'boolean' && !value) return; if (typeof value !== 'boolean' && !value) return;
const _course = cloneDeep(course); const _course = cloneDeep(course);
@@ -34,66 +38,76 @@
course = _course; course = _course;
} }
function handleChange(html) { function handleChange(html: string) {
const _course = cloneDeep(course); const _course = cloneDeep(course);
set(_course, 'metadata.reward.description', html); set(_course, 'metadata.reward.description', html);
course = _course; course = _course;
} }
$: setter(showDiscount, 'metadata.showDiscount'); $: setter(showDiscount, 'metadata.showDiscount');
$: setter(paymentLink, 'metadata.paymentLink');
$: setter(discount, 'metadata.discount'); $: setter(discount, 'metadata.discount');
$: setter(giftToggled, 'metadata.reward.show'); $: setter(giftToggled, 'metadata.reward.show');
$: setDefaults(course); $: setDefaults(course);
</script> </script>
<TextField {#if typeof course !== 'undefined'}
className="mt-5" <Select labelText="Currency" bind:selected={course.currency}>
labelClassName="font-bold" <SelectItem value="NGN" />
label="Currency" <SelectItem value="USD" />
value={course.currency} </Select>
isDisabled={true}
/>
<TextField
className="mt-5"
labelClassName="font-bold"
label="Cost"
type="number"
bind:value={course.cost}
/>
<div class="mt-5">
<!-- <label for="text-field" class="m-0 font-bold mb-2">Discount</label> -->
<Toggle labelText="Discount" bind:toggled={showDiscount}>
<span slot="labelA">No</span>
<span slot="labelB">Yes</span>
</Toggle>
</div>
{#if showDiscount}
<TextField <TextField
className="mt-5" className="mt-5"
labelClassName="font-bold" labelClassName="font-bold"
label="Discout Percent" label="Cost"
type="number" type="number"
bind:value={discount} bind:value={course.cost}
helperMessage="In Percentage %"
/> />
{/if}
<div class="mt-5"> {#if !isCourseFree(course.cost || 0)}
<!-- <label for="text-field" class="m-0 font-bold mb-2">Discount</label> --> <TextField
<Toggle labelText="Gift on Completion" bind:toggled={giftToggled}> className="mt-5"
<span slot="labelA">No</span> labelClassName="font-bold"
<span slot="labelB">Yes</span> label="Payment Link"
</Toggle> helperMessage="Stripe, Lemon Squeezy or any payment link"
</div> bind:value={paymentLink}
/>
{/if}
{#if giftToggled} <div class="mt-5">
<p class="dark:text-white font-bold mt-5">About Gift</p> <!-- <label for="text-field" class="m-0 font-bold mb-2">Discount</label> -->
<Toggle labelText="Discount" bind:toggled={showDiscount}>
<div class="h-2/5"> <span slot="labelA">No</span>
<TextEditor value={get(course, 'metadata.reward.description')} onChange={handleChange} /> <span slot="labelB">Yes</span>
</Toggle>
</div> </div>
{#if showDiscount}
<TextField
className="mt-5"
labelClassName="font-bold"
label="Discout Percent"
type="number"
bind:value={discount}
helperMessage="In Percentage %"
/>
{/if}
<div class="mt-5">
<!-- <label for="text-field" class="m-0 font-bold mb-2">Discount</label> -->
<Toggle labelText="Gift on Completion" bind:toggled={giftToggled}>
<span slot="labelA">No</span>
<span slot="labelB">Yes</span>
</Toggle>
</div>
{#if giftToggled}
<p class="dark:text-white font-bold mt-5">About Gift</p>
<div class="h-2/5">
<TextEditor value={get(course, 'metadata.reward.description', '')} onChange={handleChange} />
</div>
{/if}
{/if} {/if}
@@ -18,13 +18,16 @@
import { VARIANTS } from '$lib/components/PrimaryButton/constants'; import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { updateCourse } from '$lib/utils/services/courses'; import { updateCourse } from '$lib/utils/services/courses';
import generateSlug from '$lib/utils/functions/generateSlug'; import generateSlug from '$lib/utils/functions/generateSlug';
import ChevronLeftIcon from 'carbon-icons-svelte/lib/ChevronLeft.svelte';
import { isMobile } from '$lib/utils/store/useMobile';
export let course; export let course;
export let courseId; export let courseId;
let borderBottomGrey = 'border-r-0 border-b border-l-0 border-gray-300'; let borderBottomGrey = 'border-r-0 border-b border-l-0 border-gray-300';
let loading = false; let loading = false;
let show = false;
const sections = [ const sections = [
{ {
key: 1, key: 1,
@@ -115,69 +118,112 @@
} }
</script> </script>
<Container show={true}> <aside
<!-- Publish Header Section --> class={`${
{#if !selectedSection} show ? '-translate-x-[100%] fixed z-[9999]' : 'translate-x-0 fixed z-[9999]'
<div class="flex justify-between items-center px-2 w-full"> } left-0 transition w-[280px] min-w-[280px] bg-gray-100 dark:bg-gray-800 h-full border border-l-0 border-t-0 border-b-0 border-r-1`}
<CloseButton onClick={handleClose} /> >
<div class="flex items-center"> <div class="toggler rounded-full shadow-lg absolute">
<PrimaryButton <IconButton
label="Save" value="toggle"
type="button" onClick={() => (show = !show)}
className="mr-1" size={$isMobile ? 'large' : 'small'}
variant={VARIANTS.OUTLINED} color="text-black"
onClick={handleSave} toolTipProps={$isMobile
isLoading={loading} ? {}
/> : {
<PrimaryButton label="Publish" type="button" onClick={handlePublish} isDisabled={loading} /> title: 'Toggle editor',
<IconButton onClick={handlePreview} disabled={loading || !course.slug}> direction: 'right'
<ArrowUpRightIcon size={24} class="carbon-icon dark:text-white" title="Preview" /> }}
</IconButton> >
</div> {#if show}
</div> <ChevronRightIcon size={16} />
<div class="flex justify-between items-center px-2 w-full mb-2"> {:else}
<h3 class="dark:text-white">Page Builder</h3> <ChevronLeftIcon size={16} />
</div>
{#each sections as section}
<button
class="w-full flex items-center justify-between px-2 py-3 border border-b-0 border-gray-300"
on:click={handleSectionSelect(section.key)}
>
<p class="dark:text-white mr-2">{section.title} section</p>
<ChevronRightIcon size={24} class="carbon-class" />
</button>
{/each}
{:else}
<!-- Title -->
<div class="flex items-center {borderBottomGrey} w-full">
<IconButton onClick={handleClose}>
<ArrowLeftIcon size={24} class="carbon-icon dark:text-white" title="Go back" />
</IconButton>
<h3 class="dark:text-white">{selectedSection.title}</h3>
</div>
<div class="title-content p-2 overflow-y-auto">
{#if selectedSection.key === 1}
<HeaderForm bind:course />
{:else if selectedSection.key === 2}
<RequirementForm bind:course />
{:else if selectedSection.key === 3}
<DescriptionForm bind:course />
{:else if selectedSection.key === 4}
<GoalsForm bind:course />
{:else if selectedSection.key === 5}
<ReviewsForm bind:course />
{:else if selectedSection.key === 6}
<InstructorForm bind:course />
{:else if selectedSection.key === 7}
<PricingForm bind:course />
{/if} {/if}
</div> </IconButton>
{/if} </div>
</Container> <div class="h-full flex flex-col">
{#if !selectedSection}
<div class="flex justify-between items-center px-2 w-full">
<CloseButton onClick={handleClose} />
<div class="flex items-center">
<PrimaryButton
label="Save"
type="button"
className="mr-1"
variant={VARIANTS.OUTLINED}
onClick={handleSave}
isLoading={loading}
/>
<PrimaryButton
label="Publish"
type="button"
onClick={handlePublish}
isDisabled={loading}
/>
<IconButton onClick={handlePreview} disabled={loading || !course.slug}>
<ArrowUpRightIcon size={24} class="carbon-icon dark:text-white" title="Preview" />
</IconButton>
</div>
</div>
<div class="flex justify-between items-center px-2 w-full mb-2">
<h3 class="dark:text-white">Page Builder</h3>
</div>
{#each sections as section}
<button
class="w-full flex items-center justify-between px-2 py-3 border border-b-0 border-gray-300"
on:click={handleSectionSelect(section.key)}
>
<p class="dark:text-white mr-2">{section.title} section</p>
<ChevronRightIcon size={24} class="carbon-class" />
</button>
{/each}
{:else}
<!-- Title -->
<div class="flex items-center {borderBottomGrey} w-full">
<IconButton onClick={handleClose}>
<ArrowLeftIcon size={24} class="carbon-icon dark:text-white" title="Go back" />
</IconButton>
<h3 class="dark:text-white">{selectedSection.title}</h3>
</div>
<div class="title-content p-2 overflow-y-auto">
{#if selectedSection.key === 1}
<HeaderForm bind:course />
{:else if selectedSection.key === 2}
<RequirementForm bind:course />
{:else if selectedSection.key === 3}
<DescriptionForm bind:course />
{:else if selectedSection.key === 4}
<GoalsForm bind:course />
{:else if selectedSection.key === 5}
<ReviewsForm bind:course />
{:else if selectedSection.key === 6}
<InstructorForm bind:course />
{:else if selectedSection.key === 7}
<PricingForm bind:course />
{/if}
</div>
{/if}
</div>
</aside>
<style> <style>
.title-content { .title-content {
height: 90%; height: 90%;
} }
.toggler {
right: -15px;
z-index: 10;
border: 1px solid var(--border-color);
top: 50px;
height: fit-content;
background: var(--border-color);
}
@media screen and (max-width: 767px) {
.toggler {
right: -25px;
}
}
</style> </style>
@@ -0,0 +1,111 @@
<script lang="ts">
import TextField from '$lib/components/Form/TextField.svelte';
import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { coursePaymentValidation } from '$lib/utils/functions/validator';
import { Link } from 'carbon-components-svelte';
import { sendTeacherStudentJoinRequest, sendStudentRequestReceipt } from './utils';
import { currentOrg } from '$lib/utils/store/org';
export let open = false;
export let paymentLink = '';
export let teacherEmail = '';
export let courseName = '';
let fields = {
fullname: '',
email: ''
};
let errors = {
fullname: '',
email: ''
};
const STEPS = {
STEP_1: 'ENTER_DETAILS',
STEP_2: 'GO_TO_PAYMENT'
};
let step = STEPS.STEP_1;
function onSubmit() {
errors = coursePaymentValidation(fields);
console.log('coursePayment errors', errors);
if (Object.keys(errors).length) {
return;
}
if (paymentLink) {
step = STEPS.STEP_2;
// Send email to first added teacher of student wanting to join
sendTeacherStudentJoinRequest({
to: teacherEmail,
courseName,
studentEmail: fields.email,
studentFullname: fields.fullname
});
} else {
open = false;
}
}
function onClickPaymentLink() {
// Send email to student to send proof of payment to teacher
sendStudentRequestReceipt({
to: fields.email,
courseName,
teacherEmail,
studentFullname: fields.fullname,
orgName: $currentOrg.name
});
fields.fullname = '';
fields.email = '';
open = false;
}
</script>
<Modal onClose={() => (open = false)} bind:open width="w-96" modalHeading="Process course payment">
{#if step === STEPS.STEP_1}
<form on:submit|preventDefault={onSubmit}>
<TextField
label="Your Fullname"
bind:value={fields.fullname}
autoFocus={true}
placeholder="John Doe"
className="mb-4"
isRequired={true}
autoComplete={false}
errorMessage={errors.fullname}
/>
<TextField
label="Your Fullname"
bind:value={fields.email}
placeholder="[email protected]"
className="mb-4"
isRequired={true}
autoComplete={false}
errorMessage={errors.email}
/>
<div class="mt-5 flex items-center flex-row-reverse">
<PrimaryButton
className="px-6 py-3"
label={paymentLink ? 'Next' : 'Finish'}
type="submit"
/>
</div>
</form>
{:else if step === STEPS.STEP_2}
<p>
You will now be taken to a payment page, once you've paid send a proof of payment to the
course admin
</p>
<div class="mt-5 flex items-center flex-row-reverse">
<Link href={paymentLink} target="_blank" on:click={onClickPaymentLink}>Go to payment</Link>
</div>
{/if}
</Modal>
@@ -1,20 +1,24 @@
<script lang="ts"> <script lang="ts">
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import type { Course } from '$lib/utils/types';
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
import get from 'lodash/get'; import get from 'lodash/get';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
import { isCourseFree } from '$lib/utils/functions/course';
import { getStudentInviteLink } from '$lib/utils/functions/course';
import { currentOrg } from '$lib/utils/store/org';
import { goto } from '$app/navigation';
import PaymentModal from './PaymentModal.svelte';
import type { Course } from '$lib/utils/types';
import { ROLE } from '$lib/utils/constants/roles';
export let className = ''; export let className = '';
export let courseData: Course = { export let editMode = false;
id: '', export let courseData: Course;
title: '', export let startCoursePayment = false;
description: ''
};
let calculatedCost = 0; let calculatedCost = 0;
let discount = 0; let discount = 0;
let formatter: Intl.NumberFormat | undefined; let formatter: Intl.NumberFormat | undefined;
let isFree = false;
function calcDisc(percent: number, cost: number) { function calcDisc(percent: number, cost: number) {
if (!percent) return cost; if (!percent) return cost;
@@ -23,27 +27,51 @@
return Math.round(discountedPrice); return Math.round(discountedPrice);
} }
function addToCart() {} function handleJoinCourse() {
if (editMode) return;
function buyNow() {} if (isFree) {
const link = getStudentInviteLink(courseData, $currentOrg.siteName);
goto(link);
} else {
startCoursePayment = true;
}
}
function setFormatter(currency: string | undefined) { function setFormatter(currency: string | undefined) {
if (!currency) return; if (!currency) return;
formatter = getCurrencyFormatter(currency); formatter = getCurrencyFormatter(currency);
} }
function getTeacherEmail(group: Course['group']) {
const firstTutor = group?.members?.find((m) => m.role_id === ROLE.TUTOR);
return firstTutor?.profile?.email || '';
}
$: setFormatter(courseData.currency); $: setFormatter(courseData.currency);
$: discount = get(courseData, 'metadata.discount', 0); $: discount = get(courseData, 'metadata.discount', 0);
$: calculatedCost = calcDisc(discount, courseData.cost || 0); $: calculatedCost = calcDisc(discount, courseData.cost || 0);
$: isFree = isCourseFree(calculatedCost);
</script> </script>
<PaymentModal
bind:open={startCoursePayment}
paymentLink={get(courseData, 'metadata.paymentLink', '')}
courseName={courseData.title}
teacherEmail={getTeacherEmail(courseData.group)}
/>
<!-- Pricing Details --> <!-- Pricing Details -->
<aside class="{className} price-container md:sticky md:top-0 md:shadow-2xl md:rounded-lg m-h-fit"> <aside class="{className} price-container lg:sticky lg:top-10 lg:shadow-2xl lg:rounded-lg m-h-fit">
<div class="p-10"> <div class="p-10">
<!-- Pricing --> <!-- Pricing -->
<div class="mb-6"> <div class="mb-6">
<p class="dark:text-white font-medium text-lg"> <p class="dark:text-white font-medium text-lg">
{formatter?.format(calculatedCost) || calculatedCost} {formatter?.format(calculatedCost) || calculatedCost}
{#if isFree}
<span class="text-sm">(Free)</span>
{/if}
</p> </p>
{#if courseData?.metadata?.showDiscount} {#if courseData?.metadata?.showDiscount}
<p class="dark:text-white font-light text-sm text-gray-500"> <p class="dark:text-white font-light text-sm text-gray-500">
@@ -58,15 +86,9 @@
<!-- Call To Action Buttons --> <!-- Call To Action Buttons -->
<div class="flex flex-col w-full items-center"> <div class="flex flex-col w-full items-center">
<PrimaryButton <PrimaryButton
label="Add to Cart" label={isFree ? 'Join Course' : 'Buy Now'}
className="w-full sm:w-full py-3 mb-3" className="w-full sm:w-full py-3 mb-3"
onClick={addToCart} onClick={handleJoinCourse}
/>
<PrimaryButton
label="Buy Now"
className="w-full sm:w-full py-3 mb-3"
variant={VARIANTS.OUTLINED}
onClick={addToCart}
/> />
<p class="dark:text-white font-light text-sm text-gray-500">Early bird offer. Buy ASAP</p> <p class="dark:text-white font-light text-sm text-gray-500">Early bird offer. Buy ASAP</p>
</div> </div>
@@ -120,9 +142,9 @@
height: fit-content; height: fit-content;
} }
@media (max-width: 768px) { @media screen and (min-width: 768px) and (max-width: 1024px) {
.price-container { .price-container {
width: 100%; width: 405px;
min-width: unset; min-width: unset;
} }
} }
@@ -0,0 +1,60 @@
import { supabase } from '$lib/utils/functions/supabase';
export async function sendTeacherStudentJoinRequest(params: {
to: string;
courseName: string;
studentEmail: string;
studentFullname: string;
}) {
const { data } = await supabase.auth.getSession();
const accessToken = data.session?.access_token || '';
try {
const response = await fetch('/api/email/course/teacher_student_buycourse', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
},
body: JSON.stringify(params)
});
if (!response.ok) {
throw new Error(await response.text());
}
const result = await response.json();
console.log('Sent invite email', result);
} catch (error) {
console.error('Error sending invite email', error);
}
}
export async function sendStudentRequestReceipt(params: {
to: string;
courseName: string;
orgName: string;
studentFullname: string;
teacherEmail: string;
}) {
const { data } = await supabase.auth.getSession();
const accessToken = data.session?.access_token || '';
try {
const response = await fetch('/api/email/course/student_prove_payment', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
},
body: JSON.stringify(params)
});
if (!response.ok) {
throw new Error(await response.text());
}
const result = await response.json();
console.log('Sent invite email', result);
} catch (error) {
console.error('Error sending invite email', error);
}
}
@@ -25,11 +25,8 @@
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
export let courseData: Course = { export let editMode = false;
id: '', export let courseData: Course;
title: '',
description: ''
};
const ratingsImg = [ const ratingsImg = [
'/images/rating-1.svg', '/images/rating-1.svg',
'/images/rating-2.svg', '/images/rating-2.svg',
@@ -43,6 +40,7 @@
let player: any; let player: any;
let averageRating = 0; let averageRating = 0;
let totalRatings = 0; let totalRatings = 0;
let startCoursePayment = false;
// initialize the expandDescription array with 'false' values for each review. // initialize the expandDescription array with 'false' values for each review.
let expandDescription = Array(reviews.length).fill(false); let expandDescription = Array(reviews.length).fill(false);
@@ -61,16 +59,6 @@
activeNav = window.location.hash; activeNav = window.location.hash;
} }
function addToCart() {}
function buyNow() {
// if (courseData.cost) {
// paystackApi.tra;
// } else {
// // Course is free
// }
}
function initPlyr(_player: any, _video: string | undefined) { function initPlyr(_player: any, _video: string | undefined) {
if (!player) return; if (!player) return;
@@ -119,7 +107,13 @@
<p class="dark:text-white author my-3 text-sm"> <p class="dark:text-white author my-3 text-sm">
{get(courseData, 'metadata.instructor.name', '')} {get(courseData, 'metadata.instructor.name', '')}
</p> </p>
<PrimaryButton label="Start Course" className="px-6 py-5 mt-6 sm:w-fit" /> <PrimaryButton
label="Start Course"
className="px-6 py-5 mt-6 sm:w-fit"
onClick={() => {
startCoursePayment = true;
}}
/>
</div> </div>
<!-- Banner Image getEmbedId(videoUrl) --> <!-- Banner Image getEmbedId(videoUrl) -->
@@ -164,14 +158,16 @@
{/if} {/if}
</div> </div>
</header> </header>
<PricingSection className="md:hidden" {courseData} /> <PricingSection className="md:hidden" {courseData} {editMode} bind:startCoursePayment />
<!-- Body --> <!-- Body -->
<div class="bg-white dark:bg-gray-800 w-full"> <div class="bg-white dark:bg-gray-800 w-full">
<div class="py-8 lg:w-10/12 w-full m-2 lg:m-auto flex justify-between"> <div
class="py-8 lg:w-11/12 w-full m-2 lg:m-auto flex flex-col-reverse lg:flex-row items-center lg:items-start justify-between max-w-[1200px]"
>
<!-- Course Details --> <!-- Course Details -->
<div class="course-content w-full p-3 md:w-9/12 md:mr-14"> <div class="course-content w-full p-3 lg:w-10/12 lg:mr-14">
<!-- Navigation --> <!-- Navigation -->
<nav class="flex items-center border-b border-gray-300 pb-3"> <nav class="flex items-center border-b border-gray-300 py-3 sticky top-11 bg-white">
{#each NAV_ITEMS as navItem} {#each NAV_ITEMS as navItem}
<a <a
href="{$page.url.pathname}{navItem.key}" href="{$page.url.pathname}{navItem.key}"
@@ -416,7 +412,7 @@
</div> </div>
<!-- Pricing Details --> <!-- Pricing Details -->
<PricingSection className="hidden md:block" {courseData} /> <PricingSection {courseData} />
</div> </div>
</div> </div>
</div> </div>
@@ -464,7 +460,7 @@
} }
.course-content { .course-content {
max-width: 608px; max-width: 800px;
} }
nav { nav {
+2 -2
View File
@@ -1,11 +1,11 @@
<script> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import IconButton from '$lib/components/IconButton/index.svelte'; import IconButton from '$lib/components/IconButton/index.svelte';
import PasswordEye from '../Icons/PasswordEye.svelte'; import PasswordEye from '../Icons/PasswordEye.svelte';
export let label = ''; export let label = '';
export let placeholder = ''; export let placeholder = '';
export let value = ''; export let value: string | number;
export let name = ''; export let name = '';
export let onKeyDown = () => {}; export let onKeyDown = () => {};
export let className = ''; export let className = '';
+1 -1
View File
@@ -10,7 +10,7 @@
export let logo; export let logo;
export let orgName; export let orgName;
export let isOrgSite = false; export let isOrgSite = false;
export let backgroundColor = 'bg-blue'; export let backgroundColor = 'bg-white';
let navClass = ''; let navClass = '';
+8 -4
View File
@@ -8,6 +8,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { isOrgAdmin } from '$lib/utils/store/org';
let selected = 0; let selected = 0;
let query = new URLSearchParams($page.url.search); let query = new URLSearchParams($page.url.search);
@@ -18,19 +19,22 @@
key: 0, key: 0,
label: 'Profile', label: 'Profile',
tabKey: '', tabKey: '',
href: $page.url.pathname href: $page.url.pathname,
disabled: false
}, },
{ {
key: 1, key: 1,
label: 'Organization', label: 'Organization',
tabKey: 'org', tabKey: 'org',
href: `${$page.url.pathname}?tab=org` href: `${$page.url.pathname}?tab=org`,
disabled: !$isOrgAdmin
}, },
{ {
key: 2, key: 2,
label: 'LandingPage', label: 'LandingPage',
tabKey: 'landingpage', tabKey: 'landingpage',
href: `${$page.url.pathname}?tab=landingpage` href: `${$page.url.pathname}?tab=landingpage`,
disabled: !$isOrgAdmin
} }
]; ];
@@ -59,7 +63,7 @@
<Tabs autoWidth bind:selected> <Tabs autoWidth bind:selected>
{#each tabs as tab} {#each tabs as tab}
<Tab label={tab.label} href={tab.href} /> <Tab label={tab.label} href={tab.href} disabled={tab.disabled} />
{/each} {/each}
<!-- <Tab label="Account" /> --> <!-- <Tab label="Account" /> -->
<svelte:fragment slot="content"> <svelte:fragment slot="content">
+2 -1
View File
@@ -82,7 +82,8 @@ export let landingPageSettings: Writable<OrgLandingPageJson> = writable({
title: 'Our Story', title: 'Our Story',
content: content:
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.', 'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.',
imageUrl: '', imageUrl:
'https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/landingpage/riverside.jpeg',
show: true show: true
}, },
courses: { courses: {
+5 -5
View File
@@ -19,12 +19,12 @@
const menuItems = [ const menuItems = [
{ {
path: '', path: '',
label: 'Home' label: 'Dashboard'
},
{
path: '/quiz',
label: 'Quizzes'
}, },
// {
// path: '/quiz',
// label: 'Quizzes'
// },
{ {
path: '/courses', path: '/courses',
label: 'Courses' label: 'Courses'
@@ -7,6 +7,7 @@ export const VARIANTS = {
CONTAINED_SUCCESS: 'CONTAINED_SUCCESS', CONTAINED_SUCCESS: 'CONTAINED_SUCCESS',
OUTLINED: 'OUTLINED', OUTLINED: 'OUTLINED',
NONE: 'NONE', NONE: 'NONE',
LINK: 'LINK',
TEXT: 'TEXT', TEXT: 'TEXT',
TEXT_DANGER: 'TEXT_DANGER' TEXT_DANGER: 'TEXT_DANGER'
}; };
@@ -25,6 +26,7 @@ export const VARIANTS_CLASS = {
[VARIANTS.NONE]: [VARIANTS.NONE]:
'border-none hover:border-black-300 hover:bg-gray-200 dark:hover:bg-gray-500 text-black', 'border-none hover:border-black-300 hover:bg-gray-200 dark:hover:bg-gray-500 text-black',
[VARIANTS.TEXT]: 'text-black hover:underline', [VARIANTS.TEXT]: 'text-black hover:underline',
[VARIANTS.LINK]: 'text-primary-500 dark:text-primary-500 hover:underline',
[VARIANTS.TEXT_DANGER]: [VARIANTS.TEXT_DANGER]:
'text-red-500 hover:border border-t-0 border-l-0 border-r-0 border-red-300' 'text-red-500 hover:border border-t-0 border-l-0 border-r-0 border-red-300'
}; };
@@ -13,12 +13,12 @@
export let isDisabled = false; export let isDisabled = false;
export let isLoading = false; export let isLoading = false;
$: isDisabled = isLoading ? true : false; $: isDisabled = isLoading ? true : isDisabled;
</script> </script>
<button <button
class="{isDisabled class="{isDisabled
? 'bg-opacity-25 hover:bg-opacity-25 cursor-not-allowed' ? 'opacity-25 cursor-not-allowed'
: 'cursor-pointer'} flex items-center h-auto {VARIANTS_CLASS[ : 'cursor-pointer'} flex items-center h-auto {VARIANTS_CLASS[
isLoading ? VARIANTS.OUTLINED : variant isLoading ? VARIANTS.OUTLINED : variant
]} {!disablePadding && ]} {!disablePadding &&
+2 -2
View File
@@ -6,7 +6,7 @@
</script> </script>
<div class="w-full flex flex-col"> <div class="w-full flex flex-col">
<div class="flex items-center border-b-2 w-full"> <div class="flex items-center border-b-2 w-full overflow-x-auto">
{#each tabs as tab} {#each tabs as tab}
{#if !tab.icon && !tab.badgeValue} {#if !tab.icon && !tab.badgeValue}
<button <button
@@ -32,7 +32,7 @@
: 'dark:bg-gray-500 dark:text-white'} dark:bg-transparent font-semibold focus:outline-none w-24 text-left py-3 mr-10" : 'dark:bg-gray-500 dark:text-white'} dark:bg-transparent font-semibold focus:outline-none w-24 text-left py-3 mr-10"
on:click={onChange(tab.value)} on:click={onChange(tab.value)}
> >
<div class="grid grid-cols-4 gap-3 items-center"> <div class="grid grid-cols-5 md:grid-cols-4 gap-5 md:gap-3 items-center">
{#if tab.icon} {#if tab.icon}
<svelte:component this={tab.icon} /> <svelte:component this={tab.icon} />
{/if} {/if}
+2 -2
View File
@@ -4,10 +4,10 @@
export let id = ''; export let id = '';
export let value: string; export let value: string;
export let onChange = (html = '') => {}; export let onChange = (html: string) => {};
export let height = 300; export let height = 300;
export let placeholder = ''; export let placeholder = '';
export let editorWindowRef; export let editorWindowRef = null;
const apiKey = PUBLIC_TINYMCE_API_KEY; const apiKey = PUBLIC_TINYMCE_API_KEY;
+1 -1
View File
@@ -53,7 +53,7 @@
<input <input
style="display:none" style="display:none"
type="file" type="file"
accept=".jpg, .jpeg, .png" accept=".jpg, .jpeg, .png, .webp"
on:change={(e) => onFileSelected(e)} on:change={(e) => onFileSelected(e)}
bind:this={fileinput} bind:this={fileinput}
/> />
+5 -3
View File
@@ -7,7 +7,8 @@ export const ROUTE = {
COURSE: '/course', COURSE: '/course',
LOGIN: '/login', LOGIN: '/login',
SIGN_UP: '/signup', SIGN_UP: '/signup',
INVITE_SIGNUP: '/invite', INVITE_TEACHER: '/invite/t',
INVITE_STUDENT: '/invite/s',
PROFILE: '/profile', PROFILE: '/profile',
PEOPLE: '/people', PEOPLE: '/people',
DISCUSSIONS: '/discussions', DISCUSSIONS: '/discussions',
@@ -20,7 +21,7 @@ export const PUBLIC_ROUTES = [
`^${ROUTE.HOME}$`, `^${ROUTE.HOME}$`,
ROUTE.LOGIN, ROUTE.LOGIN,
ROUTE.SIGN_UP, ROUTE.SIGN_UP,
`^${ROUTE.INVITE_SIGNUP}/.*`, `^${ROUTE.INVITE_TEACHER}/.*`,
ROUTE.FORGOT, ROUTE.FORGOT,
ROUTE.RESET, ROUTE.RESET,
`^${ROUTE.PAGES}/.*`, `^${ROUTE.PAGES}/.*`,
@@ -31,7 +32,8 @@ export const PUBLIC_ROUTES = [
export const ROUTES_TO_HIDE_NAV = [ export const ROUTES_TO_HIDE_NAV = [
ROUTE.LOGIN, ROUTE.LOGIN,
ROUTE.SIGN_UP, ROUTE.SIGN_UP,
`^${ROUTE.INVITE_SIGNUP}/.*`, `^${ROUTE.INVITE_TEACHER}/.*`,
`^${ROUTE.INVITE_STUDENT}/.*`,
ROUTE.FORGOT, ROUTE.FORGOT,
ROUTE.RESET, ROUTE.RESET,
ROUTE.ONBOARDING ROUTE.ONBOARDING
+18
View File
@@ -0,0 +1,18 @@
import type { Course } from '../types';
export const isCourseFree = (cost: number) => !(Number(cost) > 0);
export const getStudentInviteLink = (_course: Course, orgSiteName: string) => {
const hash = encodeURIComponent(
btoa(
JSON.stringify({
groupId: _course.group?.id,
name: _course.title,
description: _course.description,
orgSiteName
})
)
);
return `https://${orgSiteName}.classroomio.com/invite/s/${hash}`;
};
@@ -1,7 +1,8 @@
export default (currency: string | undefined) => { export default (currency: string | undefined) => {
return new Intl.NumberFormat('en-US', { const locale = currency == 'NGN' ? 'en-NG' : 'en-US';
return new Intl.NumberFormat(locale, {
style: 'currency', style: 'currency',
currency: currency, currency: currency,
minimumFractionDigits: 2, minimumFractionDigits: 2
}); });
}; };
+8
View File
@@ -0,0 +1,8 @@
import { supabase } from '$lib/utils/functions/supabase';
import type { Profile } from '$lib/components/Course/components/People/types';
export async function getProfile(email: string): Promise<Profile | null> {
const { data } = await supabase.from('profile').select('*').eq('email', email).single();
console.log('getProfile data', data);
return data;
}
+16
View File
@@ -134,6 +134,22 @@ export const authValidation = (fields = {}) => {
return processErrors(error); return processErrors(error);
}; };
export const coursePaymentValidation = (fields = {}) => {
const schema = z.object({
fullname: z.string().min(6, {
message: 'Must be 6 or more characters long',
invalid_type_error: 'Must not be empty'
}),
email: z.string().email({
message: 'Invalid email address',
invalid_type_error: 'Must not be empty'
})
});
const { error } = schema.safeParse(fields);
return processErrors(error);
};
export const resetValidation = (fields = {}) => { export const resetValidation = (fields = {}) => {
const { error } = resetValidationSchema.safeParse(fields); const { error } = resetValidationSchema.safeParse(fields);
+6 -4
View File
@@ -125,14 +125,16 @@ export async function getOrgAudience(orgId) {
email, email,
avatar_url, avatar_url,
created_at, created_at,
organizationmember!inner( groupmember!inner(
role_id, role_id,
organization_id group_id:group!inner(
organization_id
)
) )
` `
) )
.eq('organizationmember.organization_id', orgId) .eq('groupmember.group.organization_id', orgId)
.eq('organizationmember.role_id', 3); // is a student, tutor is 2 and admin is 1 .eq('groupmember.role_id', ROLE.STUDENT);
console.log('data', data); console.log('data', data);
+1 -1
View File
@@ -3,7 +3,7 @@ import type { MailService } from '@sendgrid/mail';
import { SENDGRID_API_KEY } from '$env/static/private'; import { SENDGRID_API_KEY } from '$env/static/private';
export const SENDGRID_FROM = 'Rotimi Best <[email protected]>'; export const SENDGRID_FROM = 'Rotimi Best <[email protected]>';
export const SENDGRID_FROM_NOTIFY = 'ClassroomIO <notif[email protected]>'; export const SENDGRID_FROM_NOREPLY = 'ClassroomIO <norepl[email protected]>';
export let sendgrid: MailService; export let sendgrid: MailService;
+5
View File
@@ -2,6 +2,7 @@ import { writable, derived } from 'svelte/store';
import { STEPS } from '../constants/quiz'; import { STEPS } from '../constants/quiz';
import type { Writable } from 'svelte/store'; import type { Writable } from 'svelte/store';
import type { CurrentOrg, OrgTeamMember } from '../types/org'; import type { CurrentOrg, OrgTeamMember } from '../types/org';
import { ROLE } from '$lib/utils/constants/roles';
export const orgs = writable<CurrentOrg[]>([]); export const orgs = writable<CurrentOrg[]>([]);
export const currentOrg: Writable<CurrentOrg> = writable({ export const currentOrg: Writable<CurrentOrg> = writable({
@@ -17,6 +18,10 @@ export const currentOrg: Writable<CurrentOrg> = writable({
}); });
export const orgAudience = writable([]); export const orgAudience = writable([]);
export const orgTeam = writable<OrgTeamMember[]>([]); export const orgTeam = writable<OrgTeamMember[]>([]);
export const isOrgAdmin = derived(
currentOrg,
($currentOrg) => parseInt($currentOrg.role_id) === ROLE.ADMIN
);
export const currentOrgPath = derived(currentOrg, ($currentOrg) => export const currentOrgPath = derived(currentOrg, ($currentOrg) =>
$currentOrg.siteName ? `/org/${$currentOrg.siteName}` : '' $currentOrg.siteName ? `/org/${$currentOrg.siteName}` : ''
); );
+4 -1
View File
@@ -8,6 +8,7 @@ interface CourseMetadata {
videoUrl?: ''; videoUrl?: '';
showDiscount: false; showDiscount: false;
discount: 0; discount: 0;
paymentLink?: string;
reward?: { reward?: {
show: boolean; show: boolean;
description: string; description: string;
@@ -64,6 +65,8 @@ export interface Group {
updated_at?: string; updated_at?: string;
organization_id?: string /* foreign key to organization.id */; organization_id?: string /* foreign key to organization.id */;
organization?: Organization; organization?: Organization;
tutors: Profile[];
members: Groupmember[];
} }
export interface Role { export interface Role {
@@ -93,7 +96,7 @@ export interface Course {
logo?: string; logo?: string;
slug?: any; // type unknown; slug?: any; // type unknown;
metadata?: CourseMetadata; metadata?: CourseMetadata;
cost?: number; cost: number;
currency?: string; currency?: string;
group?: Group; group?: Group;
organization?: Organization; organization?: Organization;
+3 -3
View File
@@ -81,7 +81,7 @@
console.log('Get user profile', authUser); console.log('Get user profile', authUser);
if (!authUser) { if (!authUser) {
goto('/login'); goto('/login?redirect=/' + path);
} }
// Check if user has profile // Check if user has profile
@@ -315,14 +315,14 @@
{#if !isQuizPage($page.url.pathname)} {#if !isQuizPage($page.url.pathname)}
<OrgSideBar /> <OrgSideBar />
{/if} {/if}
<div class="org-slot w-full bg-white dark:bg-gray-800"> <div class="org-slot bg-white dark:bg-gray-800 w-full">
<slot /> <slot />
</div> </div>
</div> </div>
{:else if isLMSPage($page.url.pathname)} {:else if isLMSPage($page.url.pathname)}
<div class="org-root w-full flex items-center justify-between"> <div class="org-root w-full flex items-center justify-between">
<LMSSideBar /> <LMSSideBar />
<div class="org-slot w-full bg-white dark:bg-gray-800"> <div class="org-slot bg-white dark:bg-gray-800 w-full">
<slot /> <slot />
</div> </div>
</div> </div>
@@ -0,0 +1,60 @@
import { render } from 'svelte-email';
import { json } from '@sveltejs/kit';
import JoinRequestTemplate from './template.svelte';
import { getSendgrid, SENDGRID_FROM_NOREPLY } from '$lib/utils/services/sendgrid';
import { getSupabase } from '$lib/utils/functions/supabase';
const sendgrid = getSendgrid();
const supabase = getSupabase();
export async function POST({ request }) {
const { to, courseName, teacherEmail, studentFullname, orgName } = await request.json();
const accessToken = request.headers.get('Authorization') || '';
console.log(
'/POST api/email/course/student_prove_payment',
to,
courseName,
teacherEmail,
studentFullname,
orgName
);
if (!to || !courseName || !teacherEmail || !studentFullname || !orgName) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 });
}
let user;
try {
const { data } = await supabase.auth.getUser(accessToken);
user = data.user;
} catch (error) {
console.error(error);
}
if (!user) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
}
const options = {
from: SENDGRID_FROM_NOREPLY,
to,
replyTo: teacherEmail,
subject: `[${courseName}] One more step left`,
html: render({
template: JoinRequestTemplate,
props: {
courseName,
studentFullname,
teacherEmail,
orgName
}
})
};
sendgrid.send(options);
return json({
success: true,
message: 'Email sent'
});
}
@@ -0,0 +1,37 @@
<script lang="ts">
import { Text } from 'svelte-email';
export let courseName = '';
export let studentFullname = '';
export let teacherEmail = '';
export let orgName = '';
const fontFamily =
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif';
const paragraph = {
fontFamily,
fontSize: '16px',
lineHeight: '26px'
};
const paragraphContent = {
...paragraph,
margin: '20px 0'
};
const footerParagraph = {
...paragraph,
margin: '0px'
};
</script>
<Text style={paragraph}>Hi {studentFullname},</Text>
<Text style={paragraphContent}>
You are one step closer to joining: {courseName}
</Text>
<Text style={paragraphContent}>
Please send your proof of payment to: {teacherEmail}, in order to join the course.
</Text>
<Text style={footerParagraph}>Talk to you soon and see you in class.</Text>
<Text style={footerParagraph}>{orgName}</Text>
@@ -0,0 +1,19 @@
<script lang="ts">
import EmailTemplate from '$lib/components/Email/EmailTemplate.svelte';
import ProvePayment from './prove_payment.svelte';
export let courseName = '';
export let studentFullname = '';
export let orgName = '';
export let teacherEmail = '';
</script>
<EmailTemplate
children={ProvePayment}
props={{
courseName,
studentFullname,
orgName,
teacherEmail
}}
/>
@@ -0,0 +1,51 @@
import { render } from 'svelte-email';
import { json } from '@sveltejs/kit';
import WelcomeTemplate from './template.svelte';
import { getSendgrid, SENDGRID_FROM_NOREPLY } from '$lib/utils/services/sendgrid';
import { getSupabase } from '$lib/utils/functions/supabase';
const sendgrid = getSendgrid();
const supabase = getSupabase();
// Sends email to student when they successfully join a course
export async function POST({ request }) {
const { to, orgName, courseName } = await request.json();
const accessToken = request.headers.get('Authorization') || '';
console.log('/POST api/email/course/student_welcome', to, orgName);
if (!to || !orgName || !courseName) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 });
}
let user;
try {
const { data } = await supabase.auth.getUser(accessToken);
user = data.user;
} catch (error) {
console.error(error);
}
if (!user) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
}
const options = {
from: SENDGRID_FROM_NOREPLY,
to,
subject: `[${orgName}] Welcome to Class`,
html: render({
template: WelcomeTemplate,
props: {
orgName,
courseName
}
})
};
sendgrid.send(options);
return json({
success: true,
message: 'Email sent'
});
}
@@ -0,0 +1,15 @@
<script lang="ts">
import EmailTemplate from '$lib/components/Email/EmailTemplate.svelte';
import Welcome from './welcome.svelte';
export let orgName = '';
export let courseName = '';
</script>
<EmailTemplate
children={Welcome}
props={{
orgName,
courseName
}}
/>
@@ -0,0 +1,37 @@
<script lang="ts">
import { Text } from 'svelte-email';
export let orgName = '';
export let courseName = '';
const fontFamily =
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif';
const paragraph = {
fontFamily,
fontSize: '16px',
lineHeight: '26px'
};
const paragraphContent = {
...paragraph,
margin: '20px 0'
};
const footerParagraph = {
...paragraph,
margin: '0px'
};
</script>
<Text style={paragraph}>Hey there,</Text>
<Text style={paragraphContent}>
Congratulations, you’ve successfully joined “{courseName}"
</Text>
<Text style={paragraphContent}>
Everything has been set up for you to have an amazing learning experience.
</Text>
<Text style={paragraphContent}>
If you run into any issues, don’t fail to reach out to your instructor(s).
</Text>
<Text style={footerParagraph}>Cheers,</Text>
<Text style={footerParagraph}>{orgName}</Text>
@@ -0,0 +1,57 @@
import { render } from 'svelte-email';
import { json } from '@sveltejs/kit';
import JoinRequestTemplate from './template.svelte';
import { getSendgrid, SENDGRID_FROM_NOREPLY } from '$lib/utils/services/sendgrid';
import { getSupabase } from '$lib/utils/functions/supabase';
const sendgrid = getSendgrid();
const supabase = getSupabase();
export async function POST({ request }) {
const { to, courseName, studentEmail, studentFullname } = await request.json();
const accessToken = request.headers.get('Authorization') || '';
console.log(
'/POST api/email/course/teacher_student_buycourse',
to,
courseName,
studentEmail,
studentFullname
);
if (!to || !courseName || !studentEmail || !studentFullname) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 });
}
let user;
try {
const { data } = await supabase.auth.getUser(accessToken);
user = data.user;
} catch (error) {
console.error(error);
}
if (!user) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
}
const options = {
from: SENDGRID_FROM_NOREPLY,
to,
subject: `[${courseName}] Request to Join Course!`,
html: render({
template: JoinRequestTemplate,
props: {
courseName,
studentFullname,
studentEmail
}
})
};
sendgrid.send(options);
return json({
success: true,
message: 'Email sent'
});
}
@@ -0,0 +1,44 @@
<script lang="ts">
import { Text, Link } from 'svelte-email';
export let courseName = '';
export let studentFullname = '';
export let studentEmail = '';
const fontFamily =
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif';
const paragraph = {
fontFamily,
fontSize: '16px',
lineHeight: '26px'
};
const paragraphContent = {
...paragraph,
margin: '20px 0'
};
const footerParagraph = {
...paragraph,
margin: '0px'
};
const linkStyle = {
color: '#1d4ee2'
};
</script>
<Text style={paragraph}>Hi amazing tutor,</Text>
<Text style={paragraphContent}>
A new student has requested to join a course you are teaching: “{courseName}"
</Text>
<Text style={paragraphContent}>Student details</Text>
<Text style={paragraphContent}>
Name: {studentFullname}<br />
Email: {studentEmail}
</Text>
<Text style={footerParagraph}>Cheers</Text>
<Text style={footerParagraph}>
<Link style={linkStyle} href="https://classroomio.com">ClassroomIO</Link>
</Text>
@@ -0,0 +1,17 @@
<script lang="ts">
import EmailTemplate from '$lib/components/Email/EmailTemplate.svelte';
import JoinRequest from './join_request.svelte';
export let courseName = '';
export let studentFullname = '';
export let studentEmail = '';
</script>
<EmailTemplate
children={JoinRequest}
props={{
courseName,
studentFullname,
studentEmail
}}
/>
@@ -0,0 +1,49 @@
import { render } from 'svelte-email';
import { json } from '@sveltejs/kit';
import WelcomeTemplate from './template.svelte';
import { getSendgrid, SENDGRID_FROM_NOREPLY } from '$lib/utils/services/sendgrid';
import { getSupabase } from '$lib/utils/functions/supabase';
const sendgrid = getSendgrid();
const supabase = getSupabase();
export async function POST({ request }) {
const { to, courseName } = await request.json();
const accessToken = request.headers.get('Authorization') || '';
console.log('/POST api/email/course/teacher_student_joined', to, courseName);
if (!to || !courseName) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 });
}
let user;
try {
const { data } = await supabase.auth.getUser(accessToken);
user = data.user;
} catch (error) {
console.error(error);
}
if (!user) {
return json({ success: false, message: 'Unauthenticated user' }, { status: 401 });
}
const options = {
from: SENDGRID_FROM_NOREPLY,
to,
subject: `[${courseName}] You've got a new student!`,
html: render({
template: WelcomeTemplate,
props: {
courseName
}
})
};
sendgrid.send(options);
return json({
success: true,
message: 'Email sent'
});
}
@@ -0,0 +1,13 @@
<script lang="ts">
import EmailTemplate from '$lib/components/Email/EmailTemplate.svelte';
import Welcome from './welcome.svelte';
export let courseName = '';
</script>
<EmailTemplate
children={Welcome}
props={{
courseName
}}
/>
@@ -0,0 +1,44 @@
<script lang="ts">
import { Text, Link } from 'svelte-email';
export let courseName = '';
const fontFamily =
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif';
const paragraph = {
fontFamily,
fontSize: '16px',
lineHeight: '26px'
};
const paragraphContent = {
...paragraph,
margin: '20px 0'
};
const footerParagraph = {
...paragraph,
margin: '0px'
};
const linkStyle = {
color: '#1d4ee2'
};
</script>
<Text style={paragraph}>Hi amazing tutor,</Text>
<Text style={paragraphContent}>
Congratulations, a new student has joined a course you are teaching: “{courseName}"
</Text>
<Text style={paragraphContent}>
We hope they have a great experience learning from the best (YOU).
</Text>
<Text style={paragraphContent}>
If you run into any issues, please don’t fail to reach out to us, we’d love to make your teaching
experience as easy as possible.
</Text>
<Text style={footerParagraph}>Cheers</Text>
<Text style={footerParagraph}>
<Link style={linkStyle} href="https://classroomio.com">ClassroomIO</Link>
</Text>
@@ -1,7 +1,7 @@
import { render } from 'svelte-email'; import { render } from 'svelte-email';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import WelcomeTemplate from './template.svelte'; import WelcomeTemplate from './template.svelte';
import { getSendgrid, SENDGRID_FROM_NOTIFY } from '$lib/utils/services/sendgrid'; import { getSendgrid, SENDGRID_FROM_NOREPLY } from '$lib/utils/services/sendgrid';
import { getSupabase } from '$lib/utils/functions/supabase'; import { getSupabase } from '$lib/utils/functions/supabase';
const sendgrid = getSendgrid(); const sendgrid = getSendgrid();
@@ -10,7 +10,7 @@ const supabase = getSupabase();
export async function POST({ request }) { export async function POST({ request }) {
const { to, name, orgName, courseName, orgSiteName } = await request.json(); const { to, name, orgName, courseName, orgSiteName } = await request.json();
const accessToken = request.headers.get('Authorization') || ''; const accessToken = request.headers.get('Authorization') || '';
console.log('/POST api/email/course/welcome', to, name, orgName); console.log('/POST api/email/course/teacher_welcome', to, name, orgName);
if (!to || !name || !orgName || !courseName || !orgSiteName) { if (!to || !name || !orgName || !courseName || !orgSiteName) {
return json({ success: false, message: 'Missing required fields' }, { status: 400 }); return json({ success: false, message: 'Missing required fields' }, { status: 400 });
@@ -32,7 +32,7 @@ export async function POST({ request }) {
const inviteLink = `${origin}/org/${orgSiteName}/courses`; const inviteLink = `${origin}/org/${orgSiteName}/courses`;
const options = { const options = {
from: SENDGRID_FROM_NOTIFY, from: SENDGRID_FROM_NOREPLY,
to, to,
subject: `You have been invited to a course in ${orgName}!`, subject: `You have been invited to a course in ${orgName}!`,
html: render({ html: render({
+4 -4
View File
@@ -1,7 +1,7 @@
import { render } from 'svelte-email'; import { render } from 'svelte-email';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import TeacherInviteTemplate from './template.svelte'; import TeacherInviteTemplate from './template.svelte';
import { getSendgrid, SENDGRID_FROM_NOTIFY } from '$lib/utils/services/sendgrid'; import { getSendgrid, SENDGRID_FROM_NOREPLY } from '$lib/utils/services/sendgrid';
import { getSupabase } from '$lib/utils/functions/supabase'; import { getSupabase } from '$lib/utils/functions/supabase';
const sendgrid = getSendgrid(); const sendgrid = getSendgrid();
@@ -15,7 +15,7 @@ export async function POST({ request }) {
const accessToken = request.headers.get('Authorization'); const accessToken = request.headers.get('Authorization');
console.log('/POST api/email/invite', body); console.log('/POST api/email/invite', body);
if (!org || !Object.keys(org) || !email) { if (!org || !Object.keys(org) || !email || !accessToken) {
return json( return json(
{ success: false, message: 'Org data and Teacher name are required' }, { success: false, message: 'Org data and Teacher name are required' },
{ status: 400 } { status: 400 }
@@ -43,11 +43,11 @@ export async function POST({ request }) {
orgId: id, orgId: id,
orgSiteName: siteName orgSiteName: siteName
}); });
const inviteLink = `${origin}/invite/${encodeURIComponent(btoa(inviteData))}`; const inviteLink = `${origin}/invite/t/${encodeURIComponent(btoa(inviteData))}`;
console.log('inviteData', inviteData); console.log('inviteData', inviteData);
const options = { const options = {
from: SENDGRID_FROM_NOTIFY, from: SENDGRID_FROM_NOREPLY,
to: email, to: email,
subject: `Join ${name} on ClassroomIO`, subject: `Join ${name} on ClassroomIO`,
html: render({ html: render({
+11 -1
View File
@@ -1,5 +1,15 @@
export const load = ({ params = { slug: '' } }) => { import { fetchCourse } from '$lib/utils/services/courses';
import { supabase, getSupabase } from '$lib/utils/functions/supabase';
if (!supabase) {
getSupabase();
}
export const load = async ({ params = { slug: '' } }) => {
const { data } = await fetchCourse(undefined, params.slug);
return { return {
slug: params.slug, slug: params.slug,
course: data
}; };
}; };
+13 -31
View File
@@ -1,39 +1,21 @@
<script> <script lang="ts">
import Backdrop from '$lib/components/Backdrop/index.svelte';
import { Moon } from 'svelte-loading-spinners';
import CourseLandingPage from '$lib/components/CourseLandingPage/index.svelte'; import CourseLandingPage from '$lib/components/CourseLandingPage/index.svelte';
import { profile } from '$lib/utils/store/user'; import type { Course } from '$lib/utils/types';
import { setCourse, course } from '$lib/components/Course/store';
import { lessons } from '$lib/components/Course/components/Lesson/store/lessons';
import { fetchCourse } from '$lib/utils/services/courses';
import { browser } from '$app/environment';
export let data; interface Data {
course: Course;
}
export let data: Data;
let courseData = {}; let courseData: Course;
let loading = false;
async function triggerSetCourse(_profile) { function setCourseData(course: Course | null) {
if (!browser) return; if (!course) return;
loading = true;
const { data: _data } = await fetchCourse(undefined, data.slug); courseData = { ...course };
setCourse(_data);
loading = false;
} }
function setCourseData(course, lessons) { $: setCourseData(data.course);
courseData = { ...course, lessons };
console.log('courseData', courseData);
}
$: triggerSetCourse($profile);
$: setCourseData($course, $lessons);
</script> </script>
{#if loading} <CourseLandingPage {courseData} />
<Backdrop>
<Moon size="60" color="#1d4ed8" unit="px" duration="1s" />
</Backdrop>
{:else}
<CourseLandingPage {courseData} />
{/if}
+70 -67
View File
@@ -138,77 +138,80 @@
<CourseContainer bind:isStudent> <CourseContainer bind:isStudent>
<PageNav title="Attendance" /> <PageNav title="Attendance" />
<PageBody width="w-full max-w-6xl md:w-11/12">
<section class="mx-9 my-5"> <section class="flex items-center mx-2 lg:mx-9 my-5">
<div class="flex flex-row items-center justify-between"> <div
<div class="flex"> class="flex flex-col lg:flex-row items-start gap-2 lg:items-center justify-between w-full"
<p class="flex items-center mr-5"> >
<Checkbox checked disabled /> Present <div class="flex">
</p> <p class="flex items-center mr-5">
<p class="flex items-center"> <Checkbox checked disabled /> Present
<Checkbox disabled /> Absent </p>
</p> <p class="flex items-center">
<Checkbox disabled /> Absent
</p>
</div>
<div>
<Search
class="dark:text-slate-950"
placeholder="Search students"
bind:value={searchValue}
/>
</div>
</div> </div>
<div> </section>
<Search
class="dark:text-slate-950"
placeholder="Search students"
bind:value={searchValue}
/>
</div>
</div>
</section>
<section class="my-5 mx-9"> <section class="my-5 mx-2 lg:mx-9">
<StructuredList class="m-0"> <StructuredList class="m-0 relative">
<!-- Moved the lesson headers outside the students loop --> <!-- Moved the lesson headers outside the students loop -->
<StructuredListHead class="bg-primary-100"> <StructuredListHead class="bg-primary-100">
<StructuredListRow head class="mx-7"> <StructuredListRow head class="mx-7">
<StructuredListCell head class="text-primary-600 py-3">Student</StructuredListCell> <StructuredListCell head class="text-primary-600 py-3">Student</StructuredListCell>
{#each $lessons as lesson, index} {#each $lessons as lesson, index}
<StructuredListCell head class="text-primary-600 py-3" <StructuredListCell head class="text-primary-600 py-3"
>Lesson 0{getLectureNo(index + 1)}</StructuredListCell >Lesson 0{getLectureNo(index + 1)}</StructuredListCell
> >
{/each}
</StructuredListRow>
</StructuredListHead>
{#each searchStudents(searchValue) as student}
<StructuredListBody>
<StructuredListRow>
<StructuredListCell>
<div class="w-1/4 flex items-center">
<p class="dark:text-white font-semibold">
{student.profile.fullname}
</p>
</div>
</StructuredListCell>
{#each $lessons as lesson}
<StructuredListCell class="">
<Checkbox
class={isStudent && 'cursor-not-allowed'}
disabled={isStudent}
checked={$attendance[student.id]
? $attendance[student.id][lesson.id]
? $attendance[student.id][lesson.id].is_present
: false
: false}
on:change={(e) => handleAttendanceChange(e, student, lesson)}
/>
</StructuredListCell>
{/each} {/each}
</StructuredListRow> </StructuredListRow>
</StructuredListBody> </StructuredListHead>
{/each}
</StructuredList> {#each searchStudents(searchValue) as student}
{#if students.length === 0} <StructuredListBody>
<div> <StructuredListRow>
<Box> <StructuredListCell>
<div class="w-1/4 flex items-center">
<p class="dark:text-white font-semibold">
{student.profile.fullname}
</p>
</div>
</StructuredListCell>
{#each $lessons as lesson}
<StructuredListCell class="">
<Checkbox
class={isStudent && 'cursor-not-allowed'}
disabled={isStudent}
checked={$attendance[student.id]
? $attendance[student.id][lesson.id]
? $attendance[student.id][lesson.id].is_present
: false
: false}
on:change={(e) => handleAttendanceChange(e, student, lesson)}
/>
</StructuredListCell>
{/each}
</StructuredListRow>
</StructuredListBody>
{/each}
</StructuredList>
{#if students.length === 0}
<Box className="h-[300px] w-full">
<AudioConsoleIcon size={32} class="carbon-icon w-80" /> <AudioConsoleIcon size={32} class="carbon-icon w-80" />
<h3 class="text-3xl text-gray-500 dark:text-white">No Student Added</h3> <h3 class="text-3xl text-gray-500 dark:text-white text-center">No Student Added</h3>
</Box> </Box>
</div> {/if}
{/if} {#if students.length !== 0}
<Pagination totalItems={10} pageSizes={[10, 15, 20]} /> <Pagination totalItems={10} pageSizes={[10, 15, 20]} />
</section> {/if}
</section>
</PageBody>
</CourseContainer> </CourseContainer>
@@ -30,26 +30,21 @@
</script> </script>
<div <div
class="root absolute inset-0 flex justify-center items-center z-50 bg-white" class="absolute inset-0 z-50 bg-white"
in:fly={{ y: 500, duration: 500 }} in:fly={{ y: 500, duration: 500 }}
out:fly={{ y: 500, duration: 500 }} out:fly={{ y: 500, duration: 500 }}
> >
<Editor {courseId} bind:course={courseData} /> <Editor {courseId} bind:course={courseData} />
<div class="rightBar" class:isMobile={$isMobile}> <div class="rightBar">
<CourseLandingPage bind:courseData /> <CourseLandingPage bind:courseData editMode={true} />
</div> </div>
</div> </div>
<style> <style>
.rightBar { .rightBar {
flex-grow: 1; flex-grow: 1;
width: calc(100% - 360px); width: 100%;
overflow: auto;
height: 100%; height: 100%;
overflow: auto; overflow-y: auto;
}
.rightBar.isMobile {
margin-left: 10px;
} }
</style> </style>
+2 -2
View File
@@ -321,12 +321,12 @@
</div> </div>
{:else} {:else}
<Box> <Box>
<div class="flex justify-between flex-col items-center w-96"> <div class="flex justify-between flex-col items-center w-[90%] md:w-96">
<img src="/images/empty-lesson-icon.svg" alt="Lesson" class="my-2.5 mx-auto" /> <img src="/images/empty-lesson-icon.svg" alt="Lesson" class="my-2.5 mx-auto" />
<h2 class="text-xl my-1.5 font-normal">No lessons yet</h2> <h2 class="text-xl my-1.5 font-normal">No lessons yet</h2>
<p class="text-sm text-center text-slate-500"> <p class="text-sm text-center text-slate-500">
Share your knowledge with the world by creating engaging lessons. Start by clicking on Share your knowledge with the world by creating engaging lessons. Start by clicking on
the New Lesson button. the Add button.
</p> </p>
</div> </div>
</Box> </Box>
@@ -4,17 +4,19 @@
import { fetchCourse, fetchLesson } from '$lib/utils/services/courses'; import { fetchCourse, fetchLesson } from '$lib/utils/services/courses';
import CheckmarkOutlineIcon from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte'; import CheckmarkOutlineIcon from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants.js'; import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { Loading } from 'carbon-components-svelte'; import { Loading } from 'carbon-components-svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte'; import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import PageNav from '$lib/components/PageNav/index.svelte'; import PageNav from '$lib/components/PageNav/index.svelte';
import PageBody from '$lib/components/PageBody/index.svelte'; import PageBody from '$lib/components/PageBody/index.svelte';
import Materials from '$lib/components/Course/components/Lesson/Materials/index.svelte'; import Materials from '$lib/components/Course/components/Lesson/Materials/index.svelte';
import Exercises from '$lib/components/Course/components/Lesson/Exercises/index.svelte'; import Exercises from '$lib/components/Course/components/Lesson/Exercises/index.svelte';
import MODES from '$lib/utils/constants/mode.js'; import MODES from '$lib/utils/constants/mode';
import { setCourse, course } from '$lib/components/Course/store'; import { setCourse, course } from '$lib/components/Course/store';
import Download from 'carbon-icons-svelte/lib/Download.svelte'; import Download from 'carbon-icons-svelte/lib/Download.svelte';
import OverflowMenuVertical from 'carbon-icons-svelte/lib/OverflowMenuVertical.svelte';
import { apps } from '$lib/components/Apps/store';
import IconButton from '$lib/components/IconButton/index.svelte';
import { import {
lesson, lesson,
lessons, lessons,
@@ -144,6 +146,10 @@
})); }));
} }
const toggleApps = () => {
$apps.open = !$apps.open;
};
function toggleMode() { function toggleMode() {
prevMode = mode; prevMode = mode;
mode = mode === MODES.edit ? MODES.view : MODES.edit; mode = mode === MODES.edit ? MODES.view : MODES.edit;
@@ -176,9 +182,20 @@
<svelte:fragment slot="widget"> <svelte:fragment slot="widget">
<RoleBasedSecurity allowedRoles={[1, 2]}> <RoleBasedSecurity allowedRoles={[1, 2]}>
{#if data.isMaterialsTabActive} {#if data.isMaterialsTabActive}
<div class="flex items-center"> <div class="lg:hidden">
<IconButton onClick={toggleApps} buttonClassName="">
<OverflowMenuVertical size={24} />
</IconButton>
</div>
<div
class={`flex-row ${
$apps.dropdown && $apps.open
? 'absolute lg:relative top-[85px] lg:top-0 right-14 lg:right-0 z-40 dark:bg-gray-800 p-3 lg:p-0'
: 'hidden'
} lg:flex items-center`}
>
<PrimaryButton <PrimaryButton
className="mr-2" className="mb-2 lg:mb-0 mr-2"
variant={VARIANTS.OUTLINED} variant={VARIANTS.OUTLINED}
onClick={downloadLesson} onClick={downloadLesson}
{isLoading} {isLoading}
@@ -207,7 +224,7 @@
<PageBody> <PageBody>
<Materials lessonId={data.lessonId} {mode} {prevMode} {toggleMode} bind:isSaving /> <Materials lessonId={data.lessonId} {mode} {prevMode} {toggleMode} bind:isSaving />
<div class="w-full flex flex-row-reverse"> <div class="w-full flex flex-row-reverse mt-10">
<PrimaryButton <PrimaryButton
onClick={markLessonComplete} onClick={markLessonComplete}
isLoading={isMarkingComplete} isLoading={isMarkingComplete}
+15 -33
View File
@@ -8,10 +8,7 @@
import { ROLE } from '$lib/utils/constants/roles'; import { ROLE } from '$lib/utils/constants/roles';
import { setCourse, course, group } from '$lib/components/Course/store'; import { setCourse, course, group } from '$lib/components/Course/store';
import { lessons } from '$lib/components/Course/components/Lesson/store/lessons'; import { lessons } from '$lib/components/Course/components/Lesson/store/lessons';
import { import { fetchCourse, fetchExercisesByMarks } from '$lib/utils/services/courses';
fetchCourse,
fetchExercisesByMarks,
} from '$lib/utils/services/courses';
import { getLectureNo } from '$lib/components/Course/function'; import { getLectureNo } from '$lib/components/Course/function';
import { fetchMarks } from '$lib/utils/services/marks'; import { fetchMarks } from '$lib/utils/services/marks';
import { profile } from '$lib/utils/store/user'; import { profile } from '$lib/utils/store/user';
@@ -19,10 +16,8 @@
export let data; export let data;
const { courseId } = data; const { courseId } = data;
let borderBottomGrey = let borderBottomGrey = 'border-r-0 border-t-0 border-b border-l-0 border-gray-300';
'border-r-0 border-t-0 border-b border-l-0 border-gray-300'; let borderleftGrey = 'border-r-0 border-t-0 border-b-0 border-l border-gray-300';
let borderleftGrey =
'border-r-0 border-t-0 border-b-0 border-l border-gray-300';
let students = []; let students = [];
let isStudent = false; let isStudent = false;
@@ -52,11 +47,11 @@
if (studentMarksByExerciseId[groupmember_id]) { if (studentMarksByExerciseId[groupmember_id]) {
studentMarksByExerciseId[groupmember_id] = { studentMarksByExerciseId[groupmember_id] = {
...studentMarksByExerciseId[groupmember_id], ...studentMarksByExerciseId[groupmember_id],
[exercise_id]: total_points_gotten, [exercise_id]: total_points_gotten
}; };
} else { } else {
studentMarksByExerciseId[groupmember_id] = { studentMarksByExerciseId[groupmember_id] = {
[exercise_id]: total_points_gotten, [exercise_id]: total_points_gotten
}; };
} }
}); });
@@ -70,11 +65,11 @@
if (lessonMapping[lesson_id]) { if (lessonMapping[lesson_id]) {
lessonMapping[lesson_id] = { lessonMapping[lesson_id] = {
...lessonMapping[lesson_id], ...lessonMapping[lesson_id],
[exercise_id]: { title: exercise_title, points }, [exercise_id]: { title: exercise_title, points }
}; };
} else { } else {
lessonMapping[lesson_id] = { lessonMapping[lesson_id] = {
[exercise_id]: { title: exercise_title, points }, [exercise_id]: { title: exercise_title, points }
}; };
} }
}); });
@@ -82,20 +77,16 @@
$: { $: {
students = isStudent students = isStudent
? $group.people.filter( ? $group.people.filter((person) => !!person.profile && person.profile.id === $profile.id)
(person) => !!person.profile && person.profile.id === $profile.id : $group.people.filter((person) => !!person.profile && person.role_id === ROLE.STUDENT);
)
: $group.people.filter(
(person) => !!person.profile && person.role_id === ROLE.STUDENT
);
} }
</script> </script>
<CourseContainer bind:isStudent> <CourseContainer bind:isStudent>
<PageNav title="Marks" /> <PageNav title="Marks" />
<PageBody width="w-11/12"> <PageBody width="w-full max-w-6xl md:w-11/12">
<div class="table rounded-md border border-gray-300 w-auto"> <div class="table rounded-md border border-gray-300 w-full">
<div class="flex items-center {borderBottomGrey}"> <div class="flex items-center {borderBottomGrey}">
<div class="box flex items-center p-3"> <div class="box flex items-center p-3">
<p class="dark:text-white w-40">Student</p> <p class="dark:text-white w-40">Student</p>
@@ -111,8 +102,7 @@
> >
{#each Object.keys(lessonMapping[lesson.id]) as exerciseId, index} {#each Object.keys(lessonMapping[lesson.id]) as exerciseId, index}
<p <p
class="col text-sm dark:text-white {index && class="col text-sm dark:text-white {index && borderleftGrey}"
borderleftGrey}"
title={lessonMapping[lesson.id][exerciseId].title} title={lessonMapping[lesson.id][exerciseId].title}
> >
{lessonMapping[lesson.id][exerciseId].title} {lessonMapping[lesson.id][exerciseId].title}
@@ -129,9 +119,7 @@
</div> </div>
{#each students as student} {#each students as student}
<div <div class="flex relative items-center p-3 cursor-pointer {borderBottomGrey}">
class="flex relative items-center p-3 cursor-pointer {borderBottomGrey}"
>
<div class="w-40 flex items-center"> <div class="w-40 flex items-center">
<img <img
alt="Student avatar" alt="Student avatar"
@@ -143,11 +131,7 @@
{student.profile.fullname} {student.profile.fullname}
</p> </p>
<p class="dark:text-white"> <p class="dark:text-white">
{`${ {`${student.assigned_student_id ? '#' + student.assigned_student_id : '-'}`}
student.assigned_student_id
? '#' + student.assigned_student_id
: '-'
}`}
</p> </p>
</div> </div>
</div> </div>
@@ -176,9 +160,7 @@
{:else} {:else}
<Box> <Box>
<AudioConsoleIcon size={32} class="carbon-icon w-80" /> <AudioConsoleIcon size={32} class="carbon-icon w-80" />
<h3 class="text-3xl text-gray-500 dark:text-white"> <h3 class="text-3xl text-gray-500 dark:text-white">No Student Added</h3>
No Student Added
</h3>
</Box> </Box>
{/each} {/each}
</div> </div>
+141 -129
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import TextChip from '$lib/components/Chip/Text.svelte'; import TextChip from '$lib/components/Chip/Text.svelte';
import ComingSoon from '$lib/components/ComingSoon/index.svelte'; import ComingSoon from '$lib/components/ComingSoon/index.svelte';
import Filter from 'carbon-icons-svelte/lib/Filter.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import copy from 'copy-to-clipboard'; import copy from 'copy-to-clipboard';
import { import {
@@ -17,6 +18,7 @@
import { fetchCourse, deleteGroupMember, updatedGroupMember } from '$lib/utils/services/courses'; import { fetchCourse, deleteGroupMember, updatedGroupMember } from '$lib/utils/services/courses';
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte'; import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.svelte';
import PageNav from '$lib/components/PageNav/index.svelte'; import PageNav from '$lib/components/PageNav/index.svelte';
import PageBody from '$lib/components/PageBody/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import Select from '$lib/components/Form/Select.svelte'; import Select from '$lib/components/Form/Select.svelte';
import IconButton from '$lib/components/IconButton/index.svelte'; import IconButton from '$lib/components/IconButton/index.svelte';
@@ -135,138 +137,148 @@
</RoleBasedSecurity> </RoleBasedSecurity>
</slot:fragment> </slot:fragment>
</PageNav> </PageNav>
<section class="my-5 mx-9"> <PageBody width="w-full max-w-6xl md:w-11/12">
<div class="flex flex-row flex-end justify-end items-center mb-7"> <section class="my-5 mx-2 md:mx-9">
<div class="mr-5 w-80"> <div
<Search class="flex flex-col md:flex-row flex-end gap-2 justify-end items-start md:items-center mb-7"
class="dark:text-slate-950 border-0 bg-zinc-100 w-full"
placeholder="Search people"
bind:value={searchValue}
/>
</div>
<div class="mb-3">
<Select bind:value={filterBy} options={ROLES} className="dark:text-black mt-3 w-20" />
<!-- <select bind:value={filterBy} class="mt-3">
{#each ROLES as option}
<option value={option.value}>{option.label}</option>
{/each}
</select> -->
</div>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<p class="dark:text-white hidden lg:block text-lg w-20" />
</RoleBasedSecurity>
</div>
<StructuredList class="m-0">
<StructuredListHead
class="bg-slate-100 dark:bg-neutral-800 dark:border-2 dark:border-neutral-800"
> >
<StructuredListRow head class="mx-7"> <div class="max-w-[320px]">
<StructuredListCell head class="text-primary-700 py-3 dark:text-white" <Search
>Name</StructuredListCell class="dark:text-slate-950 border-0 bg-zinc-100 w-full"
> placeholder="Search people"
<StructuredListCell head class="text-primary-700 py-3 dark:text-white" bind:value={searchValue}
>Role</StructuredListCell />
> </div>
<StructuredListCell head class="text-primary-700 py-3 dark:text-white" <div class="mb-3">
>Action</StructuredListCell <Select
> bind:value={filterBy}
<RoleBasedSecurity allowedRoles={[1, 2]}> options={ROLES}
<p class="dark:text-white hidden lg:block text-lg w-20" /> className="dark:text-black mt-3 max-w-[80px]"
</RoleBasedSecurity> />
</StructuredListRow> <!-- <select bind:value={filterBy} class="mt-3">
</StructuredListHead> {#each ROLES as option}
<option value={option.value}>{option.label}</option>
{/each}
</select> -->
</div>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<p class="dark:text-white hidden lg:block text-lg w-20" />
</RoleBasedSecurity>
</div>
{#each filterPeople(searchValue, people) as person} <StructuredList class="m-0">
<StructuredListBody> <StructuredListHead
<StructuredListRow class="relative"> class="bg-slate-100 dark:bg-neutral-800 dark:border-2 dark:border-neutral-800"
<!-- first column --> >
<StructuredListCell class="w-3/6"> <StructuredListRow head class="mx-7">
{#if person.profile} <StructuredListCell head class="text-primary-700 py-3 dark:text-white"
<div class="flex items-center absolute bottom-3"> >Name</StructuredListCell
<Avatar >
src={person.profile.avatar_url} <StructuredListCell head class="text-primary-700 py-3 dark:text-white"
name={person.profile.fullname} >Role</StructuredListCell
width="w-8" >
height="h-8" <StructuredListCell head class="text-primary-700 py-3 dark:text-white"
className="mr-3" >Action</StructuredListCell
/> >
<div class="flex items-center"> <RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="mr-2"> <p class="dark:text-white hidden lg:block text-lg w-20" />
<p class="dark:text-white text-base font-normal"> </RoleBasedSecurity>
{person.profile.fullname} </StructuredListRow>
</p> </StructuredListHead>
<p class="text-xs text-primary-600">
{getEmail(person)} {#each filterPeople(searchValue, people) as person}
</p> <StructuredListBody>
</div> <StructuredListRow class="relative">
<RoleBasedSecurity allowedRoles={[1, 2]}> <!-- first column -->
<div class="hidden lg:flex lg:justify-between lg:items-center mr-2"> <StructuredListCell class="w-4/6 md:w-3/6">
<CopyButton text={getEmail(person)} feedback="Copied Email to clipboard" /> {#if person.profile}
<div class="flex items-start lg:items-center">
<Avatar
src={person.profile.avatar_url}
name={person.profile.fullname}
width="w-8"
height="h-8"
className="mr-3"
/>
<div class="flex flex-col lg:flex-row items-start lg:items-center">
<div class="mr-2">
<p class="dark:text-white text-base font-normal">
{person.profile.fullname}
</p>
<p class="text-xs text-primary-600 line-clamp-1">
{getEmail(person)}
</p>
</div> </div>
</RoleBasedSecurity> <div class="flex items-center">
{#if person.profile_id == $profile.id} <RoleBasedSecurity allowedRoles={[1, 2]}>
<ComingSoon label="You" /> <CopyButton
text={getEmail(person)}
feedback="Copied Email to clipboard"
/>
</RoleBasedSecurity>
{#if person.profile_id == $profile.id}
<ComingSoon label="You" />
{/if}
</div>
</div>
</div>
{:else}
<div class="flex items-start lg:items-center w-2/4">
<TextChip
value={person.email.substring(0, 2).toUpperCase()}
className="bg-primary-200 text-black font-semibold text-xs mr-3"
shape="rounded-full"
/>
<a
href="mailto:{person.email}"
class="text-md text-primary-600 mr-2 dark:text-white"
>
{person.email}
</a>
<div class="flex items-center justify-between">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<CopyButton text={getEmail(person)} feedback="Copied Email to clipboard" />
</RoleBasedSecurity>
<TextChip
value="Pending"
className="text-xs bg-yellow-200 text-yellow-700 h-fit"
size="sm"
/>
</div>
</div>
{/if}
</StructuredListCell>
<!-- second column -->
<StructuredListCell class="w-1/4 px-3">
<p class=" dark:text-white font-normal text-center text-base w-1/4">
{ROLE_LABEL[person.role_id]}
</p>
</StructuredListCell>
<!-- third column -->
<StructuredListCell class="p-0 w-1/4">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="hidden lg:flex lg:justify-between lg:items-center w-20">
{#if person.profile_id !== $profile.id}
<IconButton
onClick={() => {
member = person;
$deleteMemberModal.open = true;
}}
>
<TrashCanIcon size={16} class="carbon-icon dark:text-white" />
</IconButton>
{/if} {/if}
</div> </div>
</div> </RoleBasedSecurity>
{:else} </StructuredListCell>
<div class="flex items-center w-2/4 absolute bottom-3"> </StructuredListRow>
<TextChip </StructuredListBody>
value={person.email.substring(0, 2).toUpperCase()} {/each}
className="bg-primary-200 text-black font-semibold text-xs mr-3" </StructuredList>
shape="rounded-full" <!-- <Pagination totalItems={10} pageSizes={[10, 15, 20]} /> -->
/> </section>
<a </PageBody>
href="mailto:{person.email}"
class="text-md text-primary-600 mr-2 dark:text-white"
>
{person.email}
</a>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="hidden lg:flex lg:justify-between lg:items-center w-20">
<CopyButton text={getEmail(person)} feedback="Copied Email to clipboard" />
</div>
</RoleBasedSecurity>
<TextChip
value="Pending"
className="text-xs bg-yellow-200 text-yellow-700 h-fit"
size="sm"
/>
</div>
{/if}
</StructuredListCell>
<!-- second column -->
<StructuredListCell class="w-1/4 py-4 px-3">
<p class="dark:text-white font-normal text-base w-1/4 my-auto">
{ROLE_LABEL[person.role_id]}
</p>
</StructuredListCell>
<!-- third column -->
<StructuredListCell class="p-0 w-1/4">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="hidden lg:flex lg:justify-between lg:items-center w-20">
{#if person.profile_id !== $profile.id}
<IconButton
onClick={() => {
member = person;
$deleteMemberModal.open = true;
}}
>
<TrashCanIcon size={16} class="carbon-icon dark:text-white" />
</IconButton>
{/if}
</div>
</RoleBasedSecurity>
</StructuredListCell>
</StructuredListRow>
</StructuredListBody>
{/each}
</StructuredList>
<Pagination totalItems={10} pageSizes={[10, 15, 20]} />
</section>
</CourseContainer> </CourseContainer>
@@ -244,8 +244,8 @@
<CourseContainer> <CourseContainer>
<PageNav title="Submitted Exercises" /> <PageNav title="Submitted Exercises" />
<PageBody width="w-11/12 overflow-x-auto overflow-y-hidden"> <PageBody width="w-full max-w-6xl md:w-11/12 overflow-x-auto">
<div class="flex items-center w-full"> <div class="flex items-center w-full overflow-x-scroll">
{#each sections as { id, title, items }, idx (id)} {#each sections as { id, title, items }, idx (id)}
<div <div
class="section rounded-md bg-gray-100 dark:bg-gray-800 border border-gray-50 p-3 h-80 mr-3 overflow-hidden" class="section rounded-md bg-gray-100 dark:bg-gray-800 border border-gray-50 p-3 h-80 mr-3 overflow-hidden"
@@ -301,7 +301,7 @@
{`${item.tutor ? 'created by' + item.tutor.name : ''}`} {`${item.tutor ? 'created by' + item.tutor.name : ''}`}
</p> </p>
</a> </a>
<p class="dark:text-white text-gray-500 dark:text-white text-xs"> <p class="dark:text-white text-gray-500 text-xs">
{item.submittedAt} {item.submittedAt}
</p> </p>
<!-- <div class="badge rounded-md px-2 bg-green-500 text-white"> <!-- <div class="badge rounded-md px-2 bg-green-500 text-white">
@@ -324,10 +324,15 @@
.section { .section {
max-width: 355px; max-width: 355px;
min-width: 355px; min-width: 355px;
height: 80vh; height: 75vh;
} }
.content { .content {
height: 95%; height: 95%;
} }
@media screen and (max-width: 768px) {
.section {
min-width: 250px;
}
}
</style> </style>
@@ -0,0 +1,31 @@
import { redirect } from '@sveltejs/kit';
import { getSupabase, supabase } from '$lib/utils/functions/supabase';
import { getCurrentOrg } from '$lib/utils/services/org';
if (!supabase) {
getSupabase();
}
export const load = async ({ params = { hash: '' } }) => {
try {
const courseHashData = atob(decodeURIComponent(params.hash));
console.log('courseHashData', courseHashData);
const { groupId, name, description, orgSiteName } = JSON.parse(courseHashData);
if (!groupId || !name || !description || !orgSiteName) {
throw 'Validation failed';
}
const currentOrg = await getCurrentOrg(orgSiteName, true);
return {
groupId,
name,
description,
currentOrg
};
} catch (error) {
console.error('Error decoding course invite params.hash', error);
throw redirect(301, '/404');
}
};
+125
View File
@@ -0,0 +1,125 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { getSupabase } from '$lib/utils/functions/supabase';
import AuthUI from '$lib/components/AuthUI/index.svelte';
import { currentOrg } from '$lib/utils/store/org';
import { setTheme } from '$lib/utils/functions/theme';
import { addGroupMember } from '$lib/utils/services/courses';
import type { CurrentOrg } from '$lib/utils/types/org.js';
import { ROLE } from '$lib/utils/constants/roles';
import { profile } from '$lib/utils/store/user.js';
import { sendStudentCourseWelcome, sendTeacherStudentJoinedCourse } from './utils';
export let data;
let supabase = getSupabase();
let loading = false;
let refresh = false;
let disableSubmit = false;
let formRef: HTMLFormElement;
async function handleSubmit() {
loading = true;
if (refresh) {
window.location.reload();
refresh = false;
return;
}
if (!$profile.id || !$profile.email) {
console.log('Profile not found', $profile);
refresh = true;
return;
}
const member = {
profile_id: $profile.id,
group_id: data.groupId,
role_id: ROLE.STUDENT
};
const teacherMembers = await supabase
.from('groupmember')
.select('id, profile(email)')
.eq('group_id', data.groupId)
.eq('role_id', ROLE.TUTOR);
const teachers: Array<string> =
teacherMembers.data?.map((teacher) => {
return teacher.profile?.email || '';
}) || [];
addGroupMember(member).then((addedMember) => {
loading = false;
if (addedMember.error) {
console.error('Error adding student to group', data.groupId);
return;
}
// Send email welcoming student to the course
sendStudentCourseWelcome({
to: $profile.email,
orgName: data.currentOrg?.name,
courseName: data.name
});
// Send notification to teacher(s) that a student has joined the course.
sendTeacherStudentJoinedCourse({
to: teachers,
courseName: data.name
});
// go to lms
return goto('/lms');
});
}
function setCurOrg(cOrg: CurrentOrg) {
if (!cOrg) return;
currentOrg.set(cOrg);
}
onMount(async () => {
setTheme(data.currentOrg?.theme || '');
});
$: setCurOrg(data.currentOrg as CurrentOrg);
</script>
<svelte:head>
<title>Join {data.name} on ClassroomIO</title>
</svelte:head>
<AuthUI
{supabase}
isLogin={false}
{handleSubmit}
isLoading={loading}
showOnlyContent={true}
showLogo={true}
bind:formRef
>
<div class="mt-0 w-full">
{#if refresh}
<p class="dark:text-white text-sm font-light text-center">
Something went wrong, please try again.
</p>
{:else}
<h3 class="dark:text-white text-lg font-medium mt-0 mb-4 text-center">{data.name}</h3>
<p class="dark:text-white text-sm font-light text-center">{data.description}</p>
{/if}
</div>
<div class="my-4 w-full flex justify-center items-center">
<PrimaryButton
label={refresh ? 'Try again' : 'Join Course'}
type="submit"
isDisabled={disableSubmit || loading}
isLoading={loading}
/>
</div>
</AuthUI>
+56
View File
@@ -0,0 +1,56 @@
import { supabase } from '$lib/utils/functions/supabase';
export async function sendStudentCourseWelcome(params: {
to: string;
orgName: string;
courseName: string;
}) {
const { data } = await supabase.auth.getSession();
const accessToken = data.session?.access_token || '';
try {
const response = await fetch('/api/email/course/student_welcome', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
},
body: JSON.stringify(params)
});
if (!response.ok) {
throw new Error(await response.text());
}
const result = await response.json();
console.log('Sent invite email', result);
} catch (error) {
console.error('Error sending invite email', error);
}
}
export async function sendTeacherStudentJoinedCourse(params: {
to: Array<string>;
courseName: string;
}) {
const { data } = await supabase.auth.getSession();
const accessToken = data.session?.access_token || '';
try {
const response = await fetch('/api/email/course/teacher_student_joined', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
},
body: JSON.stringify(params)
});
if (!response.ok) {
throw new Error(await response.text());
}
const result = await response.json();
console.log('Sent invite email', result);
} catch (error) {
console.error('Error sending invite email', error);
}
}
@@ -1,7 +1,14 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { getCurrentOrg } from '$lib/utils/services/org'; import { getCurrentOrg } from '$lib/utils/services/org';
import { getSupabase } from '$lib/utils/functions/supabase'; import { getSupabase, supabase } from '$lib/utils/functions/supabase';
import { getProfile } from '$lib/utils/functions/user';
if (!supabase) {
getSupabase();
}
// we need to know if the email exists or not.
// with this we can only ask the user to accept
export const load = async ({ params = { hash: '' } }) => { export const load = async ({ params = { hash: '' } }) => {
try { try {
const hashData = atob(decodeURIComponent(params.hash)); const hashData = atob(decodeURIComponent(params.hash));
@@ -9,13 +16,16 @@ export const load = async ({ params = { hash: '' } }) => {
const { orgId, email, orgSiteName } = JSON.parse(hashData); const { orgId, email, orgSiteName } = JSON.parse(hashData);
getSupabase();
const _currentOrg = await getCurrentOrg(orgSiteName, true); const _currentOrg = await getCurrentOrg(orgSiteName, true);
const profile = await getProfile(email);
console.log('profile data', profile);
return { return {
orgId, orgId,
email, email,
currentOrg: _currentOrg currentOrg: _currentOrg,
profile
}; };
} catch (error) { } catch (error) {
console.error('Error decoding invite params.hash', error); console.error('Error decoding invite params.hash', error);
@@ -10,11 +10,11 @@
} from '$lib/utils/functions/validator'; } from '$lib/utils/functions/validator';
import { SIGNUP_FIELDS } from '$lib/utils/constants/authentication'; import { SIGNUP_FIELDS } from '$lib/utils/constants/authentication';
import AuthUI from '$lib/components/AuthUI/index.svelte'; import AuthUI from '$lib/components/AuthUI/index.svelte';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import { setTheme } from '$lib/utils/functions/theme'; import { setTheme } from '$lib/utils/functions/theme';
import type { CurrentOrg } from '$lib/utils/types/org.js'; import type { CurrentOrg } from '$lib/utils/types/org.js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Error from '../../+error.svelte'; import type { Profile } from '$lib/components/Course/components/People/types';
export let data; export let data;
@@ -34,7 +34,10 @@
let formRef: HTMLFormElement; let formRef: HTMLFormElement;
async function handleSubmit() { async function handleSubmit() {
const validationRes = authValidation(fields); const validationRes = authValidation({
...fields,
email: '[email protected]' // validation for this ema
});
console.log('validationRes', validationRes); console.log('validationRes', validationRes);
if (Object.keys(validationRes).length) { if (Object.keys(validationRes).length) {
@@ -44,40 +47,44 @@
try { try {
loading = true; loading = true;
// Signup let profile: Profile | null = data.profile;
const { data: signupData, error } = await supabase.auth.signUp({
email: data.email,
password: fields.password
});
console.log('Signup', signupData); if (!data.profile) {
if (error) throw error; // Signup
const { data: signupData, error } = await supabase.auth.signUp({
email: data.email,
password: fields.password
});
if (!signupData.user) { console.log('Signup', signupData);
throw 'User cannot be created '; if (error) throw error;
if (!signupData.user) {
throw 'User cannot be created ';
}
// Insert profile
const userId = signupData.user.id;
const profileRes = await supabase
.from('profile')
.insert({
id: userId,
username: fields.name.toLowerCase().replace(' ', '-') + new Date().getTime(),
fullname: fields.name,
email: data.email
})
.select();
console.log('Insert profile', profileRes.data);
if (profileRes.error) {
throw profileRes.error;
}
profile = profileRes.data[0] || {};
} }
// Insert profile if (!profile?.id) {
const userId = signupData.user.id;
const profileRes = await supabase
.from('profile')
.insert({
id: userId,
username: fields.name.toLowerCase().replace(' ', '-') + new Date().getTime(),
fullname: fields.name,
email: data.email
})
.select();
console.log('Insert profile', profileRes.data);
if (profileRes.error) {
throw profileRes.error;
}
const profile = profileRes.data[0] || {};
if (!profile.id) {
throw 'Unable to create profile'; throw 'Unable to create profile';
} }
@@ -91,12 +98,23 @@
.match({ email: data.email, organization_id: data.currentOrg?.id }); .match({ email: data.email, organization_id: data.currentOrg?.id });
console.log('Update member response', updateMemberRes); console.log('Update member response', updateMemberRes);
formRef?.reset();
return goto('/login'); const res = await supabase.auth.signInWithPassword({
email: data.email,
password: fields.password
});
if (res.error) {
throw res.error;
}
formRef?.reset();
return goto($currentOrgPath);
} catch (error) { } catch (error) {
if (error instanceof Error) { if (error instanceof Error) {
submitError = error.message; submitError = error.message;
} else {
submitError = error?.toString() || '';
} }
} finally { } finally {
loading = false; loading = false;
@@ -116,6 +134,7 @@
$: disableSubmit = getDisableSubmit(fields); $: disableSubmit = getDisableSubmit(fields);
$: setCurOrg(data.currentOrg as CurrentOrg); $: setCurOrg(data.currentOrg as CurrentOrg);
$: console.log('data.profile', data.profile);
</script> </script>
<svelte:head> <svelte:head>
@@ -132,7 +151,13 @@
bind:formRef bind:formRef
> >
<div class="mt-4 w-full"> <div class="mt-4 w-full">
<p class="dark:text-white text-lg font-semibold mb-6">Create a free account</p> <p class="dark:text-white text-lg font-semibold mb-6">
{#if data.profile}
Log in to join
{:else}
Create a free account to join
{/if}
</p>
<TextField <TextField
label="Your Email" label="Your Email"
value={data.email} value={data.email}
@@ -142,18 +167,20 @@
inputClassName="w-full" inputClassName="w-full"
isDisabled={true} isDisabled={true}
/> />
<TextField {#if !data.profile}
label="Full Name" <TextField
bind:value={fields.name} label="Full Name"
type="text" bind:value={fields.name}
autoFocus={true} type="text"
placeholder="e.g Joke Silva" autoFocus={true}
className="mb-6" placeholder="e.g Joke Silva"
inputClassName="w-full" className="mb-6"
isDisabled={loading} inputClassName="w-full"
errorMessage={errors.name} isDisabled={loading}
isRequired errorMessage={errors.name}
/> isRequired
/>
{/if}
<TextField <TextField
label="Your Password" label="Your Password"
bind:value={fields.password} bind:value={fields.password}
@@ -166,17 +193,19 @@
helperMessage="Password must be more than 6 characters" helperMessage="Password must be more than 6 characters"
isRequired isRequired
/> />
<TextField {#if !data.profile}
label="Confirm Password" <TextField
bind:value={fields.confirmPassword} label="Confirm Password"
type="password" bind:value={fields.confirmPassword}
placeholder="************" type="password"
className="mb-6" placeholder="************"
inputClassName="w-full" className="mb-6"
isDisabled={loading} inputClassName="w-full"
errorMessage={errors.confirmPassword} isDisabled={loading}
isRequired errorMessage={errors.confirmPassword}
/> isRequired
/>
{/if}
{#if submitError} {#if submitError}
<p class="text-sm text-red-500">{submitError}</p> <p class="text-sm text-red-500">{submitError}</p>
{/if} {/if}
@@ -184,7 +213,7 @@
<div class="my-4 w-full flex justify-end items-center"> <div class="my-4 w-full flex justify-end items-center">
<PrimaryButton <PrimaryButton
label="Create and Join" label="Accept Invite"
type="submit" type="submit"
className="sm:w-full w-full" className="sm:w-full w-full"
isDisabled={disableSubmit || loading} isDisabled={disableSubmit || loading}
+11
View File
@@ -1,4 +1,5 @@
<script> <script>
import { page } from '$app/stores';
import TextField from '$lib/components/Form/TextField.svelte'; import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { getSupabase } from '$lib/utils/functions/supabase'; import { getSupabase } from '$lib/utils/functions/supabase';
@@ -14,6 +15,9 @@
let loading = false; let loading = false;
let errors = {}; let errors = {};
let query = new URLSearchParams($page.url.search);
let redirect = query.get('redirect');
async function handleSubmit() { async function handleSubmit() {
const validationRes = authValidation(fields); const validationRes = authValidation(fields);
console.log('validationRes', validationRes); console.log('validationRes', validationRes);
@@ -31,6 +35,13 @@
}); });
console.log('data', data); console.log('data', data);
if (error) throw error; if (error) throw error;
// reload page on org site cause for some reason the authlistener on +layout.svelte doesn't run
// on org site
if ($currentOrg.id && redirect) {
console.log('Forcing full page reload for auth listener');
window.location.reload();
}
} catch (error) { } catch (error) {
submitError = error.error_description || error.message; submitError = error.error_description || error.message;
} finally { } finally {
-4
View File
@@ -199,10 +199,6 @@
$: progress = Math.round((step / maxSteps) * 100); $: progress = Math.round((step / maxSteps) * 100);
$: fields.siteName = generateSitename(fields.siteName || ''); $: fields.siteName = generateSitename(fields.siteName || '');
$: setOrgSiteName(fields.orgName, isSiteNameTouched); $: setOrgSiteName(fields.orgName, isSiteNameTouched);
$: {
console.log('currentOrg', $currentOrg);
}
</script> </script>
{#if $profile.id} {#if $profile.id}
+7 -6
View File
@@ -15,6 +15,7 @@
import WelcomeModal from '$lib/components/WelcomeModal/WelcomeModal.svelte'; import WelcomeModal from '$lib/components/WelcomeModal/WelcomeModal.svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { Add } from 'carbon-icons-svelte'; import { Add } from 'carbon-icons-svelte';
import { isOrgAdmin } from '$lib/utils/store/org';
import { VARIANTS } from '$lib/components/PrimaryButton/constants.js'; import { VARIANTS } from '$lib/components/PrimaryButton/constants.js';
import VisitOrgSiteButton from '$lib/components/Buttons/VisitOrgSite.svelte'; import VisitOrgSiteButton from '$lib/components/Buttons/VisitOrgSite.svelte';
@@ -201,13 +202,13 @@
<div class="flex items-center justify-between mb-10"> <div class="flex items-center justify-between mb-10">
<h1 class="dark:text-white text-2xl md:text-3xl font-bold">Dashboard</h1> <h1 class="dark:text-white text-2xl md:text-3xl font-bold">Dashboard</h1>
<div class="flex items-center"> <div class="flex items-center">
{#if $isMobile} <PrimaryButton variant={VARIANTS.OUTLINED} onClick={createCourse} isDisabled={!$isOrgAdmin}>
<PrimaryButton onClick={createCourse}> {#if $isMobile}
<Add size={24} /> <Add size={24} />
</PrimaryButton> {:else}
{:else} Create Course
<PrimaryButton variant={VARIANTS.OUTLINED} label="Create Course" onClick={createCourse} /> {/if}
{/if} </PrimaryButton>
<VisitOrgSiteButton /> <VisitOrgSiteButton />
</div> </div>
+7 -2
View File
@@ -10,6 +10,7 @@
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } 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';
export let data; export let data;
let { allCourses, cantFetch } = data; let { allCourses, cantFetch } = data;
@@ -51,11 +52,15 @@
<div class="flex items-center justify-between mb-10"> <div class="flex items-center justify-between mb-10">
<h1 class="dark:text-white text-2xl md:text-3xl font-bold">Courses</h1> <h1 class="dark:text-white text-2xl md:text-3xl font-bold">Courses</h1>
{#if $isMobile} {#if $isMobile}
<PrimaryButton onClick={() => ($createCourseModal.open = true)}> <PrimaryButton isDisabled={!$isOrgAdmin} onClick={() => ($createCourseModal.open = true)}>
<Add size={24} /> <Add size={24} />
</PrimaryButton> </PrimaryButton>
{:else} {:else}
<PrimaryButton label="Create Course" onClick={() => ($createCourseModal.open = true)} /> <PrimaryButton
label="Create Course"
isDisabled={!$isOrgAdmin}
onClick={() => ($createCourseModal.open = true)}
/>
{/if} {/if}
</div> </div>
+55 -9
View File
@@ -1,5 +1,6 @@
<script> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores';
import TextField from '$lib/components/Form/TextField.svelte'; import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { getSupabase } from '$lib/utils/functions/supabase'; import { getSupabase } from '$lib/utils/functions/supabase';
@@ -10,15 +11,20 @@
} from '$lib/utils/functions/validator'; } from '$lib/utils/functions/validator';
import { SIGNUP_FIELDS } from '$lib/utils/constants/authentication'; import { SIGNUP_FIELDS } from '$lib/utils/constants/authentication';
import AuthUI from '$lib/components/AuthUI/index.svelte'; import AuthUI from '$lib/components/AuthUI/index.svelte';
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
let supabase = getSupabase(); let supabase = getSupabase();
let fields = Object.assign({}, SIGNUP_FIELDS); let fields = Object.assign({}, SIGNUP_FIELDS);
let loading = false; let loading = false;
let success = false; let success = false;
let errors = {}; let errors = {};
let submitError; let submitError: string;
let disableSubmit = false; let disableSubmit = false;
let formRef; let formRef: HTMLFormElement;
let query = new URLSearchParams($page.url.search);
let redirect = query.get('redirect');
async function handleSubmit(e) { async function handleSubmit(e) {
const validationRes = authValidation(fields); const validationRes = authValidation(fields);
@@ -31,18 +37,58 @@
try { try {
loading = true; loading = true;
const { data, error } = await supabase.auth.signUp({
const {
data: { session },
error
} = await supabase.auth.signUp({
email: fields.email, email: fields.email,
password: fields.password password: fields.password
}); });
console.log('data', data); console.log('session', session);
if (error) throw error; if (error) throw error;
formRef?.reset(); const { user: authUser } = session || {};
if (!authUser) {
throw 'Error creating user';
}
return goto('/login'); if (!$currentOrg.id) return;
// success = true;
// fields = Object.assign({}, SIGNUP_FIELDS); const [regexUsernameMatch] = [...(authUser.email?.matchAll(/(.*)@/g) || [])];
const response = await fetch('https://api.ipregistry.co/?key=tryout');
const metadata = await response.json();
const profileRes = await supabase
.from('profile')
.insert({
id: authUser.id,
username: regexUsernameMatch[1] + `${new Date().getTime()}`,
fullname: regexUsernameMatch[1],
email: authUser.email,
metadata
})
.select();
console.log('profileRes', profileRes);
if (profileRes.error) {
throw profileRes.error;
}
// Setting profile
console.log('setting profile', profileRes.data[0]);
profile.set(profileRes.data[0]);
if (redirect) {
goto(redirect);
} else {
goto('/login');
}
formRef?.reset();
success = true;
fields = Object.assign({}, SIGNUP_FIELDS);
} catch (error) { } catch (error) {
submitError = error.error_description || error.message; submitError = error.error_description || error.message;
} finally { } finally {