feat: dashboard billing (#193)

* feat: move plans into sharable json

* remove unused code

* update pnpm lock

* update plans.json

* implement early adopter pricing

* docs: update meta title and description

* Finished the UI for upgrading plans

* Updated the pricing plans

* Updated the cards ui

* use relative import

* improve styling of upgrade plan card

* styling improvements, lemonsqueezy setup

* trigger lemonsqueezy payment and add webhook

* update pnpm lock

* add logout route

* feat: complete billing functionality

* generate lock file

* make crypto false on browser

* feat: adding upgrade prompts to org

* chore: add upgrade icon to upload video

* feat: add upgrade prompt to certificate and settings

* feat: implement onboarding setup page (#229)

* working on the onboarding setup

* working on the onboarding page

* Almost done with the onboarding UI

* finished the UI of the onBoarding setup

* finished the UI of the onBoarding setup

* Implementing the functions to carryout the logic of updating the UI onmount if a user has carried out a certain step or not

* Implementing the functions to carryout the logic of updating the UI onmount if a user has carried out a certain step or not

* making some chnages

* finished the onBoarding task

* updated some logic

* Removed the uneeded svg from the static folder

* fixed isCoursePublished not returning a boolean

* improved the setup link protection

* chore: make menuItems reactive

* improve formatting

* format supabase queries

---------

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

* revamp setup

* improve the upgrade experience

* migration

* feat: add lemonsqueezy customer portal

---------

Co-authored-by: emmanuel <[email protected]>
This commit is contained in:
Rotimi Best
2024-04-07 06:08:34 +01:00
committed by GitHub
co-authored by emmanuel
parent 136e057954
commit fcd0bcc656
105 changed files with 1743 additions and 463 deletions
+5 -6
View File
@@ -13,13 +13,12 @@
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.3/plyr.css" />
<title>ClassroomIO –&nbsp;Launch Your Online Bootcamp In Minutes</title>
<meta name="description"
content="ClassroomIO is a no-code tool that allows you build and scale your own teaching platform with ease." />
<meta name="description" content="ClassroomIO is the easiest place to launch and scale your online bootcamp." />
<meta name="theme-color" content="#ffffff" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta property="og:title" content="ClassroomIO –&nbsp;Launch Your Online Bootcamp In Minutes" />
<meta property="og:description"
content="ClassroomIO is a no-code tool that allows you build and scale your own teaching platform with ease." />
content="ClassroomIO is the easiest place to launch and scale your online bootcamp." />
<meta property="og:type" content="website" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1920" />
@@ -29,9 +28,7 @@
<meta name="twitter:creator" content="@rotimi_best" />
<meta name="twitter:title" content="ClassroomIO –&nbsp;Launch Your Online Bootcamp In Minutes" />
<meta name="twitter:description"
content="ClassroomIO is a no-code tool that allows you build and scale your own teaching platform with ease." />
<script async type="text/javascript" src="https://static.senja.io/dist/platform.js"></script>
content="ClassroomIO is the easiest place to launch and scale your online bootcamp." />
<!-- <link rel="stylesheet" href="global.css"> -->
<link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
@@ -45,6 +42,8 @@
-->
<script src="https://cdn.plyr.io/3.7.3/plyr.js"></script>
<script async type="text/javascript" src="https://static.senja.io/dist/platform.js"></script>
%sveltekit.head%
</head>
@@ -149,7 +149,7 @@
{:else}
{#each poll.options as option, index}
<button
class="bg-white dark:bg-black rounded-md border-2 border-gray-100 h-[50px] {didUserSelect(
class="bg-white dark:bg-black rounded-md border-2 border-gray-100 dark:border-neutral-600 h-[50px] {didUserSelect(
option,
currentUserId
) &&
@@ -16,7 +16,7 @@
{#each tabs as tab}
<button
class="mr-3 pb-2 border-t-0 border-l-0 border-r-0 {selectedTab === tab.value
? 'border-b-4 border-indigo-600 focus:border-indigo-600'
? 'border-b-4 border-primary-600 focus:border-primary-600'
: 'border-b-0'}"
on:click={() => (selectedTab = tab.value)}
>
@@ -50,7 +50,7 @@
function getAppClass(appName: string, selected: string | undefined) {
return `transition duration-500 delay-150 ease-in-out border-t-0 border-b-0 border-r-0 ${
appName === selected ? 'border-l-4 border-indigo-600 focus:border-indigo-600' : 'border-l-0'
appName === selected ? 'border-l-4 border-primary-600 focus:border-primary-600' : 'border-l-0'
}`;
}
@@ -3,7 +3,7 @@
</script>
<div
class="{className} w-full h-[517px] flex items-center justify-center flex-col border rounded border-gray-200"
class="{className} w-full h-[517px] flex items-center justify-center flex-col border rounded border-gray-200 dark:border-neutral-600"
>
<slot />
</div>
@@ -1,29 +1,7 @@
<script lang="ts">
import posthog from 'posthog-js';
import { goto } from '$app/navigation';
import { logout } from '$lib/utils/functions/logout';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { supabase } from '$lib/utils/functions/supabase';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { currentOrg, orgs, defaultCurrentOrgState } from '$lib/utils/store/org';
import { user, profile, defaultProfileState, defaultUserState } from '$lib/utils/store/user';
async function logout() {
const { error } = await supabase.auth.signOut();
if (error) {
console.error('Error logging out: ', error);
}
currentOrg.set(defaultCurrentOrgState);
orgs.set([]);
user.set(defaultUserState);
profile.set(defaultProfileState);
capturePosthogEvent('user_logged_out');
posthog.reset();
goto('/login');
}
</script>
<PrimaryButton variant={VARIANTS.OUTLINED} label="Log out" onClick={logout} />
@@ -1,5 +1,6 @@
<script lang="ts">
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import ArrowUpRightIcon from '$lib/components/Icons/ArrowTopRight.svelte';
import { currentOrgDomain } from '$lib/utils/store/org';
import { isMobile } from '$lib/utils/store/useMobile';
@@ -8,7 +9,7 @@
</script>
<a href={$currentOrgDomain} target="_blank" class="{className} ml-2 hover:no-underline">
<PrimaryButton>
<PrimaryButton variant={VARIANTS.CONTAINED_DARK}>
{#if !$isMobile}
View site
{/if}
@@ -0,0 +1,9 @@
<script>
export let name = '';
</script>
<p
class="w-fit text-xs border font-semibold px-1.5 rounded-md text-center text-blue-700 border-blue-700"
>
{name}
</p>
@@ -4,12 +4,14 @@
import { updateCourse } from '$lib/utils/services/courses';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import Professional from './certificates/Professional.svelte';
import Plain from './certificates/Plain.svelte';
import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import { globalStore } from '$lib/utils/store/app';
import FlashFilled from 'carbon-icons-svelte/lib/FlashFilled.svelte';
const studentNamePlaceholder = 'Name of student';
const themes = ['professional', 'plain'];
@@ -44,14 +46,20 @@
/>
</svelte:head>
<main class="md:-ml-3 md:-mr-3 px-2">
<UpgradeBanner>Upgrade your plan to generate certificates</UpgradeBanner>
<main class="md:-ml-3 md:-mr-3 px-2 mt-2">
<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
>
<p class="text-xs font-normal my-4 dark:text-gray-100">Choose a theme</p>
<RadioButtonGroup bind:selected={$course.certificate_theme} class="mb-10">
<RadioButtonGroup
bind:selected={$course.certificate_theme}
class="mb-10"
disabled={$isFreePlan}
>
{#each themes as theme}
<div class="flex mr-3">
<RadioButton value={theme} />
@@ -71,11 +79,12 @@
>Settings &gt; Organisation settings</strong
> and upload your brand logo
</p>
<PrimaryButton
label="Go to Settings"
variant={VARIANTS.OUTLINED}
className="rounded-md mt-3"
onClick={() => goto(`org/${$currentOrg.siteName}/settings`)}
onClick={() => goto(`/org/${$currentOrg.siteName}/settings`)}
/>
</div>
<span class="my-4">
@@ -86,6 +95,7 @@
bgColor="bg-gray-100"
bind:value={$course.description}
errorMessage={errors.description}
disabled={$isFreePlan}
/>
</span>
<Toggle
@@ -93,6 +103,7 @@
bind:toggled={$course.is_certificate_downloadable}
class="my-4"
size="sm"
disabled={$isFreePlan}
>
<span slot="labelA" style={$globalStore.isDark ? 'color: white' : 'color: #161616'}
>Locked</span
@@ -115,11 +126,17 @@
</div>
<div class="h-1/5">
<PrimaryButton
label="Save Changes"
className="rounded-md"
className="rounded-md flex gap-2 items-center"
variant={VARIANTS.CONTAINED_DARK}
onClick={saveCertificate}
isLoading={isSaving}
/>
isDisabled={$isFreePlan}
>
{#if $isFreePlan}
<FlashFilled size={16} class="text-blue-700" />
{/if}
Save Changes
</PrimaryButton>
</div>
</main>
@@ -17,7 +17,9 @@
<section class="flex flex-wrap items-start gap-2 mb-10 -ml-4 -mr-4">
{#each CertificateReportData as data (data.id)}
<div class="box flex flex-col rounded border border-gray-200 justify-center px-2 mb-5">
<div
class="box flex flex-col rounded border border-gray-200 dark:border-neutral-600 justify-center px-2 mb-5"
>
<p class="text-xs font-normal">{data.title}</p>
<bold class="text-2xl font-medium">{data.value}</bold>
</div>
@@ -61,7 +61,7 @@
modalHeading="Add New Lesson"
>
<div
class="sm:min-h-[245px] md:min-h-[100px] lg:min-h-[190px] relative m-auto rounded-md border-2 border-gray-200 py-2 md:py-3 px-2 md:px-5 mb-2 md:mb-4 flex flex-wrap items-center dark:bg-neutral-800"
class="sm:min-h-[245px] md:min-h-[100px] lg:min-h-[190px] relative m-auto rounded-md border-2 border-gray-200 dark:border-neutral-600 py-2 md:py-3 px-2 md:px-5 mb-2 md:mb-4 flex flex-wrap items-center dark:bg-neutral-800"
>
<div class="mr-5">
<TextChip
@@ -122,7 +122,7 @@
class="w-[261px] h-[240px] p-5 rounded-md dark:bg-neutral-700 border-2 {option.type ===
type
? 'border-primary-400'
: `border-gray-200 dark:border-gray-400 ${
: `border-gray-200 dark:border-neutral-600 dark:border-gray-400 ${
!option.isDisabled && 'hover:scale-95'
}`} flex flex-col {option.isDisabled &&
'cursor-not-allowed opacity-60'} transition-all ease-in-out"
@@ -205,7 +205,7 @@
class="w-[161px] h-[140px] hover:scale-95 p-5 rounded-md dark:bg-neutral-700 border-2 {template.id ===
selectedTemplateId
? 'border-primary-400'
: `border-gray-200 dark:border-gray-400`} flex flex-col transition-all ease-in-out"
: `border-gray-200 dark:border-neutral-600 dark:border-gray-400`} flex flex-col transition-all ease-in-out"
type="button"
on:click={() => (selectedTemplateId = template.id)}
>
@@ -3,6 +3,8 @@
import LocalVideo from './LocalVideo.svelte';
import EmbedLink from './EmbedLink.svelte';
import * as CONSTANTS from './constants';
import { isFreePlan } from '$lib/utils/store/org';
import FlashFilled from 'carbon-icons-svelte/lib/FlashFilled.svelte';
export let lessonId = '';
@@ -23,14 +25,18 @@
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'
: 'border border-gray-200 dark:border-neutral-600'
} rounded-md cursor-pointer flex flex-row items-center justify-start gap-2 whitespace-nowrap`}
>
<svelte:component
this={item.icon}
size={20}
color={`${currentTab === item.value ? 'dark:invert-0' : 'dark:invert'}`}
/>
{#if $isFreePlan && item.value === 3}
<FlashFilled size={20} class="text-blue-700" />
{:else}
<svelte:component
this={item.icon}
size={20}
color={`${currentTab === item.value ? 'dark:invert-0' : 'dark:invert'}`}
/>
{/if}
<p>{item.title}</p>
</button>
{/each}
@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import axios from 'axios';
import { PUBLIC_SERVER_URL } from '$env/static/public';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
@@ -10,11 +10,14 @@
import { onMount, onDestroy } from 'svelte';
import { ProgressBar } from 'carbon-components-svelte';
import { supabase } from '$lib/utils/functions/supabase';
import { isFreePlan } from '$lib/utils/store/org';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
let value = 0;
let max = 100;
let status = 'active';
let fileSize;
let isDisabled = false;
export let lessonId = '';
@@ -61,7 +64,7 @@
console.log('Upload res', formRes);
isLoading = false;
isLoaded = false;
} catch (err) {
} catch (err: any) {
console.error('Error uploading video', err, '\n\n', err.response);
if (err.response) {
formRes = err.response.data;
@@ -115,14 +118,19 @@
}
$: isDoneUploading(formRes);
$: isDisabled = isLoading || !PUBLIC_SERVER_URL || $isFreePlan;
</script>
<UpgradeBanner className="mb-3" onClick={() => ($uploadCourseVideoStore.isModalOpen = false)}>
Upgrade your plan to upload vidoes
</UpgradeBanner>
{#if !isLoaded}
<button
type="button"
on:click={() => (fileInput && !isLoading ? fileInput.click() : null)}
class="h-full w-full"
disabled={isLoading || !PUBLIC_SERVER_URL}
class="h-full w-full {isDisabled && 'hover:cursor-not-allowed opacity-50'}"
disabled={isDisabled}
>
<form
class="border-primary-300 flex h-full w-full flex-col items-center justify-center rounded-xl border border-dashed"
@@ -6,8 +6,10 @@
import { SkeletonText } from 'carbon-components-svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
import { NavClasses } from '$lib/utils/constants/reusableClass';
import FlashFilled from 'carbon-icons-svelte/lib/FlashFilled.svelte';
import { handleAddLessonWidget } from './store';
import NavIcons from './NavIcons.svelte';
import { isFreePlan } from '$lib/utils/store/org';
export let handleClick = () => {};
export let label = '';
@@ -17,6 +19,7 @@
export let isLoading = true;
export let isLesson = false;
export let isStudent = true;
export let isPaidFeature = false;
// export let subMenuItems = [];
function addLesson() {
@@ -54,6 +57,9 @@
{/if}
<span class="grow" />
{#if isPaidFeature && $isFreePlan}
<FlashFilled size={20} class="text-blue-700" />
{/if}
{#if isLesson && !isLoading}
{#if !isStudent}
<IconButton onClick={() => addLesson()} size="small">
@@ -15,7 +15,7 @@
import { sideBar } from '$lib/components/Org/store';
import { profile } from '$lib/utils/store/user';
import { getIsLessonComplete } from '../Lesson/functions';
import { globalStore } from '$lib/utils/store/app';
import { isFreePlan } from '$lib/utils/store/org';
export let path: string;
export let isStudent: boolean = false;
@@ -25,6 +25,7 @@
to: string;
hideSortIcon: boolean;
isLesson?: boolean;
isPaidFeature: boolean;
show?: () => boolean;
}
@@ -148,23 +149,27 @@
{
label: 'News Feed',
to: getNavItemRoute($course.id),
hideSortIcon: true
hideSortIcon: true,
isPaidFeature: false
},
{
label: 'Lessons',
to: getLessonsRoute($course.id),
hideSortIcon: false,
isPaidFeature: false,
isLesson: true
},
{
label: 'Attendance',
to: getNavItemRoute($course.id, 'attendance'),
isPaidFeature: false,
hideSortIcon: true
},
{
label: 'Submissions',
to: getNavItemRoute($course.id, 'submissions'),
hideSortIcon: true,
isPaidFeature: false,
show() {
return !isStudent;
}
@@ -172,27 +177,33 @@
{
label: 'Marks',
to: getNavItemRoute($course.id, 'marks'),
isPaidFeature: false,
hideSortIcon: true
},
// {
// label: 'Scoreboard',
// to: getNavItemRoute($course.id, 'scoreboard'),
// hideSortIcon: true,
// },
{
label: 'People',
to: getNavItemRoute($course.id, 'people'),
isPaidFeature: false,
hideSortIcon: true
},
{
label: 'Certificates',
to: getNavItemRoute($course.id, 'certificates'),
hideSortIcon: true
hideSortIcon: true,
isPaidFeature: true,
show() {
// Dont show students if org on free plan
if (isStudent && $isFreePlan) {
return false;
}
return true;
}
},
{
label: 'Landing Page',
to: getNavItemRoute($course.id, 'landingpage'),
hideSortIcon: true,
isPaidFeature: false,
show() {
return !isStudent;
}
@@ -201,6 +212,7 @@
label: 'Settings',
to: getNavItemRoute($course.id, 'settings'),
hideSortIcon: true,
isPaidFeature: false,
show() {
return !isStudent;
}
@@ -219,12 +231,12 @@
transition w-[90vw] md:w-[300px] lg:w-[350px] bg-gray-100 dark:bg-black h-[calc(100vh-48px)]
${
resize && 'border-r-8 border-r-blue-500'
resize ? 'border-r-8 border-r-blue-500' : 'dark:border-r-neutral-600'
} overflow-y-auto border border-l-0 border-t-0 border-b-0 border-r-1`}
style={$sideBar.hidden === true ? 'width:0' : 'width:300px'}
bind:this={sidebarRef}
>
<div class="sidebar-contenth-full flex flex-col">
<div class="flex flex-col">
<ul
class="sidebar-content my-5"
bind:this={menuContentRef}
@@ -240,6 +252,7 @@
total={navItem.isLesson ? ($lessons || []).length : 0}
isLoading={!$course.id}
isLesson={navItem.isLesson}
isPaidFeature={navItem.isPaidFeature}
{isStudent}
>
{#if navItem.isLesson}
@@ -100,7 +100,7 @@
id={feed.id}
class="flex flex-col gap-5 {isActive
? 'border-2 border-primary-700'
: 'border border-gray-200'} rounded-md mb-7 max-w-3xl"
: 'border border-gray-200 dark:border-neutral-600'} rounded-md mb-7 max-w-3xl"
>
<section>
<div class="p-3 pb-0">
@@ -162,7 +162,7 @@
</div>
</section>
<section class="border-t border-gray-200 p-3">
<section class="border-t border-gray-200 dark:border-neutral-600 p-3">
{#if feed.comment.length > 0}
<button
on:click={expandComment}
@@ -222,7 +222,7 @@
bind:value={comment}
on:keydown={handleAddNewComment}
placeholder="Add class comment"
class="w-full bg-transparent border border-gray-200 rounded-3xl"
class="w-full bg-transparent border border-gray-200 dark:border-neutral-600 rounded-3xl"
required
/>
</div>
@@ -14,13 +14,14 @@
import { course } from '$lib/components/Course/store';
import type { Course } from '$lib/utils/types';
import { updateCourse, deleteCourse } from '$lib/utils/services/courses';
import { currentOrgPath } from '$lib/utils/store/org';
import { currentOrgPath, isFreePlan } from '$lib/utils/store/org';
import { isObject } from '$lib/utils/functions/isObject';
import { lessons } from '../Lesson/store/lessons';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { snackbar } from '$lib/components/Snackbar/store';
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
import { handleOpenWidget } from '$lib/components/CourseLandingPage/store';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import { Restart } from 'carbon-icons-svelte';
import IconButton from '$lib/components/IconButton/index.svelte';
@@ -186,7 +187,7 @@
$: setDefault($course);
</script>
<Grid class="border-c rounded border-gray-200">
<Grid class="border-c rounded border-gray-200 dark:border-neutral-600">
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
<Column sm={8} md={8} lg={8}>
<SectionTitle>Cover Image</SectionTitle>
@@ -287,10 +288,14 @@
<p>Make the Lesson available for Download in pdf for registered students</p>
</Column>
<Column sm={8} md={8} lg={8}>
<Toggle size="sm" bind:toggled={$settings.lesson_download}>
<span slot="labelA" style="color: gray">Disabled</span>
<span slot="labelB" style="color: gray">Enabled</span>
</Toggle>
{#if $isFreePlan}
<UpgradeBanner>Upgrade to download lessons</UpgradeBanner>
{:else}
<Toggle size="sm" bind:toggled={$settings.lesson_download}>
<span slot="labelA" style="color: gray">Disabled</span>
<span slot="labelB" style="color: gray">Enabled</span>
</Toggle>
{/if}
</Column>
</Row>
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
@@ -299,13 +304,17 @@
<p>Make the Course available for Download for registered students</p>
</Column>
<Column sm={8} md={8} lg={8}>
<PrimaryButton
variant={VARIANTS.OUTLINED}
label="Download"
onClick={downloadCourse}
isDisabled={isLoading || !PUBLIC_SERVER_URL}
{isLoading}
/>
{#if $isFreePlan}
<UpgradeBanner>Upgrade to download course</UpgradeBanner>
{:else}
<PrimaryButton
variant={VARIANTS.OUTLINED}
label="Download"
onClick={downloadCourse}
isDisabled={isLoading || !PUBLIC_SERVER_URL}
{isLoading}
/>
{/if}
</Column>
</Row>
@@ -1,11 +1,18 @@
<script lang="ts">
import { Tag, ImageLoader, SkeletonPlaceholder, ContextMenu, ContextMenuDivider, ContextMenuOption, } from 'carbon-components-svelte';
import {
Tag,
ImageLoader,
SkeletonPlaceholder,
ContextMenu,
ContextMenuDivider,
ContextMenuOption
} from 'carbon-components-svelte';
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import CopyFile from "carbon-icons-svelte/lib/CopyFile.svelte";
import Share from "carbon-icons-svelte/lib/Share.svelte";
import UserFollow from "carbon-icons-svelte/lib/UserFollow.svelte";
import CopyFile from 'carbon-icons-svelte/lib/CopyFile.svelte';
import Share from 'carbon-icons-svelte/lib/Share.svelte';
import UserFollow from 'carbon-icons-svelte/lib/UserFollow.svelte';
export let bannerImage: string | undefined;
export let id = '';
@@ -52,7 +59,7 @@
<ContextMenuOption indented labelText="Share" icon={Share} on:click={handleShareCourse} />
<ContextMenuOption indented labelText="Invite" icon={UserFollow} on:click={handleInvite} />
<ContextMenuDivider />
<ContextMenuOption kind="danger" labelText="Delete" on:click={handleDeleteCourse} />
<ContextMenuOption kind="danger" labelText="Delete" on:click={handleDeleteCourse} />
</ContextMenu>
{/if}
@@ -60,14 +67,14 @@
rel="prefetch"
bind:this={target}
href={isOnLandingPage ? `/course/${slug}` : `/courses/${id}`}
class="text-black border border-gray rounded w-full max-w-[320px] relative hover:scale-95 transition-all ease-in-out"
class="text-black border border-gray dark:border-neutral-600 rounded w-full max-w-[320px] relative hover:scale-95 transition-all ease-in-out"
>
<div class="p-4">
<div class=" mb-5">
<ImageLoader
src={bannerImage}
alt="Course Logo"
class="h-[200px] w-full rounded dark:border dark:border-white"
class="h-[200px] w-full rounded dark:border dark:border-neutral-600"
>
<svelte:fragment slot="loading">
<SkeletonPlaceholder style="width: 100%; height: 200px;" />
@@ -83,7 +90,7 @@
</div>
<div
class="px-4 border border-gray border-b-0 border-l-0 border-r-0 flex justify-between {isLMS &&
class="px-4 border border-gray dark:border-neutral-600 border-b-0 border-l-0 border-r-0 flex justify-between {isLMS &&
'items-center'}"
>
<div>
@@ -27,7 +27,7 @@
</p>
{/if}
<span
class="form-input custom-placeholder w-full block overflow-hidden resize min-h-[50px] leading-5 border mt-2 p-3 pt-3.5 outline-none dark:text-black dark:bg-white form-input border-l-0 border-r-0 border-t-0 border-b-2 border-gray-200 focus:border-l-0 focus:border-r-0 rounded-t-md focus:border-t-0 focus:border-b-2 {inputClassName} {focusClass} {isDisabled &&
class="form-input custom-placeholder w-full block overflow-hidden resize min-h-[50px] leading-5 border mt-2 p-3 pt-3.5 outline-none dark:text-black dark:bg-white form-input border-l-0 border-r-0 border-t-0 border-b-2 border-gray-200 dark:border-neutral-600 focus:border-l-0 focus:border-r-0 rounded-t-md focus:border-t-0 focus:border-b-2 {inputClassName} {focusClass} {isDisabled &&
'hover:cursor-not-allowed opacity-50'} dark:text-black p-3 mt-1 block w-full {bgColor} {errorMessage
? 'border-red-600'
: ''}"
@@ -59,7 +59,7 @@
bind:value
bind:this={ref}
required={isRequired}
class="form-textarea border-l-0 border-r-0 border-t-0 border-b-2 border-gray-200 focus:border-l-0 focus:border-r-0 rounded-t-md focus:border-t-0 focus:border-b-2 focus:border-primary-600 mt-1 block w-full
class="form-textarea border-l-0 border-r-0 border-t-0 border-b-2 border-gray-200 dark:border-neutral-600 focus:border-l-0 focus:border-r-0 rounded-t-md focus:border-t-0 focus:border-b-2 focus:border-primary-600 mt-1 block w-full
{bgColor} {!!errorMessage ? 'border-red-500' : 'border-gray-300'}"
on:change={onChange}
/>
@@ -69,7 +69,7 @@
{/if}
<input
use:typeAction
class="form-input border-l-0 border-r-0 border-t-0 border-b-2 border-gray-200 focus:border-l-0 focus:border-r-0 rounded-t-md focus:border-t-0 focus:border-b-2 {inputClassName} {focusClass} {isDisabled &&
class="form-input border-l-0 border-r-0 border-t-0 border-b-2 border-gray-200 dark:border-neutral-600 focus:border-l-0 focus:border-r-0 rounded-t-md focus:border-t-0 focus:border-b-2 {inputClassName} {focusClass} {isDisabled &&
'hover:cursor-not-allowed opacity-50'} dark:text-black p-3 mt-1 block w-full {bgColor} {errorMessage
? 'border-red-600'
: ''}"
@@ -1,6 +1,6 @@
<svg
width="24"
height="24"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -3,8 +3,8 @@
</script>
<svg
width="24"
height="24"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@@ -3,8 +3,8 @@
</script>
<svg
width="24"
height="24"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@@ -1,6 +1,6 @@
<svg
width="24"
height="24"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -1,6 +1,6 @@
<svg
width="24"
height="24"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,6 @@
<svg width="61" height="61" viewBox="0 0 61 61" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M30.5 0C13.6564 0 0 13.6564 0 30.5C0 47.3436 13.6564 61 30.5 61C47.3436 61 61 47.3436 61 30.5C61 13.6564 47.3436 0 30.5 0ZM44.6291 22.0629L30.0196 44.3927C30.0158 44.3966 30.0082 44.4004 30.0082 44.4042C29.9319 44.5186 29.89 44.6482 29.7947 44.7549C29.6612 44.8998 29.4897 44.9761 29.3372 45.0866C29.2991 45.1133 29.2609 45.1438 29.219 45.1705C28.9826 45.3192 28.7386 45.4183 28.4718 45.4831C28.3841 45.506 28.304 45.5289 28.2125 45.5441C28.0002 45.5733 27.785 45.5759 27.572 45.5518C27.4216 45.5422 27.2723 45.5193 27.1259 45.4831C26.9749 45.4376 26.8282 45.3789 26.6875 45.3078C26.5655 45.2506 26.4359 45.2353 26.3215 45.1629C26.2376 45.1095 26.1919 45.0256 26.1194 44.9608C26.0889 44.9341 26.0508 44.9265 26.0203 44.8998L17.5146 37.0346C17.0105 36.5505 16.7163 35.8879 16.6955 35.1893C16.6746 34.4906 16.9287 33.8117 17.403 33.2984C17.8774 32.785 18.5343 32.4783 19.2324 32.4441C19.9305 32.4099 20.6142 32.6509 21.1365 33.1154L27.2937 38.8113L40.1609 19.1426C40.5481 18.55 41.1549 18.1356 41.8477 17.9905C42.5406 17.8453 43.2627 17.9814 43.8552 18.3686C44.4477 18.7559 44.8621 19.3627 45.0073 20.0555C45.1524 20.7483 45.0164 21.4704 44.6291 22.0629Z"
fill="#0c891b"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -56,7 +56,7 @@
} overflow-y-auto transition w-[250px] min-w-[250px] bg-gray-100 dark:bg-neutral-900 h-[calc(100vh-48px)]`}
>
<div class="h-full flex flex-col">
<div class="border-b border-gray-200 pt-5 px-4">
<div class="border-b border-gray-200 dark:border-neutral-600 pt-5 px-4">
<div class="w-full flex flex-col items-center">
<Avatar
src={$profile.avatar_url}
@@ -2,6 +2,7 @@
import { Grid, Row, Column } from 'carbon-components-svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import { supabase } from '$lib/utils/functions/supabase';
import { profile } from '$lib/utils/store/user';
@@ -61,7 +62,7 @@
}
</script>
<Grid class="border-c rounded border-gray-200 w-full mt-5">
<Grid class="border-c rounded border-gray-200 dark:border-neutral-600 w-full mt-5">
<Row class="flex flex-col lg:flex-row items-center lg:items-start py-7 border-bottom-c ">
<Column sm={4} md={8} lg={4} class="mt-2 md:mt-0">
<p class="dark:text-white font-bold">Profile Picture</p>
@@ -83,6 +84,7 @@
<Row class="m-5 w-full flex items-center gap-2 lg:justify-center">
<PrimaryButton
label="Update Profile"
variant={VARIANTS.CONTAINED_DARK}
className="mr-5"
isLoading={loading}
isDisabled={loading}
@@ -24,7 +24,7 @@
{#if open}
<div
class="dialog fixed overflow-x-hidden overflow-y-auto inset-0 flex justify-center items-center z-50 bg-gray-100 bg-opacity-50"
class="dialog fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden bg-gray-100 bg-opacity-50"
on:click={onClose}
role="presentation"
>
@@ -32,12 +32,14 @@
<div
class="{maxWidth || 'container'} {size === 'sm'
? 'small'
: ''} bg-white dark:bg-neutral-800 mx-auto {width} shadow-lg p-5 pt-2 rounded-md"
: ''} mx-auto bg-white dark:bg-neutral-800 {width} rounded-md pt-2 shadow-lg"
on:click={(e) => e.stopPropagation()}
role="presentation"
>
<div class="flex items-center justify-between mb-4">
<p class="dark:text-white text-md font-medium m-0">
<div
class="mb-4 flex items-center justify-between border border-l-0 border-r-0 border-t-0 border-gray-100 dark:border-neutral-600 p-4 px-5"
>
<p class="text-md m-0 font-medium dark:text-white">
{modalHeading}
</p>
{#if isCloseable}
@@ -47,7 +49,7 @@
{/if}
</div>
<div class="body p-1 h-4/5 overflow-y-auto {containerClass}">
<div class="body h-4/5 overflow-y-auto p-6 {containerClass}">
<slot />
</div>
</div>
@@ -3,7 +3,7 @@
</script>
<div
class="h-[70px] w-full border border-gray-100 dark:bg-neutral-800 mx-4 my-2 rounded-md transition ease-in-out relative flex pt-3 px-3"
class="h-[70px] w-full border border-gray-100 dark:border-neutral-600 dark:bg-neutral-800 mx-4 my-2 rounded-md transition ease-in-out relative flex pt-3 px-3"
>
<div class="w-10 flex justify-evenly flex-col px-1 mr-2 rounded-md">
<SkeletonPlaceholder style="width: 100%; height: 40%; border-radius: 4px;" />
@@ -3,7 +3,7 @@
</script>
<div
class="root w-full border border-gray-100 dark:bg-neutral-800 mr-4 mb-4 rounded-md transition ease-in-out relative flex p-3"
class="root w-full border border-gray-100 dark:border-neutral-600 dark:bg-neutral-800 mr-4 mb-4 rounded-md transition ease-in-out relative flex p-3"
>
<SkeletonPlaceholder style="width: 20%; height: 100%;" />
<div class="w-full mx-5 flex justify-evenly flex-col">
@@ -0,0 +1,46 @@
<script>
import { Grid, Row, Column } from 'carbon-components-svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { currentOrgPlan, isFreePlan } from '$lib/utils/store/org';
import SectionTitle from '../SectionTitle.svelte';
let isRedirecting = false;
async function onOpenBilling() {
console.log({
plan: $currentOrgPlan
});
if ($currentOrgPlan?.subscriptionId) {
isRedirecting = true;
fetch('/api/lmz/subscriptions?subscriptionId=' + $currentOrgPlan?.subscriptionId)
.then((response) => response.json())
.then((res) => {
isRedirecting = false;
window.open(res.customerPortal, '_blank');
});
}
}
</script>
<Grid class="border-c rounded border-gray-200 dark:border-neutral-600 w-full mt-5">
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
<Column sm={4} md={4} lg={4}><SectionTitle>Billing details</SectionTitle></Column>
<Column sm={8} md={8} lg={8}>
<h4 class="dark:text-white lg:mt-0">Manage your billing</h4>
{#if $isFreePlan}
You don't have any active billing
{:else}
<p class="mb-3">We use Lemonsqueezy to help manage your billing</p>
<PrimaryButton
label="Open billing"
variant={VARIANTS.CONTAINED_DARK}
onClick={onOpenBilling}
isLoading={isRedirecting}
/>
{/if}
</Column>
</Row>
</Grid>
@@ -72,7 +72,7 @@
$: setSiteName($currentOrg.siteName);
</script>
<Grid class="border rounded border-gray-200 w-full mt-5">
<Grid class="border rounded border-gray-200 dark:border-neutral-600 w-full mt-5">
<Row class="py-7 border-bottom-c">
<Column sm={2} md={2} lg={4} class="text-lg"><SectionTitle>Add</SectionTitle></Column>
<Column sm={2} md={6} lg={8}>
@@ -48,7 +48,7 @@
}
</script>
<Grid class="border-c rounded border-gray-200 w-full mt-5">
<Grid class="border-c rounded border-gray-200 dark:border-neutral-600 w-full mt-5">
<Row class="flex lg:flex-row flex-col justify-center lg:justify-start py-7 border-bottom-c">
<Column sm={4} md={4} lg={4} class="flex items-center justify-center">
<img src="/telegram-svg.svg" alt="" class="w-10 mr-2" />
@@ -117,7 +117,7 @@
$: setDefault($currentOrg?.landingpage as OrgLandingPageJson);
</script>
<Grid class="border-c rounded border-gray-200 w-full mt-5 relative">
<Grid class="border-c rounded border-gray-200 dark:border-neutral-600 w-full mt-5 relative">
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
<Column sm={4} md={4} lg={4}>
<SectionTitle>Landing Page</SectionTitle>
@@ -1,6 +1,7 @@
<script>
import { goto } from '$app/navigation';
import { Grid, Row, Column } from 'carbon-components-svelte';
import FlashFilled from 'carbon-icons-svelte/lib/FlashFilled.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
@@ -9,6 +10,7 @@
import { snackbar } from '$lib/components/Snackbar/store';
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import SectionTitle from '../SectionTitle.svelte';
import { isFreePlan } from '$lib/utils/store/org';
let avatar;
let loading = false;
@@ -94,7 +96,7 @@
}
</script>
<Grid class="border-c rounded border-gray-200 w-full mt-5">
<Grid class="border-c rounded border-gray-200 dark:border-neutral-600 w-full mt-5">
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
<Column sm={4} md={4} lg={4}><SectionTitle>Organization Profile</SectionTitle></Column>
<Column sm={8} md={8} lg={8} class="mt-2 lg:mt-0 flex flex-col items-center lg:items-start">
@@ -175,7 +177,7 @@
</p>
<PrimaryButton
label="Edit domain"
className="my-7 py-5 px-10 text-primary-700"
className="my-7 py-5 px-10"
variant={VARIANTS.OUTLINED}
onClick={() => gotoSetting('/domains')}
/>
@@ -189,11 +191,15 @@
Add team mates to your organization so you can both collaborate on projects.
</p>
<PrimaryButton
label="Manage Team"
className="my-7 py-5 px-10 text-primary-700"
className="my-7 py-5 px-10 flex items-center gap-2 justify-center"
variant={VARIANTS.OUTLINED}
onClick={() => gotoSetting('/teams')}
/>
>
{#if $isFreePlan}
<FlashFilled size={16} class="text-blue-700" />
{/if}
Manage Team
</PrimaryButton>
</Column>
</Row>
</Grid>
@@ -3,6 +3,7 @@
import TextField from '$lib/components/Form/TextField.svelte';
import SectionTitle from '../SectionTitle.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import { supabase } from '$lib/utils/functions/supabase';
import { profile } from '$lib/utils/store/user';
@@ -61,7 +62,7 @@
}
</script>
<Grid class="border-c rounded border-gray-200 w-full mt-5">
<Grid class="border-c rounded border-gray-200 dark:border-neutral-600 w-full mt-5">
<Row class="flex flex-col lg:flex-row items-center lg:items-start py-7 border-bottom-c ">
<Column sm={4} md={8} lg={4} class="mt-2 md:mt-0">
<SectionTitle>Profile Picture</SectionTitle>
@@ -83,6 +84,7 @@
<Row class="m-5 w-full flex items-center gap-2 lg:justify-center">
<PrimaryButton
label="Update Profile"
variant={VARIANTS.CONTAINED_DARK}
className="mr-5"
isLoading={loading}
isDisabled={loading}
@@ -18,6 +18,7 @@
triggerSendEmail,
NOTIFICATION_NAME
} from '$lib/utils/services/notification/notification';
import { isFreePlan } from '$lib/utils/store/org';
import type { OrgTeamMember } from '$lib/utils/types/org';
let emailsStr = '';
@@ -132,7 +133,7 @@
$: fetchTeam($currentOrg.id);
</script>
<Grid class="border rounded border-gray-200 w-full mt-5">
<Grid class="border rounded border-gray-200 dark:border-neutral-600 w-full mt-5 relative">
<Row class="py-7 border-bottom-c">
<Column sm={2} md={2} lg={4} class="text-lg"><SectionTitle>Add</SectionTitle></Column>
<Column sm={2} md={6} lg={8}>
@@ -146,10 +147,11 @@
placeholder="email comma separated"
bind:value={emailsStr}
className="mb-3"
isDisabled={$isFreePlan}
{errorMessage}
/>
<Select labelText="Role" bind:selected={role} class="mb-5 w-40">
<Select labelText="Role" bind:selected={role} class="mb-5 w-40" disabled={$isFreePlan}>
<!-- <SelectItem value={ROLE.ADMIN} text={ROLE_LABEL[ROLE.ADMIN]} /> -->
<SelectItem value={ROLE.TUTOR} text={ROLE_LABEL[ROLE.TUTOR]} />
</Select>
@@ -158,7 +160,7 @@
label="Send Invite"
onClick={onSendInvite}
{isLoading}
isDisabled={isLoading}
isDisabled={isLoading || $isFreePlan}
/>
</div>
</Column>
@@ -5,6 +5,7 @@
import LandingpageSettings from './LandingpageSettings.svelte';
// import Account from './Account.svelte';
import OrgSettings from './OrgSettings.svelte';
import Billing from './Billing.svelte';
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { goto } from '$app/navigation';
@@ -64,6 +65,13 @@
},
{
key: 3,
label: 'Billing',
tabKey: 'billing',
href: `${$page.url.pathname}?tab=billing`,
disabled: !$isOrgAdmin
},
{
key: 4,
label: 'Integrations',
tabKey: 'integrations',
href: `${$page.url.pathname}?tab=integrations`,
@@ -91,6 +99,9 @@
<TabContent class="w-full p-0">
<LandingpageSettings />
</TabContent>
<TabContent class="w-full p-0">
<Billing />
</TabContent>
<TabContent class="w-full p-0">
<Integrations />
</TabContent>
@@ -2,8 +2,8 @@
import { page } from '$app/stores';
import HelpIcon from 'carbon-icons-svelte/lib/Help.svelte';
import ForumIcon from 'carbon-icons-svelte/lib/Forum.svelte';
import TextChip from '$lib/components/Chip/Text.svelte';
import { SettingsAdjust } from 'carbon-icons-svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import OrgSelector from '$lib/components/OrgSelector/OrgSelector.svelte';
import HomeIcon from '$lib/components/Icons/HomeIcon.svelte';
import CourseIcon from '$lib/components/Icons/CourseIcon.svelte';
@@ -11,37 +11,18 @@
import SiteSettingsIcon from '$lib/components/Icons/SiteSettingsIcon.svelte';
import AudienceIcon from '$lib/components/Icons/AudienceIcon.svelte';
import Avatar from '$lib/components/Avatar/index.svelte';
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import { currentOrgPath, currentOrgPlan, isFreePlan } from '$lib/utils/store/org';
import { isOrgAdmin } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { NavClasses } from '$lib/utils/constants/reusableClass';
import { sideBar } from './store';
import { goto } from '$app/navigation';
const menuItems = [
{
path: '',
label: 'Dashboard'
},
// {
// path: '/quiz',
// label: 'Quizzes'
// },
{
path: '/courses',
label: 'Courses'
},
{
path: '/community',
label: 'Community'
},
// {
// path: '/site',
// label: 'Site settings',
// },
{
path: '/audience',
label: 'Audience'
}
];
let menuItems: {
path: string;
label: string;
show: boolean;
}[] = [];
function isActive(pagePath: string, itemPath: string) {
const pageLinkItems = pagePath.split('/');
@@ -56,87 +37,137 @@
const toggleSidebar = () => {
$sideBar.hidden = !$sideBar.hidden;
};
const openModal = () => {
goto(window.location.pathname + '?upgrade=true');
};
$: {
menuItems = [
{
path: '',
label: 'Dashboard',
show: true
},
// {
// path: '/quiz',
// label: 'Quizzes'
// },
{
path: '/courses',
label: 'Courses',
show: true
},
{
path: '/community',
label: 'Community',
show: true
},
// {
// path: '/site',
// label: 'Site settings',
// },
{
path: '/audience',
label: 'Audience',
show: true
},
{
path: '/setup',
label: 'Setup',
show: $isOrgAdmin
}
];
}
</script>
<aside
class={`${
$sideBar.hidden
? '-translate-x-[100%] absolute md:translate-x-0 md:relative z-40'
: 'translate-x-0 absolute md:relative z-40'
} overflow-y-auto transition w-[250px] min-w-[250px] bg-gray-100 dark:bg-neutral-900 h-[calc(100vh-48px)] border border-l-0 border-t-0 border-b-0 border-r-1`}
? 'absolute z-40 -translate-x-[100%] md:relative md:translate-x-0'
: 'absolute z-40 translate-x-0 md:relative'
} border-r-1 h-[calc(100vh-48px)] w-[250px] min-w-[250px] overflow-y-auto border border-b-0 border-l-0 border-t-0 border-gray-100 dark:border-neutral-600 bg-gray-100 transition dark:bg-neutral-900`}
>
<div class="h-full flex flex-col">
<div class="border-b border-gray-200 pt-5 px-4">
{#if $currentOrg.avatar_url && $currentOrg.name}
<Avatar
src={$currentOrg.avatar_url}
name={$currentOrg.name}
shape="rounded-md"
width="w-7"
height="h-7"
/>
{:else if $currentOrg.shortName}
<TextChip
value={$currentOrg.shortName}
className="bg-primary-200 dark:text-black font-bold"
/>
{/if}
<div class="flex h-full flex-col">
<div class="">
<OrgSelector />
<ul class="my-5">
<ul class="mt-4 my-2 px-4">
{#each menuItems as menuItem}
<a
href="{$currentOrgPath}{menuItem.path}"
class="text-black no-underline"
on:click={toggleSidebar}
>
<li
class="flex items-center py-3 px-4 mb-2 {NavClasses.item} {isActive(
$page.url.pathname,
`${$currentOrgPath}${menuItem.path}`
)
? NavClasses.active
: 'dark:text-white'}"
{#if menuItem.show}
<a
href="{$currentOrgPath}{menuItem.path}"
class="text-black no-underline"
on:click={toggleSidebar}
>
{#if menuItem.path === ''}
<HomeIcon />
{:else if menuItem.path === '/courses'}
<CourseIcon />
{:else if menuItem.path === '/site'}
<SiteSettingsIcon />
{:else if menuItem.path === '/community'}
<ForumIcon size={24} class="carbon-icon dark:fill-[#fff] fill-[#000]" />
{:else if menuItem.path === '/quiz'}
<QuizIcon />
{:else if menuItem.path === '/audience'}
<AudienceIcon />
{/if}
<p class=" ml-2">{menuItem.label}</p>
</li>
</a>
<li
class="mb-1 flex items-center gap-2.5 px-2.5 py-2 {NavClasses.item} {isActive(
$page.url.pathname,
`${$currentOrgPath}${menuItem.path}`
)
? NavClasses.active
: 'dark:text-white'}"
>
{#if menuItem.path === ''}
<HomeIcon />
{:else if menuItem.path === '/courses'}
<CourseIcon />
{:else if menuItem.path === '/site'}
<SiteSettingsIcon />
{:else if menuItem.path === '/community'}
<ForumIcon size={20} class="carbon-icon fill-[#000] dark:fill-[#fff]" />
{:else if menuItem.path === '/quiz'}
<QuizIcon />
{:else if menuItem.path === '/audience'}
<AudienceIcon />
{:else if menuItem.path === '/setup'}
<SettingsAdjust />
{/if}
<p class="text-sm font-medium">{menuItem.label}</p>
</li>
</a>
{/if}
{/each}
</ul>
</div>
<span class="flex-grow" />
<ul class="my-5 pb-5 px-4">
{#if $isFreePlan}
<div
class="border-primary-700 mx-4 flex flex-col items-center justify-center gap-4 rounded-md border px-2 py-6 text-center hover:scale-95 transition-all ease-in-out"
>
<img src="/upgrade.png" alt="upgrade" class="h-16 w-16" />
<span class="flex flex-col gap-1">
<p class="text-base font-semibold">Become an Early Adopter</p>
<p class="text-xs">Unlock unlimited features and invest in our future</p>
</span>
<PrimaryButton label="Upgrade Now" onClick={openModal} className="font-normal" />
</div>
{/if}
<ul class="my-5 px-4 pb-5">
<a href={$currentOrgPath} class="text-black no-underline" on:click={toggleSidebar}>
<li class="flex items-center py-3 px-4 mb-2 rounded">
<li class="mb-2 flex items-center rounded px-2.5 py-1.5">
<HelpIcon size={20} class="carbon-icon dark:text-white" />
<p class="dark:text-white ml-2">Help</p>
<p class="ml-2.5 dark:text-white text-sm font-medium">Help</p>
</li>
</a>
<a href="{$currentOrgPath}/settings" class="text-black no-underline" on:click={toggleSidebar}>
<li
class="flex items-center py-3 px-4 mb-2 {NavClasses.item} {isActive(
class="mb-2 flex items-center px-2.5 py-1.5 {NavClasses.item} {isActive(
$page.url.pathname,
`${$currentOrgPath}/settings`
)
? NavClasses.active
: 'dark:text-white'}"
>
<Avatar src={$profile.avatar_url} name={$profile.username} width="w-7" height="h-7" />
<p class="ml-2">Settings</p>
<Avatar
src={$profile.avatar_url}
name={$profile.username}
width="w-[1.2rem]"
height="h-[1.2rem]"
/>
<p class="ml-2.5 text-sm font-medium">Settings</p>
</li>
</a>
</ul>
@@ -1,14 +1,19 @@
<script lang="ts">
import { Popover, SkeletonText } from 'carbon-components-svelte';
import CaretDown from 'carbon-icons-svelte/lib/CaretDown.svelte';
import ChevronSort from 'carbon-icons-svelte/lib/ChevronSort.svelte';
import OrgSelectorItem from './OrgSelectorItem.svelte';
import { currentOrg, orgs, currentOrgPath } from '$lib/utils/store/org';
import { currentOrg, orgs, currentOrgPath, currentOrgPlan } from '$lib/utils/store/org';
import { goto } from '$app/navigation';
import { newOrgModal } from '$lib/components/Org/store';
import Avatar from '$lib/components/Avatar/index.svelte';
import type { CurrentOrg } from '$lib/utils/types/org';
import { setTheme } from '$lib/utils/functions/theme';
import TextChip from '$lib/components/Chip/Text.svelte';
import Plan from '$lib/components/Chip/Plan.svelte';
import { PLAN_NAMES } from 'shared-constants/src/plans/constants';
export let canAddOrg = true;
let open = false;
function handleAddOrg() {
@@ -19,33 +24,60 @@
function onClick(org: CurrentOrg) {
localStorage.setItem('classroomio_org_sitename', org.siteName);
currentOrg.set(org);
setTheme(org.theme);
goto($currentOrgPath);
open = false;
}
</script>
<div class="mt-5 relative" data-outline>
<div
class="org-selector px-4 py-3 border border-l-0 border-r-0 border-t-0 border-gray-200 dark:border-neutral-600 relative"
data-outline
>
{#if $currentOrg.name}
<button
class="flex items-center cursor-pointer max-w-[219px]"
class="flex items-center gap-2 w-full"
on:click={(e) => {
e.stopPropagation();
open = !open;
}}
>
<p class="dark:text-white text-lg font-bold whitespace-nowrap truncate">
{$currentOrg.name}
</p>
<CaretDown size={16} class="ml-2" />
{#if $currentOrg.avatar_url && $currentOrg.name}
<Avatar
src={$currentOrg.avatar_url}
name={$currentOrg.name}
shape="rounded-md"
width="w-7"
height="h-7"
/>
{:else if $currentOrg.shortName}
<TextChip
size="sm"
value={$currentOrg.shortName}
className="bg-primary-200 dark:text-black font-medium"
/>
{/if}
<div class="flex items-center cursor-pointer max-w-[219px] justify-between w-full">
<div class="flex flex-col items-start">
<p class="dark:text-white text-sm font-medium whitespace-nowrap truncate mb-1">
{$currentOrg.name}
</p>
<Plan name={$currentOrgPlan ? PLAN_NAMES[$currentOrgPlan.plan_name] : PLAN_NAMES.BASIC} />
</div>
<ChevronSort size={16} />
</div>
</button>
{:else}
<div class="w-[219px] h-[30px]">
<SkeletonText style="width: 100%; height: 100%;" />
</div>
{/if}
{#if canAddOrg}
<Popover bind:open closeOnOutsideClick align="bottom-left">
<Popover class="w-[95%] left-[2%] rounded-md" bind:open closeOnOutsideClick align="bottom-left">
{#each $orgs as org}
<OrgSelectorItem
size="sm"
@@ -62,3 +94,9 @@
</Popover>
{/if}
</div>
<style>
:global(.org-selector .bx--popover-contents) {
width: 100%;
}
</style>
@@ -15,9 +15,10 @@
<div class="relative">
<button
class="{disabled &&
'opacity-25'} flex items-center text-start w-[14rem] py-2 px-3 {hasDivider &&
'border-b border-gray-100'} {active && 'bg-primary-100 dark:bg-gray-500'}"
class="w-full {disabled &&
'opacity-25'} flex items-center text-start w-[14rem] py-1.5 px-2.5 {hasDivider &&
'border-b border-gray-100 dark:border-neutral-600'} {active &&
'bg-gray-200 dark:bg-neutral-700 dark:text-white'}"
on:click={disabled ? undefined : onClick}
>
{#if avatar}
@@ -25,14 +26,14 @@
src={avatar}
name={text}
shape="rounded-md"
width="w-7"
height="h-7"
width="w-[1.2rem]"
height="h-[1.2rem]"
className="mr-2"
/>
{:else if avatarText}
<TextChip
value={avatarText}
className="mr-2 bg-primary-200 font-bold dark:bg-gray-600"
className="mr-2 bg-primary-200 font-medium text-xs dark:text-black"
{size}
/>
{/if}
@@ -1,5 +1,6 @@
export const VARIANTS = {
CONTAINED: 'CONTAINED',
CONTAINED_DARK: 'CONTAINED_DARK',
CONTAINED_LIGHT: 'CONTAINED_LIGHT',
CONTAINED_INFO: 'CONTAINED_INFO',
CONTAINED_WHITE: 'CONTAINED_WHITE',
@@ -14,6 +15,8 @@ export const VARIANTS = {
export const VARIANTS_CLASS = {
[VARIANTS.CONTAINED]: 'border-none bg-primary-700 hover:bg-primary-900 text-white font-bold',
[VARIANTS.CONTAINED_DARK]:
'border-none bg-black hover:bg-neutral-600 text-white dark:bg-white dark:text-black font-bold',
[VARIANTS.CONTAINED_LIGHT]:
'border-none bg-primary-600 hover:bg-primary-900 text-white font-bold',
[VARIANTS.CONTAINED_WHITE]:
@@ -22,7 +25,7 @@ export const VARIANTS_CLASS = {
[VARIANTS.CONTAINED_SUCCESS]: 'bg-green-700 hover:bg-green-900 text-white',
[VARIANTS.CONTAINED_DANGER]: 'bg-red-700 hover:bg-red-900 text-white',
[VARIANTS.OUTLINED]:
'border border-primary-700 hover:border-black-300 hover:bg-primary-700 text-black dark:text-white',
'border border-black hover:bg-neutral-600 hover:text-white text-black dark:text-white',
[VARIANTS.NONE]:
'border-none hover:border-black-300 hover:bg-gray-200 dark:hover:bg-neutral-800 text-black',
[VARIANTS.TEXT]: 'text-black hover:underline',
@@ -17,24 +17,21 @@
<button
class="{isLoading || isDisabled
? 'opacity-25 cursor-not-allowed'
: 'cursor-pointer'} flex items-center h-auto {VARIANTS_CLASS[
: 'cursor-pointer hover:scale-95 transition-all ease-in-out'} flex items-center h-auto {VARIANTS_CLASS[
isLoading ? VARIANTS.OUTLINED : variant
]} {!disablePadding &&
'py-[0.5rem] px-6'} rounded-md {className} w-fit min-h-[36px] justify-center sm:w-auto {isLoading &&
'py-[1rem] border-primary-700'} {variant !== VARIANTS.TEXT &&
'hover:shadow-xl'} transition delay-150 duration-300 ease-in-out {variant !==
VARIANTS.CONTAINED_WHITE
? 'dark:text-white'
: ''}"
'py-[0.5rem] px-6'} rounded-md {className} w-fit min-h-[36px] justify-center sm:w-auto {variant !==
VARIANTS.TEXT && 'hover:shadow-xl'} transition delay-150 duration-300 ease-in-out"
on:click={onClick}
{name}
{type}
disabled={isLoading || isDisabled}
>
{#if isLoading}
<Loading withOverlay={false} small />
<Loading withOverlay={false} small class="mr-2" />
<!-- <Moon size="20" color="#1d4ed8" unit="px" duration="1s" /> -->
{:else if !!label}
{/if}
{#if !!label}
{label}
{:else}
<slot />
@@ -0,0 +1,23 @@
<script lang="ts">
import { openUpgradeModal } from '$lib/utils/functions/org';
import FlashFilled from 'carbon-icons-svelte/lib/FlashFilled.svelte';
import { isFreePlan } from '$lib/utils/store/org';
export let className = '';
export let onClick = () => {};
</script>
{#if $isFreePlan}
<button
class="w-full border bg-blue-50 border-blue-700 flex items-center gap-2 px-2 py-2 rounded-md hover:scale-95 transition-all ease-in-out {className}"
on:click={() => {
onClick();
openUpgradeModal();
}}
>
<FlashFilled size={16} class="text-blue-700" />
<p class="font-semibold text-blue-700 text-sm">
<slot />
</p>
</button>
{/if}
@@ -0,0 +1,220 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import type { OrganizationPlan } from '$lib/utils/types';
import { supabase } from '$lib/utils/functions/supabase';
import { goto } from '$app/navigation';
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import { page } from '$app/stores';
import Checkmark from 'carbon-icons-svelte/lib/Checkmark.svelte';
import PLANS from 'shared-constants/src/plans/data.json';
import { profile } from '$lib/utils/store/user';
import { subscribeToProduct } from '$lib/utils/services/lemonsqueezy/subscribe';
import { snackbar } from '$lib/components/Snackbar/store';
import { Loading } from 'carbon-components-svelte';
import Modal from '$lib/components/Modal/index.svelte';
import StepDoneIcon from '$lib/components/Icons/StepDoneIcon.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import type { RealtimeChannel, RealtimePostgresChangesPayload } from '@supabase/supabase-js';
import Confetti from '$lib/components/Confetti/index.svelte';
import { toggleConfetti } from '$lib/components/Confetti/store';
const disabledClass = 'bg-gray-300 text-gray-400 hover:cursor-not-allowed';
let isLoadingPlan: string | null = null;
let orgPlanChannel: RealtimeChannel;
let open = false;
let upgraded = false;
async function handleClick(plan, planName: string) {
if (plan.CTA.IS_DISABLED || !$profile.id) {
return;
}
if (!plan.CTA.PRODUCT_ID && plan.CTA.DASHBOARD_LINK) {
window.open(plan.CTA.DASHBOARD_LINK, '_blank');
return;
}
isLoadingPlan = planName;
try {
const { checkoutURL } = await subscribeToProduct({
productId: plan.CTA.PRODUCT_ID,
email: $profile.email,
name: $profile.fullname,
triggeredBy: $currentOrg.memberId,
orgId: $currentOrg.id
});
if (!checkoutURL) {
snackbar.error('Failed to generate success');
return;
}
window.open(checkoutURL, '_blank');
} catch (error) {
console.error('Error subscribing', error);
snackbar.error('Failed, please try again later');
}
isLoadingPlan = null;
}
async function handleInsert(payload: RealtimePostgresChangesPayload<OrganizationPlan>) {
const newPlan = payload.new as OrganizationPlan;
console.log('A new plan was inserted');
// If plan was successfully generated
if (newPlan.org_id === $currentOrg.id && newPlan.is_active) {
upgraded = true;
toggleConfetti();
setTimeout(() => {
toggleConfetti();
}, 1000);
}
}
function onClose() {
if (upgraded) {
window.location.href = $currentOrgPath;
} else {
goto($page.url.pathname);
}
}
function onLearnMore() {
window.open('https://classroomio.com/blog/early-adopter', '_blank');
}
onMount(() => {
orgPlanChannel = supabase
.channel('any')
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'organization_plan' },
handleInsert
)
.subscribe();
});
onDestroy(() => {
if (orgPlanChannel) {
supabase.removeChannel(orgPlanChannel);
}
});
$: {
const query = new URLSearchParams($page.url.search);
open = (query.get('upgrade') || '') === 'true';
}
$: planNames = Object.keys(PLANS);
</script>
{#if upgraded}
<Confetti />
{/if}
<Modal
{onClose}
{open}
width="w-4/5"
maxWidth={upgraded ? 'max-w-[600px]' : undefined}
modalHeading="Upgrade Plan"
>
{#if upgraded}
<div class="flex flex-col items-center justify-center mb-4 w-full relative">
<StepDoneIcon />
<h4 class="text-2xl">Thank you for your support</h4>
<p class="mb-4 text-center">
Your plan comes with cool perks and benefits including access to all future features we
release.
</p>
<div class="flex items-center gap-4">
<PrimaryButton label="Close" variant={VARIANTS.OUTLINED} onClick={onClose} />
<PrimaryButton label="Learn more" variant={VARIANTS.CONTAINED_DARK} onClick={onLearnMore} />
</div>
</div>
{:else}
<div class="flex flex-col items-center justify-center">
<div class="isolate grid grid-cols-1 gap-3 lg:grid-cols-3">
{#each planNames as planName}
<div
class="max-w-xl rounded-xl {planName === 'EARLY_ADOPTER' &&
'bg-primary-900'} p-4 ring-1 ring-gray-200 lg:max-w-sm"
>
<p
class="mb-2 text-lg font-semibold leading-8 {planName === 'EARLY_ADOPTER'
? 'text-white'
: 'text-gray-900'} dark:text-white lg:text-xl"
>
{PLANS[planName].NAME}
</p>
<p
class="text-baseline flex items-baseline gap-x-1 font-medium {planName ===
'EARLY_ADOPTER'
? 'text-white'
: 'text-black'} dark:text-gray-300"
>
{PLANS[planName].PRICE.CURRENCY}
{PLANS[planName].PRICE.MONTHLY}
{#if !PLANS[planName].PRICE.IS_PREMIUM}
/month
{/if}
</p>
<p
class=" mt-4 text-sm font-light leading-6 {planName === 'EARLY_ADOPTER'
? 'text-white'
: 'text-black'} dark:text-gray-300 lg:leading-6"
>
{PLANS[planName].DESCRIPTION}
</p>
<button
class="text-md mt-4 block w-full rounded-md {isLoadingPlan === planName &&
disabledClass} {planName === 'EARLY_ADOPTER'
? 'bg-white text-slate-900 hover:bg-indigo-50'
: PLANS[planName].CTA.IS_DISABLED
? disabledClass
: 'bg-primary-900 hover:bg-primary-700 text-white'} py-3 text-center font-medium hover:no-underline lg:rounded-md lg:py-3 lg:text-lg lg:font-semibold flex items-center justify-center"
on:click={() => {
if (isLoadingPlan === planName) return;
handleClick(PLANS[planName], planName);
}}
>
{#if isLoadingPlan === planName}
<Loading withOverlay={false} small />
{:else}
{PLANS[planName].CTA.DASHBOARD_LABEL}
{/if}
</button>
<ul
class="mt-4 space-y-2 text-sm {planName === 'EARLY_ADOPTER'
? 'text-white'
: 'text-black'} dark:text-gray-300"
>
{#each PLANS[planName].FEATURES as features}
<li class="flex items-center">
<div>
<Checkmark
size={16}
fill={planName === 'EARLY_ADOPTER' ? '#fff' : '#1D4EE2'}
class="mr-2 lg:mr-3"
/>
</div>
<p class="text-sm">
{features}
</p>
</li>
{/each}
</ul>
</div>
{/each}
</div>
</div>
{/if}
</Modal>
@@ -24,7 +24,7 @@
<section class="width-fit p-3 flex flex-col items-center">
<div
class="avatar-container {widthHeight ||
'setwidthheight'} pointer border-2 border-gray-200 relative {shape}"
'setwidthheight'} pointer border-2 border-gray-200 dark:border-neutral-600 relative {shape}"
>
{#if src}
<img class="w-full h-full {shape}" {src} alt="Avatar" />
@@ -9,25 +9,24 @@
} from '$lib/utils/services/notification/notification';
import { profile } from '$lib/utils/store/user';
let query = new URLSearchParams($page.url.search);
let welcomePopup = query.get('welcomePopup');
let open = false;
const closeModal = () => {
triggerSendEmail(NOTIFICATION_NAME.WELCOME_TO_APP, {
to: $profile.email,
name: $profile.fullname
});
goto($currentOrgPath + '/courses?create=true');
goto($currentOrgPath + '/setup');
};
$: {
const query = new URLSearchParams($page.url.search);
open = query.get('welcomePopup') === 'true';
}
</script>
<Modal
onClose={closeModal}
open={welcomePopup === 'true'}
width="w-9/12"
maxWidth="w-[800px]"
modalHeading="Welcome"
>
<Modal onClose={closeModal} {open} width="w-9/12" maxWidth="w-[800px]" modalHeading="Welcome">
<p class="text-black dark:text-white text-sm md:text-base lg:text-lg">
We at <a
href="https://app.classroomio.com/"
@@ -23,5 +23,6 @@ export const blockedSubdomain = [
'academy',
'schools',
'training',
'cloud'
'cloud',
'launchweek'
];
@@ -0,0 +1,23 @@
import posthog from 'posthog-js';
import { supabase } from '$lib/utils/functions/supabase';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { currentOrg, orgs, defaultCurrentOrgState } from '$lib/utils/store/org';
import { user, profile, defaultProfileState, defaultUserState } from '$lib/utils/store/user';
import { goto } from '$app/navigation';
export async function logout() {
const { error } = await supabase.auth.signOut();
if (error) {
console.error('Error logging out: ', error);
}
currentOrg.set(defaultCurrentOrgState);
orgs.set([]);
user.set(defaultUserState);
profile.set(defaultProfileState);
capturePosthogEvent('user_logged_out');
posthog.reset();
goto('/login');
}
@@ -1,3 +1,5 @@
import { goto } from '$app/navigation';
export function genQuizPin(): number {
var minm = 100000;
var maxm = 999999;
@@ -10,3 +12,13 @@ export function generateSitename(orgName: string): string {
.replace(/\s+/g, '-')
.replace(/[^a-zA-Z0-9-]/g, '');
}
export function openUpgradeModal() {
const url = new URL(window.location.href);
url.searchParams.set('upgrade', 'true');
const searchParams = url.searchParams.toString();
goto(window.location.pathname + `?${searchParams}`);
}
@@ -0,0 +1,11 @@
import { LEMON_SQUEEZY_API_KEY } from '$env/static/private';
import { lemonSqueezySetup } from '@lemonsqueezy/lemonsqueezy.js';
export function configureLemonSqueezy() {
return lemonSqueezySetup({
apiKey: LEMON_SQUEEZY_API_KEY,
onError: (error) => {
throw new Error(`Lemon Squeezy API error: ${error.message}`);
}
});
}
@@ -0,0 +1,24 @@
import axios from 'axios';
interface SubscriptionData {
productId: string;
email: string;
name: string;
triggeredBy: string;
orgId: string;
}
interface SubscriptionResponse {
success: boolean;
checkoutURL: string;
}
export async function subscribeToProduct(data: SubscriptionData): Promise<SubscriptionResponse> {
const response = await axios({
method: 'POST',
url: '/api/lmz/subscribe',
data
});
return response.data;
}
@@ -1,11 +1,11 @@
import isEmpty from 'lodash/isEmpty';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { supabase } from '$lib/utils/functions/supabase';
import { orgs, currentOrg, orgAudience, orgTeam } from '$lib/utils/store/org';
import { ROLE, ROLE_LABEL } from '$lib/utils/constants/roles';
import type { CurrentOrg, OrgTeamMember, OrgAudience } from '$lib/utils/types/org';
import type { CurrentOrg, OrgTeamMember } from '$lib/utils/types/org';
import type { OrganizationPlan } from '$lib/utils/types';
export async function getOrgTeam(orgId: string) {
const { data, error } = await supabase
@@ -79,7 +79,12 @@ export async function getOrganizations(userId: string) {
avatar_url,
landingpage,
theme,
created_at
created_at,
organization_plan(
plan_name,
is_active,
subscriptionId:lmz_data->id
)
)
`
)
@@ -108,7 +113,8 @@ export async function getOrganizations(userId: string) {
avatar_url: orgMember?.organization?.avatar_url,
memberId: orgMember?.id,
role_id: orgMember?.role_id,
landingpage: orgMember?.organization?.landingpage
landingpage: orgMember?.organization?.landingpage,
organization_plan: orgMember?.organization?.organization_plan
});
});
@@ -200,7 +206,6 @@ export async function getCourseBySiteName(siteName: string) {
}
export async function getCurrentOrg(siteName: string, justGet = false) {
const { data, error } = await supabase
.from('organization')
.select(
@@ -210,14 +215,17 @@ export async function getCurrentOrg(siteName: string, justGet = false) {
siteName,
avatar_url,
landingpage,
theme
theme,
organization_plan(
plan_name,
is_active
)
`
)
.eq('siteName', siteName)
.returns<CurrentOrg[]>();
console.log('data =', data);
console.log('error =', error);
const isDataEmpty = !data?.[0];
@@ -234,3 +242,32 @@ export async function getCurrentOrg(siteName: string, justGet = false) {
return data[0];
}
}
export async function createOrgPlan(params: {
orgId: string;
planName: string;
triggeredBy: number;
data: OrganizationPlan['lmz_data'];
}) {
return await supabase.from('organization_plan').insert({
activated_at: new Date().toDateString(),
org_id: params.orgId,
triggered_by: params.triggeredBy,
plan_name: params.planName,
is_active: true,
lmz_data: params.data
});
}
export async function cancelOrgPlan(params: { orgId: string; planName: string }) {
return await supabase
.from('organization_plan')
.update({
is_active: false,
deactivated_at: new Date().toDateString()
})
.match({
plan_name: params.planName,
org_id: params.orgId
});
}
+21 -1
View File
@@ -5,6 +5,7 @@ import type { Writable } from 'svelte/store';
import type { CurrentOrg, OrgTeamMember, OrgAudience } from '../types/org';
import { ROLE } from '$lib/utils/constants/roles';
import type { UserLessonDataType } from '$lib/utils/types';
import { PLAN } from 'shared-constants/src/plans/constants';
export const defaultCurrentOrgState: CurrentOrg = {
id: '',
@@ -15,7 +16,8 @@ export const defaultCurrentOrgState: CurrentOrg = {
memberId: '',
role_id: '',
landingpage: {},
theme: ''
theme: '',
organization_plan: []
};
export const orgs = writable<CurrentOrg[]>([]);
@@ -26,6 +28,9 @@ export const isOrgAdmin = derived(
currentOrg,
($currentOrg) => parseInt($currentOrg.role_id) === ROLE.ADMIN
);
export const currentOrgPlan = derived(currentOrg, ($currentOrg) =>
$currentOrg.organization_plan.find((p) => p.is_active)
);
export const currentOrgPath = derived(currentOrg, ($currentOrg) =>
$currentOrg.siteName ? `/org/${$currentOrg.siteName}` : ''
);
@@ -38,6 +43,21 @@ export const currentOrgDomain = derived(currentOrg, ($currentOrg) => {
: '';
});
// Utility org store
export const isFreePlan = derived(
currentOrgPlan,
($plan) => !$plan || $plan.plan_name === PLAN.BASIC
);
export const currentOrgMaxAudience = derived(currentOrgPlan, ($plan) =>
!$plan
? 50
: $plan.plan_name === PLAN.EARLY_ADOPTER
? 10000
: $plan.plan_name === PLAN.ENTERPRISE
? Number.MAX_SAFE_INTEGER
: 50
);
// Quiz
export const createQuizModal = writable({
open: false,
@@ -105,6 +105,17 @@ export interface Organization {
name: any; // type unknown;
}
export interface OrganizationPlan {
id: number;
activated_at: string;
deactivated_at: string;
org_id: string;
plan_name: string;
is_active: boolean;
updated_at: string;
lmz_data: unknown;
}
export interface Profile {
id: string /* primary key */;
fullname: string;
+6 -1
View File
@@ -7,9 +7,14 @@ export interface CurrentOrg {
memberId: string;
role_id: string;
landingpage: {
[key: string]: any;
[key: string]: unknown;
};
theme: string;
organization_plan: {
subscriptionId: string;
plan_name: string;
is_active: boolean;
}[];
}
export interface OrgTeamMember {
+2
View File
@@ -34,6 +34,7 @@
import shouldRedirectOnAuth from '$lib/utils/functions/routes/shouldRedirectOnAuth';
import { identifyPosthogUser, initPosthog } from '$lib/utils/services/posthog';
import { initSentry, setSentryUser } from '$lib/utils/services/sentry';
import UpgradeModal from '$lib/components/Upgrade/Modal.svelte';
import '../app.postcss';
@@ -281,6 +282,7 @@
<Theme bind:theme={carbonTheme} />
<UpgradeModal />
<Snackbar />
{#if data.skipAuth}
@@ -1,6 +1,6 @@
import { Configuration, OpenAIApi } from 'openai-edge';
import { OpenAIStream, StreamingTextResponse } from 'ai';
import { OPENAI_API_KEY } from '$env/static/private';
import { OpenAIStream, StreamingTextResponse } from 'ai';
import { Configuration, OpenAIApi } from 'openai-edge';
// Create an OpenAI API client (that's edge friendly!)
const openAIConfig = new Configuration({
@@ -0,0 +1,38 @@
import { json } from '@sveltejs/kit';
import { createCheckout } from '@lemonsqueezy/lemonsqueezy.js';
import { LEMON_SQUEEZY_STORE_ID } from '$env/static/private';
import { configureLemonSqueezy } from '$lib/utils/services/lemonsqueezy';
export async function POST({ request }) {
const { email, name, orgId, productId, triggeredBy } = await request.json();
await configureLemonSqueezy();
if (!productId) {
return json({ success: false, message: 'Product Id is required' }, { status: 400 });
}
const checkoutData = {
email: email,
name: name,
custom: {
org_id: orgId,
triggered_by: `${triggeredBy}`
}
};
console.log({ checkoutData });
const { data } = await createCheckout(LEMON_SQUEEZY_STORE_ID, productId, {
checkoutData
});
const checkoutURL = data?.data.attributes.url;
console.log({ checkoutURL });
return json({
success: true,
checkoutURL
});
}
@@ -0,0 +1,23 @@
import { json } from '@sveltejs/kit';
import { getSubscription } from '@lemonsqueezy/lemonsqueezy.js';
import { configureLemonSqueezy } from '$lib/utils/services/lemonsqueezy';
export async function GET({ url }) {
const subscriptionId = url.searchParams.get('subscriptionId');
await configureLemonSqueezy();
if (!subscriptionId) {
return json({ success: false, message: 'Subscription id is required' }, { status: 400 });
}
const response = await getSubscription(subscriptionId);
const customerPortal = response?.data?.data?.attributes?.urls?.customer_portal;
console.log({ customerPortal });
return json({
success: true,
customerPortal
});
}
@@ -0,0 +1,77 @@
import { json } from '@sveltejs/kit';
import crypto from 'crypto';
import { LEMON_SQUEEZY_WEBHOOK_SECRET } from '$env/static/private';
import { createOrgPlan, cancelOrgPlan } from '$lib/utils/services/org';
import { PLAN } from 'shared-constants/src/plans/constants';
export async function POST({ request }) {
try {
// Catch the event type
const clonedReq = request.clone();
const eventType = request.headers.get('X-Event-Name');
const body = await request.json();
// Check signature
const secret = LEMON_SQUEEZY_WEBHOOK_SECRET;
const hmac = crypto.createHmac('sha256', secret);
const digest = Buffer.from(hmac.update(await clonedReq.text()).digest('hex'), 'utf8');
const signature = Buffer.from(request.headers.get('X-Signature') || '', 'utf8');
if (!crypto.timingSafeEqual(digest, signature)) {
throw new Error('Invalid signature.');
}
console.log(body);
// Logic according to event
if (eventType === 'subscription_created' || eventType === 'subscription_resumed') {
const orgId = body.meta.custom_data.org_id;
const triggeredBy = body.meta.custom_data.triggered_by;
const isSuccessful = body.data.attributes.status === 'active';
console.log({
orgId,
triggeredBy,
isSuccessful
});
if (isSuccessful && orgId) {
const { data, error } = await createOrgPlan({
orgId,
triggeredBy: parseInt(triggeredBy),
planName: PLAN.EARLY_ADOPTER,
data: body.data
});
if (error) console.error('Error creating org plan', error);
console.log('Create plan', data);
}
}
if (
['subscription_cancelled', 'subscription_expired', 'subscription_payment_refunded'].includes(
`${eventType}`
)
) {
const orgId = body.meta.custom_data.org_id;
const { data, error } = await cancelOrgPlan({
orgId,
planName: PLAN.EARLY_ADOPTER
});
if (error) console.error('Error canceling org plan', error);
console.log('Cancel plan', data);
}
return json({ message: 'Webhook received' });
} catch (err) {
console.error(err);
return json({ message: 'Server error' }, { status: 500 });
}
}
export function GET() {
return json({
success: true
});
}
@@ -0,0 +1,57 @@
import { json } from '@sveltejs/kit';
export async function POST({ request }) {
try {
// Catch the event type
// const clonedReq = request.clone();
// const eventType = request.headers.get('X-Event-Name');
const body = await request.json();
// // Check signature
// const secret = LEMON_SQUEEZY_WEBHOOK_SECRET;
// const hmac = crypto.createHmac('sha256', secret);
// const digest = Buffer.from(hmac.update(await clonedReq.text()).digest('hex'), 'utf8');
// const signature = Buffer.from(request.headers.get('X-Signature') || '', 'utf8');
// if (!crypto.timingSafeEqual(digest, signature)) {
// throw new Error('Invalid signature.');
// }
console.log(body);
// Logic according to event
// if (eventType === 'order_created') {
// const orgId = body.meta.custom_data.org_id;
// const triggeredBy = body.meta.custom_data.triggered_by;
// const isSuccessful = body.data.attributes.status === 'paid';
// console.log({
// orgId,
// triggeredBy,
// isSuccessful
// });
// if (isSuccessful) {
// const { error } = await createOrgPlan({
// orgId,
// triggeredBy,
// planName: PLAN.EARLY_ADOPTER,
// data: body.data
// });
// if (error) console.error('Error creating org plan', error);
// }
// }
return json({ message: 'Webhook received' });
} catch (err) {
console.error(err);
return json({ message: 'Server error' }, { status: 500 });
}
}
export function GET() {
return json({
success: true
});
}
@@ -3,7 +3,6 @@
import PageNav from '$lib/components/PageNav/index.svelte';
import PageBody from '$lib/components/PageBody/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { setCourse, course } from '$lib/components/Course/store';
import IssueCertificateModal from '$lib/components/Course/components/Ceritficate/IssueCertificateModal.svelte';
// import DeleteConfirmation from '$lib/components/Course/components/People/DeleteConfirmation.svelte';
import Design from '$lib/components/Course/components/Ceritficate/Design.svelte';
@@ -40,7 +39,7 @@
<StudentCertificate />
</RoleBasedSecurity>
<!-- <Tabs autoWidth class="border-b border-gray-200">
<!-- <Tabs autoWidth class="border-b border-gray-200 dark:border-neutral-600">
<Tab label="Design" />
<Tab label="Reports" />
<svelte:fragment slot="content">
@@ -121,7 +121,7 @@
>
{#each $lessons as lesson (lesson.id)}
<div
class="relative m-auto mb-4 flex max-w-xl items-center rounded-md border-2 border-gray-200 p-5 dark:border-neutral-700 dark:bg-neutral-800"
class="relative m-auto mb-4 flex max-w-xl items-center rounded-md border-2 border-gray-200 dark:border-neutral-600 p-5 dark:border-neutral-700 dark:bg-neutral-800"
>
<!-- Number Chip -->
<div class="mr-5">
@@ -0,0 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import { logout } from '$lib/utils/functions/logout';
onMount(() => {
logout();
});
</script>
<div class="app-background w-full min-h-screen flex items-center justify-center">
<div>Logging you out...</div>
</div>
@@ -0,0 +1,5 @@
export const load = ({ params = { slug: '' } }) => {
return {
orgName: params.slug
};
};
@@ -4,6 +4,15 @@
import { isQuizPage } from '$lib/utils/functions/app';
import OrgSideBar from '$lib/components/Org/SideBar.svelte';
import VerifyEmailModal from '$lib/components/Org/VerifyEmail/VerifyEmailModal.svelte';
import Box from '$lib/components/Box/index.svelte';
import { currentOrg } from '$lib/utils/store/org';
import { goto } from '$app/navigation';
export let data;
$: if ($currentOrg.id && data.orgName === '*') {
goto(`/org/${$currentOrg.siteName}`);
}
</script>
<AddOrgModal />
@@ -13,6 +22,10 @@
<OrgSideBar />
{/if}
<div class="org-slot bg-white dark:bg-black w-full">
<slot />
{#if data.orgName === '*'}
<Box>Taking you to your organization...</Box>
{:else}
<slot />
{/if}
</div>
</div>
@@ -184,7 +184,7 @@
<WelcomeModal />
<div class="py-10 px-5 w-full max-w-7xl mx-auto">
<div class="py-10 px-5 w-full max-w-5xl md:mx-auto">
<div class="flex items-center justify-between mb-10">
<h1 class="dark:text-white text-2xl md:text-3xl font-bold mb-3">
{getGreeting()}
@@ -232,7 +232,7 @@
<!-- <div class="flex items-start flex-wrap">
{#each boxes as box}
<div
class="w-full md:w-[246px] h-[165px] flex flex-col rounded border border-gray-200 justify-center px-5 md:mr-5 mb-5"
class="w-full md:w-[246px] h-[165px] flex flex-col rounded border border-gray-200 dark:border-neutral-600 justify-center px-5 md:mr-5 mb-5"
>
<p class="dark:text-white mb-2 text-sm">{box.label}</p>
<p class="dark:text-white text-xl font-bold">{box.value}</p>
@@ -245,7 +245,7 @@
<div class="w-full xl:w-auto container">
<p class="dark:text-white font-bold mt-7 mb-4">Your Schedule</p>
<div
class="rounded border border-gray-200 gap-3 flex flex-col md:flex-row items-start px-2 md:px-5 py-5 w-full"
class="rounded border border-gray-200 dark:border-neutral-600 gap-3 flex flex-col md:flex-row items-start px-2 md:px-5 py-5 w-full"
>
{#if !$isMobile}
<div id="calendar" class="flex justify-center mt-5 w-2/5 min-w-[250px]">
@@ -326,10 +326,10 @@
<!-- <div class="w-full">
<p class="dark:text-white font-bold mb-7">Your Activities</p>
<div
class="rounded border border-gray-200 md:min-w-[450px] activities-box py-4 px-2 md:px-5 w-full"
class="rounded border border-gray-200 dark:border-neutral-600 md:min-w-[450px] activities-box py-4 px-2 md:px-5 w-full"
>
{#each activities as activity}
<div class="flex mb-3 pb-3 border-b border-gray-200">
<div class="flex mb-3 pb-3 border-b border-gray-200 dark:border-neutral-600">
<Avatar src={activity.avatar} name="avatar" />
<div class="ml-2 flex flex-col xl:flex-row">
<div class="mr-2">
@@ -1,8 +1,12 @@
<script>
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import Audience from '$lib/components/Org/Audience/index.svelte';
import { orgAudience, currentOrgPlan, currentOrgMaxAudience } from '$lib/utils/store/org';
import { PLAN } from 'shared-constants/src/plans/constants';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
let isLoading = false;
function exportAudience() {
isLoading = true;
alert('This feature is coming soon');
@@ -14,13 +18,23 @@
<title>Audience - ClassroomIO</title>
</svelte:head>
<section class="w-full max-w-6xl mx-auto">
<section class="w-full max-w-4xl mx-auto">
<div class="py-10 px-5">
<div class="flex items-center justify-between mb-10">
<h1 class="dark:text-white text-2xl md:text-3xl font-bold">Audience</h1>
<div class="flex items-end">
<h1 class="dark:text-white text-2xl md:text-3xl font-bold m-0">Audience</h1>
{#if $currentOrgPlan?.plan_name !== PLAN.ENTERPRISE}
<span class="ml-2">
({$orgAudience.length} / {$currentOrgMaxAudience})
</span>
{/if}
</div>
<PrimaryButton label="Export" onClick={exportAudience} isDisabled={isLoading} {isLoading} />
</div>
{#if $orgAudience.length >= $currentOrgMaxAudience}
<UpgradeBanner>Upgrade your plan to invite more students</UpgradeBanner>
{/if}
<Audience />
</div>
</section>
@@ -13,6 +13,7 @@
import { isOrgAdmin } from '$lib/utils/store/org';
import type { Course } from '$lib/utils/types';
import { browser } from '$app/environment';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
export let data;
let { cantFetch } = data;
@@ -93,6 +94,7 @@
{:else}
<PrimaryButton
label="Create Course"
variant={VARIANTS.CONTAINED_DARK}
isDisabled={!$isOrgAdmin}
onClick={() => ($createCourseModal.open = true)}
/>
@@ -7,7 +7,7 @@
<title>Settings - ClassroomIO</title>
</svelte:head>
<section class="w-full max-w-6xl mx-auto">
<section class="w-full md:max-w-4xl mx-auto">
<div class="py-10 px-5">
<div class="flex items-center justify-between mb-10">
<h1 class="dark:text-white text-3xl font-bold">Settings</h1>
@@ -7,7 +7,7 @@
<title>Custom domain - ClassroomIO</title>
</svelte:head>
<section class="w-full max-w-6xl mx-auto">
<section class="w-full md:max-w-4xl mx-auto">
<div class="py-10 px-3 md:px-5">
<a class="text-gray-500 dark:text-white text-md" href={`${$currentOrgPath}/settings?tab=org`}
>Back to Settings</a
@@ -1,13 +1,14 @@
<script>
import TeamMembers from '$lib/components/Org/Settings/TeamMembers.svelte';
import { currentOrgPath } from '$lib/utils/store/org';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
</script>
<svelte:head>
<title>Teams - ClassroomIO</title>
</svelte:head>
<section class="w-full max-w-6xl mx-auto">
<section class="w-full md:max-w-4xl mx-auto">
<div class="py-10 px-3 md:px-5">
<a class="text-gray-500 dark:text-white text-md" href={`${$currentOrgPath}/settings?tab=org`}
>Back to Settings</a
@@ -16,6 +17,7 @@
<h1 class="dark:text-white text-3xl font-bold">Team members</h1>
</div>
<UpgradeBanner>Upgrade your plan to add team members</UpgradeBanner>
<TeamMembers />
</div>
</section>
@@ -0,0 +1,128 @@
<script>
import Chip from '$lib/components/Chip/Text.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import StepDoneIcon from '$lib/components/Icons/StepDoneIcon.svelte';
import CheckmarkOutline from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte';
import { currentOrg } from '$lib/utils/store/org';
import { goto } from '$app/navigation';
import { snackbar } from '$lib/components/Snackbar/store.js';
import { profile } from '$lib/utils/store/user';
export let data;
let setupList = data.setup || [];
let completed = 0;
const StepsEnum = {
UPDATE_PROFILE: 'Upload a profile picture and update username',
UPDATE_ORG_PROFILE: 'Update organisation profile picture',
CREATE_COURSE: 'Create Course',
CREATE_LESSON: 'Create a lesson',
CREATE_EXERCISE: 'Create an exercise',
PUBLISH_COURSE: 'Publish a course'
};
const isCompleted = (id) => {
return setupList?.find((c) => c.id === id).is_completed == true;
};
const handleClick = (title) => {
switch (title) {
case StepsEnum.CREATE_COURSE:
goto(`/org/${$currentOrg.siteName}/courses?create=true`);
break;
case StepsEnum.CREATE_LESSON:
if (isCompleted('course')) {
const courseId = data?.courses[0].id;
goto(`/courses/${courseId}/lessons`);
} else {
snackbar.info('You need to create a course');
}
break;
case StepsEnum.CREATE_EXERCISE:
if (isCompleted('lesson')) {
const courseId = data?.courses[0].id;
const lessonId = data?.lessons[0].id;
goto(`/courses/${courseId}/lessons/${lessonId}`);
} else {
snackbar.info('You Need to Create a lesson');
}
break;
case StepsEnum.PUBLISH_COURSE:
if (isCompleted('course')) {
const courseId = data?.courses[0].id;
goto(`/courses/${courseId}/settings`);
} else {
snackbar.info('You need to create a course');
}
break;
case StepsEnum.UPDATE_PROFILE:
goto(`/org/${$currentOrg.siteName}/settings`);
break;
case StepsEnum.UPDATE_ORG_PROFILE:
goto(`/org/${$currentOrg.siteName}/settings?tab=org`);
break;
}
};
$: setupList = setupList.map((item) => {
if (item.id === 'profile') {
item.is_completed = !$profile.avatar_url.includes('avatars/avatar.png');
}
return item;
});
$: completed = setupList.filter((list) => list.is_completed).length;
</script>
<section class="w-full md:max-w-4xl mx-auto">
<div class="py-2 md:py-10 px-2 md:px-5">
<div class="flex items-center gap-2">
<h1 class="dark:text-white text-2xl md:text-3xl font-bold">Get Started</h1>
<Chip
value={`${completed}/${setupList.length}`}
className="text-[10px] font-semibold px-3 !py-1"
shape="rounded-full"
/>
</div>
<section class="px-4">
{#each setupList as list, i}
<div
class="flex flex-col lg:flex-row lg:items-center justify-between gap-2 w-full py-8 border-b border-gray-300"
>
<div class={`space-y-2 flex-1 ${list.is_completed ? 'opacity-50' : ''} max-w-[50%]`}>
<div class="flex items-center gap-2">
<Chip
value={i + 1}
className={`text-[10px] font-semibold !py-1 `}
shape="rounded-full"
/>
<p class="font-medium text-lg">{list.title}</p>
</div>
<p class={`text-sm`}>{list.desc}</p>
</div>
<div class="w-[30%]">
<PrimaryButton
variant={list.is_completed ? VARIANTS.CONTAINED_DARK : VARIANTS.OUTLINED}
className="!w-full font-normal text-sm flex items-center gap-2"
onClick={() => handleClick(list.title)}
isDisabled={list.is_completed}
>
{#if list.is_completed}
<CheckmarkOutline />
{/if}
{list.button_label}
</PrimaryButton>
</div>
</div>
{/each}
</section>
</div>
</section>
@@ -0,0 +1,146 @@
import { getSupabase } from '$lib/utils/functions/supabase';
import get from 'lodash/get';
const supabase = getSupabase();
async function getGenericData(orgSlug: string): Promise<{
isCoursePublished: boolean;
isCourseCreated: boolean;
orgHasAvatarUrl: boolean;
courseData: any;
}> {
const { data } = await supabase
.from('course')
.select(
`
id,
is_published,
group:group_id!inner(
organization!inner(
avatar_url,
siteName
)
)
`
)
.eq('group.organization.siteName', orgSlug);
const result = data || [];
console.log('courseData', result);
return {
isCoursePublished: result.some((c) => c.is_published === true),
isCourseCreated: result.length > 0,
orgHasAvatarUrl: !!get(result[0], 'group.organization.avatar_url', ''),
courseData: result
};
}
async function getIsLessonCreated(
orgSlug: string
): Promise<{ isLessonCreated: boolean; lessonData: any }> {
const { data } = await supabase
.from('lesson')
.select(
`
id,
course:course_id!inner(
group:group_id!inner(
organization!inner(
siteName
)
)
)
`
)
.eq('course.group.organization.siteName', orgSlug);
console.log('lessonsData', data);
return {
isLessonCreated: Array.isArray(data) ? data.length > 0 : false,
lessonData: data
};
}
async function getIsExerciseCreated(orgSlug: string): Promise<boolean> {
const { data } = await supabase
.from('exercise')
.select(
`
id,
lesson:lesson_id!inner(
course:course_id!inner(
group:group_id!inner(
organization!inner(
siteName
)
)
)
)
`
)
.eq('lesson.course.group.organization.siteName', orgSlug);
return Array.isArray(data) ? data?.length > 0 : false;
}
export const load = async ({ params = { slug: '' } }) => {
const { isCourseCreated, isCoursePublished, orgHasAvatarUrl, courseData } = await getGenericData(
params.slug
);
const { isLessonCreated, lessonData } = await getIsLessonCreated(params.slug);
const isExerciseCreated = await getIsExerciseCreated(params.slug);
const data = [
{
id: 'profile',
title: 'Upload a profile picture and update username',
desc: 'Personalize and a human touch making interactions more personal and memorable',
is_completed: false,
button_label: 'Update Profile'
},
{
id: 'organization',
title: 'Update organisation profile picture',
desc: 'Establish a professional and recognizable identity for your organization',
is_completed: orgHasAvatarUrl,
button_label: 'Update Org Profile'
},
{
id: 'course',
title: 'Create Course',
desc: 'Create a course that you will share with your students',
is_completed: isCourseCreated,
button_label: 'Create Course'
},
{
id: 'lesson',
title: 'Create a lesson',
desc: 'Break your course into lesson that your students can easily understand',
is_completed: isLessonCreated,
button_label: 'Create Lesson'
},
{
id: 'exercise',
title: 'Create an exercise',
desc: 'Test your students allow them to demonstarte their understanding of the subject matter',
is_completed: isExerciseCreated,
button_label: 'Create Assignment'
},
{
id: 'publish',
title: 'Publish a course',
desc: 'Make your course public and purchaseable ',
is_completed: isCoursePublished,
button_label: 'Publish Course'
}
];
return {
orgSiteName: params.slug,
setup: data,
courses: courseData,
lessons: lessonData
};
};