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

* revamp setup

* improve the upgrade experience

* migration

* feat: add lemonsqueezy customer portal

---------

Co-authored-by: emmanuel <chifez1@gmail.com>
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
@@ -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>