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 {
--sc-theme-calendar-colors-background-highlight: var(--main-primary-color);
}
html .dark {
--scrollbarBG: #1f2937;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--thumbBG) var(--scrollbarBG);
+40 -3
View File
@@ -6,12 +6,15 @@
import SendAlt from 'carbon-icons-svelte/lib/SendAlt.svelte';
import ChartPie from 'carbon-icons-svelte/lib/ChartPie.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 LiveChat from './components/LiveChat/index.svelte';
import Notes from './components/Notes/index.svelte';
import Poll from './components/Poll/index.svelte';
import APPS_CONSTANTS from './constants';
import { OverflowMenu, OverflowMenuItem } from 'carbon-components-svelte';
let selectedApp;
@@ -48,8 +51,21 @@
});
</script>
<div class="root">
<div class="apps">
<div class={`root ${$apps.open ? 'open dark:bg-slate-800' : 'close dark:bg-slate-800'}`}>
<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
toolTipProps={{ title: 'Live Chat', hotkeys: ['A', '1'] }}
value={APPS_CONSTANTS.APPS.LIVE_CHAT}
@@ -104,7 +120,7 @@
<style lang="scss">
.root {
height: 93vh;
height: calc(100vh - 48px);
position: sticky;
top: 0;
border-left: 1px solid var(--border-color);
@@ -129,4 +145,25 @@
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>
+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 { VARIANTS } from '$lib/components/PrimaryButton/constants';
import GoogleIconColored from '../Icons/GoogleIconColored.svelte';
import Avatar from '$lib/components/Avatar/index.svelte';
import { currentOrg } from '$lib/utils/store/org';
import type { SupabaseClient } from '@supabase/supabase-js';
export let supabase;
export let handleSubmit;
export let supabase: SupabaseClient;
export let handleSubmit = () => {};
export let isLogin = true;
export let showOnlyContent = false;
export let isLoading = false;
export let showLogo = false;
export let formRef;
export let hideGoogleAuth = false;
async function signInWithGoogle() {
if (isLoading) {
@@ -19,12 +22,11 @@
}
try {
console.log('signInWithGoogle');
const { user, session, error } = await supabase.auth.signInWithOAuth({
const { error, data } = await supabase.auth.signInWithOAuth({
provider: 'google'
});
console.log('user', user);
console.log('session', session);
console.log('data', data);
console.log('error', error);
} catch (error) {
console.log('catch error', error);
@@ -57,7 +59,7 @@
>
<slot />
</form>
{#if !showOnlyContent}
{#if !showOnlyContent && !hideGoogleAuth}
<div class="w-10/12 mb-3">
<p class="dark:text-white text-sm mb-5">or sign up with:</p>
<PrimaryButton
@@ -75,10 +77,14 @@
{#if !showOnlyContent}
<div class="w-full p-6 border-t border-grey text-center">
{#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}
Already have an account? <a class="hover:underline text-primary-700" href="/login"
>Log In</a
Already have an account? <a
class="hover:underline text-primary-700"
href="/login{$page.url.search}">Log In</a
>
{/if}
</div>
@@ -2,6 +2,7 @@
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import ArrowUpRightIcon from '$lib/components/Icons/ArrowTopRight.svelte';
import { currentOrg } from '$lib/utils/store/org';
import { isMobile } from '$lib/utils/store/useMobile';
export let className = '';
</script>
@@ -12,7 +13,9 @@
class="{className} ml-2 hover:no-underline"
>
<PrimaryButton>
View site
{#if !$isMobile}
View site
{/if}
<ArrowUpRightIcon />
</PrimaryButton>
</a>
@@ -45,8 +45,8 @@
</svelte:head>
<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">
<section class="w-full md:w-2/5 h-full">
<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 lg:w-2/5 h-full">
<strong class="my-2 text-base font-semibold text-black dark:text-gray-100"
>Certificate settings</strong
>
@@ -99,7 +99,7 @@
</div>
</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">
{#if $course.certificate_theme === 'professional'}
@@ -55,7 +55,7 @@
<Modal
onClose={() => (openModal = false)}
bind:open={openModal}
width="w-[60%]"
width="w-[80%] md:w-[60%]"
containerClass="overflow-hidden"
modalHeading="Add New Lesson"
>
@@ -165,8 +165,8 @@
<p class="dark:text-white mt-4 text-sm">Created Jul 3, 2021</p>
</a>
{:else}
<Box className="mt-3">
<div class="flex justify-between flex-col items-center w-96">
<Box className="mt-3 text-center">
<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" />
<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">
@@ -11,13 +11,15 @@
const onChange = (tab) => () => (currentTab = tab);
</script>
<section class="w-full h-full flex flex-row items-start gap-3">
<div class="flex-1 flex-col">
<p class="text-sm font-normal text-start text-[#4F4B4B] dark:text-[#b0a9a9] mb-3">Add By</p>
<section class="w-full h-full flex flex-col md:flex-row items-start gap-3">
<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 w-full">
Add By
</p>
{#each tabs as item (item.value)}
<button
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
? 'border border-[#0233BD] bg-[#F5F8FE] dark:text-black'
: 'border border-gray-200'
@@ -197,13 +197,13 @@
<Modal
{onClose}
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"
>
<VideoUploader {lessonId} />
</Modal>
<h1 class="text-4xl mt-0">
<h1 class="text-4xl mt-0 capitalize">
{lessonTitle}
</h1>
@@ -386,7 +386,7 @@
</article>
{/if}
{:else}
<Box>
<Box className="text-center">
<img src="/no-video.svg" alt="Video not found" />
<h3 class="text-xl font-normal dark:text-white py-2">
No note, video or slide added for this lesson yet
@@ -24,6 +24,7 @@
import { updateCourse } from '$lib/utils/services/courses';
import { NavClasses } from '$lib/utils/constants/reusableClass';
import { isMobile } from '$lib/utils/store/useMobile';
import { menu } from '$lib/components/Org/store';
// export let lessonId;
export let path;
@@ -32,12 +33,14 @@
let show = null;
let isLessonActive = false;
const toggleSidebar = () => {
$menu.hidden = !$menu.hidden;
};
function handleMainGroupClick(href) {
return () => {
if ($isMobile) {
setTimeout(() => (show = false), 500);
}
goto(href);
toggleSidebar();
};
}
@@ -146,145 +149,66 @@
<Settings />
<div class="root z-10 {!show && 'hide'} {$isMobile ? 'fixed shadow-xl' : 'sticky'}">
{#if show}
<div class="relative h-full dark:bg-gray-800">
<!-- <PageNav title="Back to courses" paddingClass="pl-2" titleSize="text-md">
<slot:fragment slot="image">
<Avatar src={$course.logo} />
<IconButton
value="toggle"
onClick={() => goto(`${$currentOrgPath}/courses`)}
size="small"
contained={true}
<aside
class={`${
$menu.hidden
? '-translate-x-[100%] absolute md:translate-x-0 md:relative z-[40]'
: 'translate-x-0 absolute md:relative z-[40]'
} 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`}
>
<div class="h-full flex flex-col">
<ul class="my-5">
{#each navItems as navItem}
{#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" />
</IconButton>
</slot:fragment>
<slot:fragment slot="widget">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<IconButton onClick={() => ($settingsDialog.open = !$settingsDialog.open)}>
<SettingsIcon size={20} class="carbon-icon dark:text-white" />
</IconButton>
</RoleBasedSecurity>
</slot:fragment>
</PageNav> -->
<div class="h-full py-3 overflow-y-auto">
{#each navItems as navItem}
{#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}
>
{#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}
{#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={toggleSidebar}
aria-disabled={!item.is_unlocked}
>
<div
class="flex items-center py-3 px-4 {NavClasses.item} {(
path || $page.url.pathname
).includes(item.id) && NavClasses.active}"
>
<div
class="flex items-center py-3 px-4 {NavClasses.item} {(
path || $page.url.pathname
).includes(item.id) && NavClasses.active}"
>
<TextChip
value={getLectureNo(index + 1)}
className="bg-primary-200 text-primary-600 text-xs mr-2"
size="sm"
shape="rounded-full"
/>
<span>{item.title}</span>
<span class="grow" />
{#if !item.is_unlocked}
<span class="text-md ml-2" title="This lesson is locked.">
<LockedIcon class="carbon-icon dark:text-white" />
</span>
{:else if item.is_complete}
<span class="ml-2" title="You have completed this lesson">
<CheckmarkFilled class="carbon-icon dark:text-white" />
</span>
{/if}
</div>
</a>
{/each}
{/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>
<TextChip
value={getLectureNo(index + 1)}
className="bg-primary-200 text-primary-600 text-xs mr-2"
size="sm"
shape="rounded-full"
/>
<span>{item.title}</span>
<span class="grow" />
{#if !item.is_unlocked}
<span class="text-md ml-2" title="This lesson is locked.">
<LockedIcon class="carbon-icon dark:text-white" />
</span>
{:else if item.is_complete}
<span class="ml-2" title="You have completed this lesson">
<CheckmarkFilled class="carbon-icon dark:text-white" />
</span>
{/if}
</div>
</a>
{/each}
{/if}
</NavExpandable>
{/if}
{/each}
</ul>
</div>
</div>
<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>
</aside>
@@ -1,27 +1,21 @@
<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 copy from 'copy-to-clipboard';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import { ROLE } from '$lib/utils/constants/roles';
import { addGroupMember, fetchGroup } from '$lib/utils/services/courses';
import { invitationModal, resetForm } from './store';
import { validateForm } from '$lib/components/Courses/functions';
import { course, setCourse } from '$lib/components/Course/store';
import { MultiSelect, Loading } from 'carbon-components-svelte';
import { currentOrg } from '$lib/utils/store/org';
import { getOrgTeam } from '$lib/utils/services/org';
import type { OrgTeamMember } from '$lib/utils/types/org';
import { sendTeacherCourseWelcome } from './utils';
let errors: {
students: string;
tutors: string;
title?: string;
description?: string;
} = {
students: '',
tutors: ''
};
import type { Course } from '$lib/utils/types';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { getStudentInviteLink } from '$lib/utils/functions/course';
interface Tutor {
id: number;
@@ -33,6 +27,8 @@
let selectedIds: Array<number> = [];
let selectedTutors: Tutor[] = [];
let isLoadingTutors = false;
let link = '';
let copied = false;
function notEmpty<TValue>(value: TValue | null | undefined): value is TValue {
return value !== null && value !== undefined;
@@ -41,28 +37,14 @@
i.length === 0 ? [] : i.map((id) => tutors.find((tutor) => tutor.id === id)).filter(notEmpty);
function onSubmit() {
const { hasError, fieldErrors, students } = validateForm({
...$invitationModal,
title: 'sfdsd',
description: 'dsfdsf',
tutors: 'sgsg'
});
errors = fieldErrors;
if (hasError) return;
if (!selectedTutors.length) {
resetForm();
return;
}
let membersStack = [];
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) {
membersStack.push({
profile_id: tutor.profileId,
@@ -128,24 +110,34 @@
isLoadingTutors = false;
}
function copyLink() {
copy(link);
copied = true;
setTimeout(() => {
copied = false;
}, 1000);
}
$: {
selectedTutors = formatSelected(selectedIds);
console.log('selectedTutors', selectedTutors);
}
$: setTutors($currentOrg.id, $invitationModal.open);
$: link = getStudentInviteLink($course, $currentOrg.siteName);
</script>
<Modal
onClose={resetForm}
bind:open={$invitationModal.open}
width="w-2/5"
width="w-4/5 md:w-2/5"
maxWidth="max-w-lg"
modalHeading="Invite people"
>
<form on:submit|preventDefault={onSubmit}>
<div class="mb-4">
<p class="text-base mb-1">Invite Tutors</p>
<div class="mb-8">
<p class="text-base mb-1 font-semibold">Invite Tutors</p>
<MultiSelect
disabled={isLoadingTutors}
label="Select tutors in organization..."
@@ -169,19 +161,29 @@
{/if}
</div>
<TextArea
label="Invite Students"
labelClassName="text-base"
bind:value={$invitationModal.students}
rows="2"
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="mb-8 w-[90%] flex justify-between items-center">
<div class="w-3/5">
<p class="text-base mb-1 font-semibold">Invite Students</p>
<p class=" text-sm">You can invite students via an invite link</p>
</div>
<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" />
</div>
</form>
@@ -5,12 +5,10 @@ export const deleteMemberModal = writable({
});
export const invitationModal = writable({
open: false,
students: ''
open: false
});
export const resetForm = () =>
invitationModal.update(() => ({
open: false,
students: ''
open: false
}));
@@ -11,7 +11,7 @@ export async function sendTeacherCourseWelcome(params: {
const accessToken = data.session?.access_token || '';
try {
const response = await fetch('/api/email/course/welcome', {
const response = await fetch('/api/email/course/teacher_welcome', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
+2
View File
@@ -11,6 +11,7 @@ export const course: Writable<Course> = writable({
description: '',
cost: 0,
currency: '',
status: 'ACTIVE',
is_certificate_downloadable: false,
certificate_theme: 'professional',
metadata: {
@@ -20,6 +21,7 @@ export const course: Writable<Course> = writable({
videoUrl: '',
showDiscount: false,
discount: 0,
paymentLink: '',
reward: {
show: false,
description: ''
@@ -16,9 +16,7 @@
export let isFetching = false;
$: {
const user = $group.people.find(
(person) => person.profile_id === $profile.id
);
const user = $group.people.find((person) => person.profile_id === $profile.id);
if (user) {
isStudent = user.role_id === 3;
@@ -58,6 +56,6 @@
}
.rightBar.isMobile {
margin-left: 10px;
/* margin-left: 10px; */
}
</style>
@@ -7,36 +7,12 @@
export let show = true;
</script>
<section
class="root z-10 {!show && 'hide'} {$isMobile ? 'fixed shadow-xl' : 'sticky'}"
>
<section class="root z-10">
{#if show}
<div class="relative h-full bg-white dark:bg-gray-800">
<slot />
</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 editor',
hotkeys: ['B'],
direction: 'right',
}}
>
{#if show}
<ChevronLeftIcon size={16} />
{:else}
<ChevronRightIcon size={16} />
{/if}
</IconButton>
</div>
</section>
<style lang="scss">
@@ -50,11 +26,6 @@
left: 0;
border-right: 1px solid var(--border-color);
background-color: rgb(250, 251, 252);
&.hide {
min-width: 15px;
max-width: 15px;
}
}
.toggler {
@@ -67,10 +38,5 @@
}
@media (max-width: 760px) {
.root {
width: 98%;
min-width: unset;
max-width: unset;
}
}
</style>
@@ -1,32 +1,36 @@
<script>
<script lang="ts">
import cloneDeep from 'lodash/cloneDeep';
import set from 'lodash/set';
import get from 'lodash/get';
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 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 showDiscount;
let giftToggled;
let paymentLink = '';
let showDiscount = false;
let giftToggled = false;
let hasBeenSet = false;
function setDefaults(course) {
function setDefaults(course: Course) {
console.log('\n\nsetDefaults course', course);
if (isEmpty(course) || hasBeenSet) return;
hasBeenSet = true;
paymentLink = get(course, 'metadata.paymentLink', '');
discount = get(course, 'metadata.discount', 0);
showDiscount = get(course, 'metadata.showDiscount', false);
giftToggled = get(course, 'metadata.reward.show', false);
}
function setter(value, setterKey) {
console.log('\n\nsetter course', value);
function setter(value: string | number | boolean, setterKey: string) {
if (typeof value !== 'boolean' && !value) return;
const _course = cloneDeep(course);
@@ -34,66 +38,76 @@
course = _course;
}
function handleChange(html) {
function handleChange(html: string) {
const _course = cloneDeep(course);
set(_course, 'metadata.reward.description', html);
course = _course;
}
$: setter(showDiscount, 'metadata.showDiscount');
$: setter(paymentLink, 'metadata.paymentLink');
$: setter(discount, 'metadata.discount');
$: setter(giftToggled, 'metadata.reward.show');
$: setDefaults(course);
</script>
<TextField
className="mt-5"
labelClassName="font-bold"
label="Currency"
value={course.currency}
isDisabled={true}
/>
{#if typeof course !== 'undefined'}
<Select labelText="Currency" bind:selected={course.currency}>
<SelectItem value="NGN" />
<SelectItem value="USD" />
</Select>
<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
className="mt-5"
labelClassName="font-bold"
label="Discout Percent"
label="Cost"
type="number"
bind:value={discount}
helperMessage="In Percentage %"
bind:value={course.cost}
/>
{/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 !isCourseFree(course.cost || 0)}
<TextField
className="mt-5"
labelClassName="font-bold"
label="Payment Link"
helperMessage="Stripe, Lemon Squeezy or any payment link"
bind:value={paymentLink}
/>
{/if}
{#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 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
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}
@@ -18,13 +18,16 @@
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { updateCourse } from '$lib/utils/services/courses';
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 courseId;
let borderBottomGrey = 'border-r-0 border-b border-l-0 border-gray-300';
let loading = false;
let show = false;
const sections = [
{
key: 1,
@@ -115,69 +118,112 @@
}
</script>
<Container show={true}>
<!-- Publish Header Section -->
{#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 />
<aside
class={`${
show ? '-translate-x-[100%] fixed z-[9999]' : 'translate-x-0 fixed z-[9999]'
} 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`}
>
<div class="toggler rounded-full shadow-lg absolute">
<IconButton
value="toggle"
onClick={() => (show = !show)}
size={$isMobile ? 'large' : 'small'}
color="text-black"
toolTipProps={$isMobile
? {}
: {
title: 'Toggle editor',
direction: 'right'
}}
>
{#if show}
<ChevronRightIcon size={16} />
{:else}
<ChevronLeftIcon size={16} />
{/if}
</div>
{/if}
</Container>
</IconButton>
</div>
<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>
.title-content {
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>
@@ -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">
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 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 courseData: Course = {
id: '',
title: '',
description: ''
};
export let editMode = false;
export let courseData: Course;
export let startCoursePayment = false;
let calculatedCost = 0;
let discount = 0;
let formatter: Intl.NumberFormat | undefined;
let isFree = false;
function calcDisc(percent: number, cost: number) {
if (!percent) return cost;
@@ -23,27 +27,51 @@
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) {
if (!currency) return;
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);
$: discount = get(courseData, 'metadata.discount', 0);
$: calculatedCost = calcDisc(discount, courseData.cost || 0);
$: isFree = isCourseFree(calculatedCost);
</script>
<PaymentModal
bind:open={startCoursePayment}
paymentLink={get(courseData, 'metadata.paymentLink', '')}
courseName={courseData.title}
teacherEmail={getTeacherEmail(courseData.group)}
/>
<!-- 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">
<!-- Pricing -->
<div class="mb-6">
<p class="dark:text-white font-medium text-lg">
{formatter?.format(calculatedCost) || calculatedCost}
{#if isFree}
<span class="text-sm">(Free)</span>
{/if}
</p>
{#if courseData?.metadata?.showDiscount}
<p class="dark:text-white font-light text-sm text-gray-500">
@@ -58,15 +86,9 @@
<!-- Call To Action Buttons -->
<div class="flex flex-col w-full items-center">
<PrimaryButton
label="Add to Cart"
label={isFree ? 'Join Course' : 'Buy Now'}
className="w-full sm:w-full py-3 mb-3"
onClick={addToCart}
/>
<PrimaryButton
label="Buy Now"
className="w-full sm:w-full py-3 mb-3"
variant={VARIANTS.OUTLINED}
onClick={addToCart}
onClick={handleJoinCourse}
/>
<p class="dark:text-white font-light text-sm text-gray-500">Early bird offer. Buy ASAP</p>
</div>
@@ -120,9 +142,9 @@
height: fit-content;
}
@media (max-width: 768px) {
@media screen and (min-width: 768px) and (max-width: 1024px) {
.price-container {
width: 100%;
width: 405px;
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);
export let courseData: Course = {
id: '',
title: '',
description: ''
};
export let editMode = false;
export let courseData: Course;
const ratingsImg = [
'/images/rating-1.svg',
'/images/rating-2.svg',
@@ -43,6 +40,7 @@
let player: any;
let averageRating = 0;
let totalRatings = 0;
let startCoursePayment = false;
// initialize the expandDescription array with 'false' values for each review.
let expandDescription = Array(reviews.length).fill(false);
@@ -61,16 +59,6 @@
activeNav = window.location.hash;
}
function addToCart() {}
function buyNow() {
// if (courseData.cost) {
// paystackApi.tra;
// } else {
// // Course is free
// }
}
function initPlyr(_player: any, _video: string | undefined) {
if (!player) return;
@@ -119,7 +107,13 @@
<p class="dark:text-white author my-3 text-sm">
{get(courseData, 'metadata.instructor.name', '')}
</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>
<!-- Banner Image getEmbedId(videoUrl) -->
@@ -164,14 +158,16 @@
{/if}
</div>
</header>
<PricingSection className="md:hidden" {courseData} />
<PricingSection className="md:hidden" {courseData} {editMode} bind:startCoursePayment />
<!-- Body -->
<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 -->
<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 -->
<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}
<a
href="{$page.url.pathname}{navItem.key}"
@@ -416,7 +412,7 @@
</div>
<!-- Pricing Details -->
<PricingSection className="hidden md:block" {courseData} />
<PricingSection {courseData} />
</div>
</div>
</div>
@@ -464,7 +460,7 @@
}
.course-content {
max-width: 608px;
max-width: 800px;
}
nav {
+2 -2
View File
@@ -1,11 +1,11 @@
<script>
<script lang="ts">
import { onMount } from 'svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import PasswordEye from '../Icons/PasswordEye.svelte';
export let label = '';
export let placeholder = '';
export let value = '';
export let value: string | number;
export let name = '';
export let onKeyDown = () => {};
export let className = '';
+1 -1
View File
@@ -10,7 +10,7 @@
export let logo;
export let orgName;
export let isOrgSite = false;
export let backgroundColor = 'bg-blue';
export let backgroundColor = 'bg-white';
let navClass = '';
+8 -4
View File
@@ -8,6 +8,7 @@
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
import { isOrgAdmin } from '$lib/utils/store/org';
let selected = 0;
let query = new URLSearchParams($page.url.search);
@@ -18,19 +19,22 @@
key: 0,
label: 'Profile',
tabKey: '',
href: $page.url.pathname
href: $page.url.pathname,
disabled: false
},
{
key: 1,
label: 'Organization',
tabKey: 'org',
href: `${$page.url.pathname}?tab=org`
href: `${$page.url.pathname}?tab=org`,
disabled: !$isOrgAdmin
},
{
key: 2,
label: '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>
{#each tabs as tab}
<Tab label={tab.label} href={tab.href} />
<Tab label={tab.label} href={tab.href} disabled={tab.disabled} />
{/each}
<!-- <Tab label="Account" /> -->
<svelte:fragment slot="content">
+2 -1
View File
@@ -82,7 +82,8 @@ export let landingPageSettings: Writable<OrgLandingPageJson> = writable({
title: 'Our Story',
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.',
imageUrl: '',
imageUrl:
'https://koxqonvbkeakwvmdegcf.supabase.co/storage/v1/object/public/avatars/landingpage/riverside.jpeg',
show: true
},
courses: {
+5 -5
View File
@@ -19,12 +19,12 @@
const menuItems = [
{
path: '',
label: 'Home'
},
{
path: '/quiz',
label: 'Quizzes'
label: 'Dashboard'
},
// {
// path: '/quiz',
// label: 'Quizzes'
// },
{
path: '/courses',
label: 'Courses'
@@ -7,6 +7,7 @@ export const VARIANTS = {
CONTAINED_SUCCESS: 'CONTAINED_SUCCESS',
OUTLINED: 'OUTLINED',
NONE: 'NONE',
LINK: 'LINK',
TEXT: 'TEXT',
TEXT_DANGER: 'TEXT_DANGER'
};
@@ -25,6 +26,7 @@ export const VARIANTS_CLASS = {
[VARIANTS.NONE]:
'border-none hover:border-black-300 hover:bg-gray-200 dark:hover:bg-gray-500 text-black',
[VARIANTS.TEXT]: 'text-black hover:underline',
[VARIANTS.LINK]: 'text-primary-500 dark:text-primary-500 hover:underline',
[VARIANTS.TEXT_DANGER]:
'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 isLoading = false;
$: isDisabled = isLoading ? true : false;
$: isDisabled = isLoading ? true : isDisabled;
</script>
<button
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[
isLoading ? VARIANTS.OUTLINED : variant
]} {!disablePadding &&
+2 -2
View File
@@ -6,7 +6,7 @@
</script>
<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}
{#if !tab.icon && !tab.badgeValue}
<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"
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}
<svelte:component this={tab.icon} />
{/if}
+2 -2
View File
@@ -4,10 +4,10 @@
export let id = '';
export let value: string;
export let onChange = (html = '') => {};
export let onChange = (html: string) => {};
export let height = 300;
export let placeholder = '';
export let editorWindowRef;
export let editorWindowRef = null;
const apiKey = PUBLIC_TINYMCE_API_KEY;
+1 -1
View File
@@ -53,7 +53,7 @@
<input
style="display:none"
type="file"
accept=".jpg, .jpeg, .png"
accept=".jpg, .jpeg, .png, .webp"
on:change={(e) => onFileSelected(e)}
bind:this={fileinput}
/>
+5 -3
View File
@@ -7,7 +7,8 @@ export const ROUTE = {
COURSE: '/course',
LOGIN: '/login',
SIGN_UP: '/signup',
INVITE_SIGNUP: '/invite',
INVITE_TEACHER: '/invite/t',
INVITE_STUDENT: '/invite/s',
PROFILE: '/profile',
PEOPLE: '/people',
DISCUSSIONS: '/discussions',
@@ -20,7 +21,7 @@ export const PUBLIC_ROUTES = [
`^${ROUTE.HOME}$`,
ROUTE.LOGIN,
ROUTE.SIGN_UP,
`^${ROUTE.INVITE_SIGNUP}/.*`,
`^${ROUTE.INVITE_TEACHER}/.*`,
ROUTE.FORGOT,
ROUTE.RESET,
`^${ROUTE.PAGES}/.*`,
@@ -31,7 +32,8 @@ export const PUBLIC_ROUTES = [
export const ROUTES_TO_HIDE_NAV = [
ROUTE.LOGIN,
ROUTE.SIGN_UP,
`^${ROUTE.INVITE_SIGNUP}/.*`,
`^${ROUTE.INVITE_TEACHER}/.*`,
`^${ROUTE.INVITE_STUDENT}/.*`,
ROUTE.FORGOT,
ROUTE.RESET,
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) => {
return new Intl.NumberFormat('en-US', {
const locale = currency == 'NGN' ? 'en-NG' : 'en-US';
return new Intl.NumberFormat(locale, {
style: '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);
};
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 = {}) => {
const { error } = resetValidationSchema.safeParse(fields);
+6 -4
View File
@@ -125,14 +125,16 @@ export async function getOrgAudience(orgId) {
email,
avatar_url,
created_at,
organizationmember!inner(
groupmember!inner(
role_id,
organization_id
group_id:group!inner(
organization_id
)
)
`
)
.eq('organizationmember.organization_id', orgId)
.eq('organizationmember.role_id', 3); // is a student, tutor is 2 and admin is 1
.eq('groupmember.group.organization_id', orgId)
.eq('groupmember.role_id', ROLE.STUDENT);
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';
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;
+5
View File
@@ -2,6 +2,7 @@ import { writable, derived } from 'svelte/store';
import { STEPS } from '../constants/quiz';
import type { Writable } from 'svelte/store';
import type { CurrentOrg, OrgTeamMember } from '../types/org';
import { ROLE } from '$lib/utils/constants/roles';
export const orgs = writable<CurrentOrg[]>([]);
export const currentOrg: Writable<CurrentOrg> = writable({
@@ -17,6 +18,10 @@ export const currentOrg: Writable<CurrentOrg> = writable({
});
export const orgAudience = writable([]);
export const orgTeam = writable<OrgTeamMember[]>([]);
export const isOrgAdmin = derived(
currentOrg,
($currentOrg) => parseInt($currentOrg.role_id) === ROLE.ADMIN
);
export const currentOrgPath = derived(currentOrg, ($currentOrg) =>
$currentOrg.siteName ? `/org/${$currentOrg.siteName}` : ''
);
+4 -1
View File
@@ -8,6 +8,7 @@ interface CourseMetadata {
videoUrl?: '';
showDiscount: false;
discount: 0;
paymentLink?: string;
reward?: {
show: boolean;
description: string;
@@ -64,6 +65,8 @@ export interface Group {
updated_at?: string;
organization_id?: string /* foreign key to organization.id */;
organization?: Organization;
tutors: Profile[];
members: Groupmember[];
}
export interface Role {
@@ -93,7 +96,7 @@ export interface Course {
logo?: string;
slug?: any; // type unknown;
metadata?: CourseMetadata;
cost?: number;
cost: number;
currency?: string;
group?: Group;
organization?: Organization;
+3 -3
View File
@@ -81,7 +81,7 @@
console.log('Get user profile', authUser);
if (!authUser) {
goto('/login');
goto('/login?redirect=/' + path);
}
// Check if user has profile
@@ -315,14 +315,14 @@
{#if !isQuizPage($page.url.pathname)}
<OrgSideBar />
{/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 />
</div>
</div>
{:else if isLMSPage($page.url.pathname)}
<div class="org-root w-full flex items-center justify-between">
<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 />
</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 { json } from '@sveltejs/kit';
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';
const sendgrid = getSendgrid();
@@ -10,7 +10,7 @@ const supabase = getSupabase();
export async function POST({ request }) {
const { to, name, orgName, courseName, orgSiteName } = await request.json();
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) {
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 options = {
from: SENDGRID_FROM_NOTIFY,
from: SENDGRID_FROM_NOREPLY,
to,
subject: `You have been invited to a course in ${orgName}!`,
html: render({
+4 -4
View File
@@ -1,7 +1,7 @@
import { render } from 'svelte-email';
import { json } from '@sveltejs/kit';
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';
const sendgrid = getSendgrid();
@@ -15,7 +15,7 @@ export async function POST({ request }) {
const accessToken = request.headers.get('Authorization');
console.log('/POST api/email/invite', body);
if (!org || !Object.keys(org) || !email) {
if (!org || !Object.keys(org) || !email || !accessToken) {
return json(
{ success: false, message: 'Org data and Teacher name are required' },
{ status: 400 }
@@ -43,11 +43,11 @@ export async function POST({ request }) {
orgId: id,
orgSiteName: siteName
});
const inviteLink = `${origin}/invite/${encodeURIComponent(btoa(inviteData))}`;
const inviteLink = `${origin}/invite/t/${encodeURIComponent(btoa(inviteData))}`;
console.log('inviteData', inviteData);
const options = {
from: SENDGRID_FROM_NOTIFY,
from: SENDGRID_FROM_NOREPLY,
to: email,
subject: `Join ${name} on ClassroomIO`,
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 {
slug: params.slug,
course: data
};
};
+13 -31
View File
@@ -1,39 +1,21 @@
<script>
import Backdrop from '$lib/components/Backdrop/index.svelte';
import { Moon } from 'svelte-loading-spinners';
<script lang="ts">
import CourseLandingPage from '$lib/components/CourseLandingPage/index.svelte';
import { profile } from '$lib/utils/store/user';
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';
import type { Course } from '$lib/utils/types';
export let data;
interface Data {
course: Course;
}
export let data: Data;
let courseData = {};
let loading = false;
let courseData: Course;
async function triggerSetCourse(_profile) {
if (!browser) return;
loading = true;
const { data: _data } = await fetchCourse(undefined, data.slug);
setCourse(_data);
loading = false;
function setCourseData(course: Course | null) {
if (!course) return;
courseData = { ...course };
}
function setCourseData(course, lessons) {
courseData = { ...course, lessons };
console.log('courseData', courseData);
}
$: triggerSetCourse($profile);
$: setCourseData($course, $lessons);
$: setCourseData(data.course);
</script>
{#if loading}
<Backdrop>
<Moon size="60" color="#1d4ed8" unit="px" duration="1s" />
</Backdrop>
{:else}
<CourseLandingPage {courseData} />
{/if}
<CourseLandingPage {courseData} />
+70 -67
View File
@@ -138,77 +138,80 @@
<CourseContainer bind:isStudent>
<PageNav title="Attendance" />
<section class="mx-9 my-5">
<div class="flex flex-row items-center justify-between">
<div class="flex">
<p class="flex items-center mr-5">
<Checkbox checked disabled /> Present
</p>
<p class="flex items-center">
<Checkbox disabled /> Absent
</p>
<PageBody width="w-full max-w-6xl md:w-11/12">
<section class="flex items-center mx-2 lg:mx-9 my-5">
<div
class="flex flex-col lg:flex-row items-start gap-2 lg:items-center justify-between w-full"
>
<div class="flex">
<p class="flex items-center mr-5">
<Checkbox checked disabled /> Present
</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>
<Search
class="dark:text-slate-950"
placeholder="Search students"
bind:value={searchValue}
/>
</div>
</div>
</section>
</section>
<section class="my-5 mx-9">
<StructuredList class="m-0">
<!-- Moved the lesson headers outside the students loop -->
<StructuredListHead class="bg-primary-100">
<StructuredListRow head class="mx-7">
<StructuredListCell head class="text-primary-600 py-3">Student</StructuredListCell>
{#each $lessons as lesson, index}
<StructuredListCell head class="text-primary-600 py-3"
>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>
<section class="my-5 mx-2 lg:mx-9">
<StructuredList class="m-0 relative">
<!-- Moved the lesson headers outside the students loop -->
<StructuredListHead class="bg-primary-100">
<StructuredListRow head class="mx-7">
<StructuredListCell head class="text-primary-600 py-3">Student</StructuredListCell>
{#each $lessons as lesson, index}
<StructuredListCell head class="text-primary-600 py-3"
>Lesson 0{getLectureNo(index + 1)}</StructuredListCell
>
{/each}
</StructuredListRow>
</StructuredListBody>
{/each}
</StructuredList>
{#if students.length === 0}
<div>
<Box>
</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}
</StructuredListRow>
</StructuredListBody>
{/each}
</StructuredList>
{#if students.length === 0}
<Box className="h-[300px] w-full">
<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>
</div>
{/if}
<Pagination totalItems={10} pageSizes={[10, 15, 20]} />
</section>
{/if}
{#if students.length !== 0}
<Pagination totalItems={10} pageSizes={[10, 15, 20]} />
{/if}
</section>
</PageBody>
</CourseContainer>
@@ -30,26 +30,21 @@
</script>
<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 }}
out:fly={{ y: 500, duration: 500 }}
>
<Editor {courseId} bind:course={courseData} />
<div class="rightBar" class:isMobile={$isMobile}>
<CourseLandingPage bind:courseData />
<div class="rightBar">
<CourseLandingPage bind:courseData editMode={true} />
</div>
</div>
<style>
.rightBar {
flex-grow: 1;
width: calc(100% - 360px);
overflow: auto;
width: 100%;
height: 100%;
overflow: auto;
}
.rightBar.isMobile {
margin-left: 10px;
overflow-y: auto;
}
</style>
+2 -2
View File
@@ -321,12 +321,12 @@
</div>
{:else}
<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" />
<h2 class="text-xl my-1.5 font-normal">No lessons yet</h2>
<p class="text-sm text-center text-slate-500">
Share your knowledge with the world by creating engaging lessons. Start by clicking on
the New Lesson button.
the Add button.
</p>
</div>
</Box>
@@ -4,17 +4,19 @@
import { fetchCourse, fetchLesson } from '$lib/utils/services/courses';
import CheckmarkOutlineIcon from 'carbon-icons-svelte/lib/CheckmarkOutline.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 RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import PageNav from '$lib/components/PageNav/index.svelte';
import PageBody from '$lib/components/PageBody/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 MODES from '$lib/utils/constants/mode.js';
import MODES from '$lib/utils/constants/mode';
import { setCourse, course } from '$lib/components/Course/store';
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 {
lesson,
lessons,
@@ -144,6 +146,10 @@
}));
}
const toggleApps = () => {
$apps.open = !$apps.open;
};
function toggleMode() {
prevMode = mode;
mode = mode === MODES.edit ? MODES.view : MODES.edit;
@@ -176,9 +182,20 @@
<svelte:fragment slot="widget">
<RoleBasedSecurity allowedRoles={[1, 2]}>
{#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
className="mr-2"
className="mb-2 lg:mb-0 mr-2"
variant={VARIANTS.OUTLINED}
onClick={downloadLesson}
{isLoading}
@@ -207,7 +224,7 @@
<PageBody>
<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
onClick={markLessonComplete}
isLoading={isMarkingComplete}
+15 -33
View File
@@ -8,10 +8,7 @@
import { ROLE } from '$lib/utils/constants/roles';
import { setCourse, course, group } from '$lib/components/Course/store';
import { lessons } from '$lib/components/Course/components/Lesson/store/lessons';
import {
fetchCourse,
fetchExercisesByMarks,
} from '$lib/utils/services/courses';
import { fetchCourse, fetchExercisesByMarks } from '$lib/utils/services/courses';
import { getLectureNo } from '$lib/components/Course/function';
import { fetchMarks } from '$lib/utils/services/marks';
import { profile } from '$lib/utils/store/user';
@@ -19,10 +16,8 @@
export let data;
const { courseId } = data;
let borderBottomGrey =
'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 borderBottomGrey = '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 students = [];
let isStudent = false;
@@ -52,11 +47,11 @@
if (studentMarksByExerciseId[groupmember_id]) {
studentMarksByExerciseId[groupmember_id] = {
...studentMarksByExerciseId[groupmember_id],
[exercise_id]: total_points_gotten,
[exercise_id]: total_points_gotten
};
} else {
studentMarksByExerciseId[groupmember_id] = {
[exercise_id]: total_points_gotten,
[exercise_id]: total_points_gotten
};
}
});
@@ -70,11 +65,11 @@
if (lessonMapping[lesson_id]) {
lessonMapping[lesson_id] = {
...lessonMapping[lesson_id],
[exercise_id]: { title: exercise_title, points },
[exercise_id]: { title: exercise_title, points }
};
} else {
lessonMapping[lesson_id] = {
[exercise_id]: { title: exercise_title, points },
[exercise_id]: { title: exercise_title, points }
};
}
});
@@ -82,20 +77,16 @@
$: {
students = isStudent
? $group.people.filter(
(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.profile.id === $profile.id)
: $group.people.filter((person) => !!person.profile && person.role_id === ROLE.STUDENT);
}
</script>
<CourseContainer bind:isStudent>
<PageNav title="Marks" />
<PageBody width="w-11/12">
<div class="table rounded-md border border-gray-300 w-auto">
<PageBody width="w-full max-w-6xl md:w-11/12">
<div class="table rounded-md border border-gray-300 w-full">
<div class="flex items-center {borderBottomGrey}">
<div class="box flex items-center p-3">
<p class="dark:text-white w-40">Student</p>
@@ -111,8 +102,7 @@
>
{#each Object.keys(lessonMapping[lesson.id]) as exerciseId, index}
<p
class="col text-sm dark:text-white {index &&
borderleftGrey}"
class="col text-sm dark:text-white {index && borderleftGrey}"
title={lessonMapping[lesson.id][exerciseId].title}
>
{lessonMapping[lesson.id][exerciseId].title}
@@ -129,9 +119,7 @@
</div>
{#each students as student}
<div
class="flex relative items-center p-3 cursor-pointer {borderBottomGrey}"
>
<div class="flex relative items-center p-3 cursor-pointer {borderBottomGrey}">
<div class="w-40 flex items-center">
<img
alt="Student avatar"
@@ -143,11 +131,7 @@
{student.profile.fullname}
</p>
<p class="dark:text-white">
{`${
student.assigned_student_id
? '#' + student.assigned_student_id
: '-'
}`}
{`${student.assigned_student_id ? '#' + student.assigned_student_id : '-'}`}
</p>
</div>
</div>
@@ -176,9 +160,7 @@
{:else}
<Box>
<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">No Student Added</h3>
</Box>
{/each}
</div>
+141 -129
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import TextChip from '$lib/components/Chip/Text.svelte';
import ComingSoon from '$lib/components/ComingSoon/index.svelte';
import Filter from 'carbon-icons-svelte/lib/Filter.svelte';
import { onMount } from 'svelte';
import copy from 'copy-to-clipboard';
import {
@@ -17,6 +18,7 @@
import { fetchCourse, deleteGroupMember, updatedGroupMember } from '$lib/utils/services/courses';
import TrashCanIcon from 'carbon-icons-svelte/lib/TrashCan.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 Select from '$lib/components/Form/Select.svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
@@ -135,138 +137,148 @@
</RoleBasedSecurity>
</slot:fragment>
</PageNav>
<section class="my-5 mx-9">
<div class="flex flex-row flex-end justify-end items-center mb-7">
<div class="mr-5 w-80">
<Search
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"
<PageBody width="w-full max-w-6xl md:w-11/12">
<section class="my-5 mx-2 md:mx-9">
<div
class="flex flex-col md:flex-row flex-end gap-2 justify-end items-start md:items-center mb-7"
>
<StructuredListRow head class="mx-7">
<StructuredListCell head class="text-primary-700 py-3 dark:text-white"
>Name</StructuredListCell
>
<StructuredListCell head class="text-primary-700 py-3 dark:text-white"
>Role</StructuredListCell
>
<StructuredListCell head class="text-primary-700 py-3 dark:text-white"
>Action</StructuredListCell
>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<p class="dark:text-white hidden lg:block text-lg w-20" />
</RoleBasedSecurity>
</StructuredListRow>
</StructuredListHead>
<div class="max-w-[320px]">
<Search
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 max-w-[80px]"
/>
<!-- <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>
{#each filterPeople(searchValue, people) as person}
<StructuredListBody>
<StructuredListRow class="relative">
<!-- first column -->
<StructuredListCell class="w-3/6">
{#if person.profile}
<div class="flex items-center absolute bottom-3">
<Avatar
src={person.profile.avatar_url}
name={person.profile.fullname}
width="w-8"
height="h-8"
className="mr-3"
/>
<div class="flex 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">
{getEmail(person)}
</p>
</div>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<div class="hidden lg:flex lg:justify-between lg:items-center mr-2">
<CopyButton text={getEmail(person)} feedback="Copied Email to clipboard" />
<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">
<StructuredListCell head class="text-primary-700 py-3 dark:text-white"
>Name</StructuredListCell
>
<StructuredListCell head class="text-primary-700 py-3 dark:text-white"
>Role</StructuredListCell
>
<StructuredListCell head class="text-primary-700 py-3 dark:text-white"
>Action</StructuredListCell
>
<RoleBasedSecurity allowedRoles={[1, 2]}>
<p class="dark:text-white hidden lg:block text-lg w-20" />
</RoleBasedSecurity>
</StructuredListRow>
</StructuredListHead>
{#each filterPeople(searchValue, people) as person}
<StructuredListBody>
<StructuredListRow class="relative">
<!-- first column -->
<StructuredListCell class="w-4/6 md:w-3/6">
{#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>
</RoleBasedSecurity>
{#if person.profile_id == $profile.id}
<ComingSoon label="You" />
<div class="flex items-center">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<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}
</div>
</div>
{:else}
<div class="flex items-center w-2/4 absolute bottom-3">
<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>
<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>
</RoleBasedSecurity>
</StructuredListCell>
</StructuredListRow>
</StructuredListBody>
{/each}
</StructuredList>
<!-- <Pagination totalItems={10} pageSizes={[10, 15, 20]} /> -->
</section>
</PageBody>
</CourseContainer>
@@ -244,8 +244,8 @@
<CourseContainer>
<PageNav title="Submitted Exercises" />
<PageBody width="w-11/12 overflow-x-auto overflow-y-hidden">
<div class="flex items-center w-full">
<PageBody width="w-full max-w-6xl md:w-11/12 overflow-x-auto">
<div class="flex items-center w-full overflow-x-scroll">
{#each sections as { id, title, items }, idx (id)}
<div
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 : ''}`}
</p>
</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}
</p>
<!-- <div class="badge rounded-md px-2 bg-green-500 text-white">
@@ -324,10 +324,15 @@
.section {
max-width: 355px;
min-width: 355px;
height: 80vh;
height: 75vh;
}
.content {
height: 95%;
}
@media screen and (max-width: 768px) {
.section {
min-width: 250px;
}
}
</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 { 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: '' } }) => {
try {
const hashData = atob(decodeURIComponent(params.hash));
@@ -9,13 +16,16 @@ export const load = async ({ params = { hash: '' } }) => {
const { orgId, email, orgSiteName } = JSON.parse(hashData);
getSupabase();
const _currentOrg = await getCurrentOrg(orgSiteName, true);
const profile = await getProfile(email);
console.log('profile data', profile);
return {
orgId,
email,
currentOrg: _currentOrg
currentOrg: _currentOrg,
profile
};
} catch (error) {
console.error('Error decoding invite params.hash', error);
@@ -10,11 +10,11 @@
} from '$lib/utils/functions/validator';
import { SIGNUP_FIELDS } from '$lib/utils/constants/authentication';
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 type { CurrentOrg } from '$lib/utils/types/org.js';
import { onMount } from 'svelte';
import Error from '../../+error.svelte';
import type { Profile } from '$lib/components/Course/components/People/types';
export let data;
@@ -34,7 +34,10 @@
let formRef: HTMLFormElement;
async function handleSubmit() {
const validationRes = authValidation(fields);
const validationRes = authValidation({
...fields,
email: '[email protected]' // validation for this ema
});
console.log('validationRes', validationRes);
if (Object.keys(validationRes).length) {
@@ -44,40 +47,44 @@
try {
loading = true;
// Signup
const { data: signupData, error } = await supabase.auth.signUp({
email: data.email,
password: fields.password
});
let profile: Profile | null = data.profile;
console.log('Signup', signupData);
if (error) throw error;
if (!data.profile) {
// Signup
const { data: signupData, error } = await supabase.auth.signUp({
email: data.email,
password: fields.password
});
if (!signupData.user) {
throw 'User cannot be created ';
console.log('Signup', signupData);
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
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) {
if (!profile?.id) {
throw 'Unable to create profile';
}
@@ -91,12 +98,23 @@
.match({ email: data.email, organization_id: data.currentOrg?.id });
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) {
if (error instanceof Error) {
submitError = error.message;
} else {
submitError = error?.toString() || '';
}
} finally {
loading = false;
@@ -116,6 +134,7 @@
$: disableSubmit = getDisableSubmit(fields);
$: setCurOrg(data.currentOrg as CurrentOrg);
$: console.log('data.profile', data.profile);
</script>
<svelte:head>
@@ -132,7 +151,13 @@
bind:formRef
>
<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
label="Your Email"
value={data.email}
@@ -142,18 +167,20 @@
inputClassName="w-full"
isDisabled={true}
/>
<TextField
label="Full Name"
bind:value={fields.name}
type="text"
autoFocus={true}
placeholder="e.g Joke Silva"
className="mb-6"
inputClassName="w-full"
isDisabled={loading}
errorMessage={errors.name}
isRequired
/>
{#if !data.profile}
<TextField
label="Full Name"
bind:value={fields.name}
type="text"
autoFocus={true}
placeholder="e.g Joke Silva"
className="mb-6"
inputClassName="w-full"
isDisabled={loading}
errorMessage={errors.name}
isRequired
/>
{/if}
<TextField
label="Your Password"
bind:value={fields.password}
@@ -166,17 +193,19 @@
helperMessage="Password must be more than 6 characters"
isRequired
/>
<TextField
label="Confirm Password"
bind:value={fields.confirmPassword}
type="password"
placeholder="************"
className="mb-6"
inputClassName="w-full"
isDisabled={loading}
errorMessage={errors.confirmPassword}
isRequired
/>
{#if !data.profile}
<TextField
label="Confirm Password"
bind:value={fields.confirmPassword}
type="password"
placeholder="************"
className="mb-6"
inputClassName="w-full"
isDisabled={loading}
errorMessage={errors.confirmPassword}
isRequired
/>
{/if}
{#if submitError}
<p class="text-sm text-red-500">{submitError}</p>
{/if}
@@ -184,7 +213,7 @@
<div class="my-4 w-full flex justify-end items-center">
<PrimaryButton
label="Create and Join"
label="Accept Invite"
type="submit"
className="sm:w-full w-full"
isDisabled={disableSubmit || loading}
+11
View File
@@ -1,4 +1,5 @@
<script>
import { page } from '$app/stores';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { getSupabase } from '$lib/utils/functions/supabase';
@@ -14,6 +15,9 @@
let loading = false;
let errors = {};
let query = new URLSearchParams($page.url.search);
let redirect = query.get('redirect');
async function handleSubmit() {
const validationRes = authValidation(fields);
console.log('validationRes', validationRes);
@@ -31,6 +35,13 @@
});
console.log('data', data);
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) {
submitError = error.error_description || error.message;
} finally {
-4
View File
@@ -199,10 +199,6 @@
$: progress = Math.round((step / maxSteps) * 100);
$: fields.siteName = generateSitename(fields.siteName || '');
$: setOrgSiteName(fields.orgName, isSiteNameTouched);
$: {
console.log('currentOrg', $currentOrg);
}
</script>
{#if $profile.id}
+7 -6
View File
@@ -15,6 +15,7 @@
import WelcomeModal from '$lib/components/WelcomeModal/WelcomeModal.svelte';
import { onMount } from 'svelte';
import { Add } from 'carbon-icons-svelte';
import { isOrgAdmin } from '$lib/utils/store/org';
import { VARIANTS } from '$lib/components/PrimaryButton/constants.js';
import VisitOrgSiteButton from '$lib/components/Buttons/VisitOrgSite.svelte';
@@ -201,13 +202,13 @@
<div class="flex items-center justify-between mb-10">
<h1 class="dark:text-white text-2xl md:text-3xl font-bold">Dashboard</h1>
<div class="flex items-center">
{#if $isMobile}
<PrimaryButton onClick={createCourse}>
<PrimaryButton variant={VARIANTS.OUTLINED} onClick={createCourse} isDisabled={!$isOrgAdmin}>
{#if $isMobile}
<Add size={24} />
</PrimaryButton>
{:else}
<PrimaryButton variant={VARIANTS.OUTLINED} label="Create Course" onClick={createCourse} />
{/if}
{:else}
Create Course
{/if}
</PrimaryButton>
<VisitOrgSiteButton />
</div>
+7 -2
View File
@@ -10,6 +10,7 @@
import { currentOrg } from '$lib/utils/store/org';
import { Add } from 'carbon-icons-svelte';
import { isMobile } from '$lib/utils/store/useMobile';
import { isOrgAdmin } from '$lib/utils/store/org';
export let data;
let { allCourses, cantFetch } = data;
@@ -51,11 +52,15 @@
<div class="flex items-center justify-between mb-10">
<h1 class="dark:text-white text-2xl md:text-3xl font-bold">Courses</h1>
{#if $isMobile}
<PrimaryButton onClick={() => ($createCourseModal.open = true)}>
<PrimaryButton isDisabled={!$isOrgAdmin} onClick={() => ($createCourseModal.open = true)}>
<Add size={24} />
</PrimaryButton>
{:else}
<PrimaryButton label="Create Course" onClick={() => ($createCourseModal.open = true)} />
<PrimaryButton
label="Create Course"
isDisabled={!$isOrgAdmin}
onClick={() => ($createCourseModal.open = true)}
/>
{/if}
</div>
+55 -9
View File
@@ -1,5 +1,6 @@
<script>
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { getSupabase } from '$lib/utils/functions/supabase';
@@ -10,15 +11,20 @@
} from '$lib/utils/functions/validator';
import { SIGNUP_FIELDS } from '$lib/utils/constants/authentication';
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 fields = Object.assign({}, SIGNUP_FIELDS);
let loading = false;
let success = false;
let errors = {};
let submitError;
let submitError: string;
let disableSubmit = false;
let formRef;
let formRef: HTMLFormElement;
let query = new URLSearchParams($page.url.search);
let redirect = query.get('redirect');
async function handleSubmit(e) {
const validationRes = authValidation(fields);
@@ -31,18 +37,58 @@
try {
loading = true;
const { data, error } = await supabase.auth.signUp({
const {
data: { session },
error
} = await supabase.auth.signUp({
email: fields.email,
password: fields.password
});
console.log('data', data);
console.log('session', session);
if (error) throw error;
formRef?.reset();
const { user: authUser } = session || {};
if (!authUser) {
throw 'Error creating user';
}
return goto('/login');
// success = true;
// fields = Object.assign({}, SIGNUP_FIELDS);
if (!$currentOrg.id) return;
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) {
submitError = error.error_description || error.message;
} finally {