feat: redo settings and all org pages. (#601)

* feat: new components, deploy storybook

* refactor: simplify component field definitions and enhance button loading state

- Removed unnecessary type imports and simplified the FIELDS array definitions across multiple components in Storybook.
- Updated button component to use a loading state instead of isLoading, improving clarity and consistency in props.
- Adjusted button stories to reflect the new loading prop and enhanced the button's loading visual feedback.
- Minor CSS adjustments for better layout and styling consistency.

* chore: add field, card, password components

* feat: improve page component & update AuthUI path

* chore: update bits-ui to version 2.14.4 and all org related UI's

- Updated bits-ui dependency in pnpm-lock.yaml and package.json to version 2.14.4.
- Refactored imports for RoleBasedSecurity and UpgradeBanner components to use the new ui feature directory.
- Removed unused Course components and related files to clean up the codebase.
- Enhanced various components to improve consistency and maintainability.

* chore: move all setting buttons to the top and update auth ui styles

* chore: remove svelte-loading-spinners and replace with @cio/ui/base/spinner

- Removed all instances of svelte-loading-spinners from the project.
- Updated components to use the new Spinner from @cio/ui/base/spinner.
- Cleaned up pnpm-lock.yaml and package.json to reflect the removal of svelte-loading-spinners.
- Added new CSS animations for rocket launch effects in app.css.
- Enhanced email change flow with updated verification messages in translations.

* chore: remove supabase file upload from dashboard and move to r2

* chore: update organization API and UI components

- Added new API methods for organization plan operations and team management.
- Introduced middleware for API key authentication and organization admin checks.
- Refactored organization-related components to utilize the new API structure.
- Updated various UI components to improve consistency and maintainability.
- Enhanced error handling and validation for organization-related actions.
- Removed deprecated services and streamlined the codebase.
This commit is contained in:
Rotimi Best
2025-12-02 09:54:38 +01:00
committed by GitHub
parent 531516a8f7
commit c7854efae3
350 changed files with 13928 additions and 3895 deletions
@@ -1,124 +0,0 @@
<script lang="ts">
import { preventDefault } from '$lib/utils/functions/svelte';
import { page } from '$app/state';
import Avatar from '$lib/components/Avatar/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { t } from '$lib/utils/functions/translations';
import { currentOrg } from '$lib/utils/store/org';
import GoogleIconColored from '../Icons/GoogleIconColored.svelte';
import { authClient } from '$lib/utils/services/auth/client';
interface Props {
handleSubmit?: any;
isLogin?: boolean;
showOnlyContent?: boolean;
isLoading?: boolean;
showLogo?: boolean;
formRef?: any;
hideGoogleAuth?: boolean;
redirectPathname?: string;
children?: import('svelte').Snippet;
}
let {
handleSubmit = () => {},
isLogin = true,
showOnlyContent = false,
isLoading = false,
showLogo = false,
formRef = $bindable(),
hideGoogleAuth = false,
redirectPathname = '',
children
}: Props = $props();
async function signInWithGoogle() {
if (isLoading) {
return;
}
const params = new URLSearchParams(window.location.search);
console.log({ params });
// const redirectTo = `https://app.classroomio.com?forwardTo=${
// window.location.origin + params.get('redirect')
// }`;
const pathname = redirectPathname || params.get('redirect') || '';
const redirectTo = `${window.location.origin + pathname}`;
console.log({ redirectTo });
try {
console.log('signInWithGoogle');
const data = await authClient.signIn.social({
provider: 'google',
callbackURL: redirectTo
});
console.log('data', data);
} catch (error) {
console.log('catch error', error);
}
}
</script>
<div class="app-background flex min-h-screen w-full items-center justify-center">
<div class="border-gray container border bg-white dark:bg-black">
<div class="flex flex-col items-center p-2 lg:px-8 lg:py-3">
{#if !showOnlyContent || showLogo}
<div class="flex w-full flex-col items-center justify-center pt-2">
<Avatar
src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : '/logo-192.png'}
name={$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}
shape="rounded-md"
width="w-10"
height="max-h-10"
className="mr-2"
/>
<a href="/">
<h4 class="mt-0 text-xl dark:text-white">
{$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}
</h4>
</a>
</div>
{/if}
<form bind:this={formRef} onsubmit={preventDefault(handleSubmit)} class="flex w-10/12 flex-col items-center">
{@render children?.()}
</form>
{#if !showOnlyContent && !hideGoogleAuth}
<div class="mb-3 w-10/12">
<p class="mb-5 text-sm dark:text-white">{$t('login.signup_with')}:</p>
<PrimaryButton
variant={VARIANTS.OUTLINED}
onClick={signInWithGoogle}
isDisabled={isLoading}
className="py-3 sm:w-full w-full"
>
<GoogleIconColored />
<span class="ml-2">
{isLogin ? $t('login.login_with_google') : $t('login.signup_with_google')}
</span>
</PrimaryButton>
</div>
{/if}
</div>
{#if !showOnlyContent}
<div class="border-grey w-full border-t p-6 text-center">
{#if isLogin}
{$t('login.not_registered_yet')}
<a class="text-primary-700 hover:underline" href="/signup{page.url.search}">{$t('login.signup')}</a>
{:else}
{$t('login.already_have_account')}
<a class="text-primary-700 hover:underline" href="/login{page.url.search}">{$t('login.login')}</a>
{/if}
</div>
{/if}
</div>
</div>
<style>
.container {
width: 450px;
}
</style>
@@ -1,6 +1,6 @@
<script lang="ts">
import XIcon from '@lucide/svelte/icons/x';
import { Circle } from 'svelte-loading-spinners';
import { Spinner } from '@cio/ui/base/spinner';
import IconButton, { type Props as IconButtonProps } from '$lib/components/IconButton/index.svelte';
interface Props {
@@ -23,7 +23,7 @@
<IconButton {onClick} {color} {contained} {size} disabled={isClosing}>
{#if isClosing}
<Circle size="20" color="#1d4ee2" unit="px" duration="1s" />
<Spinner class="text-blue-700! size-5" />
{:else}
<XIcon size={size === 'large' ? 24 : 16} class="dark:text-black" />
{/if}
@@ -1,7 +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 { Button } from '@cio/ui/base/button';
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import { currentOrgDomain } from '$lib/utils/store/org';
import { isMobile } from '$lib/utils/store/useMobile';
import { t } from '$lib/utils/functions/translations';
@@ -18,7 +17,7 @@
</script>
<a {href} target="_blank" class="{className} ml-2 hover:no-underline">
<PrimaryButton variant={VARIANTS.CONTAINED_DARK}>
<Button variant="outline">
{#if !$isMobile}
{#if isLMS}
{$t('dashboard.visit_site')}
@@ -27,5 +26,5 @@
{/if}
{/if}
<ArrowUpRightIcon />
</PrimaryButton>
</Button>
</a>
@@ -16,7 +16,7 @@
import Plain from './templates/Plain.svelte';
import Professional from './templates/Professional.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import { UpgradeBanner } from '$lib/features/ui';
import BlueBadgePattern from './templates/BlueBadgePattern.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import PurpleBadgePattern from './templates/PurpleBadgePattern.svelte';
@@ -10,7 +10,7 @@
import TextareaQuestion from '$lib/components/Question/TextareaQuestion/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import Box from '$lib/components/Box/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import Progress from '$lib/components/Progress/index.svelte';
import { removeDuplicate } from '$lib/utils/functions/removeDuplicate';
import { QUESTION_TYPE } from '$lib/components/Question/constants';
@@ -29,7 +29,7 @@
import { IconButton } from '$lib/components/IconButton';
import UpdateDescription from './UpdateDescription.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
interface Props {
exerciseId?: string;
@@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount, untrack } from 'svelte';
import { Circle } from 'svelte-loading-spinners';
import { Spinner } from '@cio/ui/base/spinner';
import { lesson } from '../store/lessons';
import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
@@ -15,11 +15,11 @@
import { PageBody } from '$lib/components/Page';
import Exercise from '../Exercise/index.svelte';
import Box from '$lib/components/Box/index.svelte';
import Backdrop from '$lib/components/Backdrop/index.svelte';
import * as Empty from '@cio/ui/base/empty';
import { QUESTION_TYPES } from '$lib/components/Question/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import NewExerciseModal from '$lib/components/Course/components/Lesson/Exercises/NewExerciseModal.svelte';
interface Props {
@@ -160,7 +160,7 @@
{#if isFetching}
<Backdrop>
<Circle size="60" color="#1d4ed8" unit="px" duration="1s" />
<Spinner class="size-14! text-blue-700!" />
</Backdrop>
{/if}
@@ -204,22 +204,24 @@
<p class="mt-4 text-sm dark:text-white">{formatDate(exercise.created_at)}</p>
</a>
{:else}
<Box className="mt-3 text-center">
<div class="flex justify-between flex-col items-center w-[80%] md:w-96">
<img src="/images/empty-exercise-icon.svg" alt="Exercise" class="my-2.5 mx-auto" />
<h2 class="text-xl my-1.5 font-normal">
<Empty.Root class="mt-3">
<Empty.Header>
<Empty.Media variant="icon">
<img src="/images/empty-exercise-icon.svg" alt="Exercise" class="size-6" />
</Empty.Media>
<Empty.Title>
{$t('course.navItem.lessons.exercises.no_exercises')}
</h2>
<p class="text-sm text-center text-slate-500">
</Empty.Title>
<Empty.Description>
{#if $globalStore.isStudent}
{$t('course.navItem.lessons.exercises.no_assigned_exercises')}
<strong> {$t('course.navItem.lessons.exercises.chill')} :)</strong>
{:else}
{$t('course.navItem.lessons.exercises.add_exercise')}
{/if}
</p>
</div>
</Box>
</Empty.Description>
</Empty.Header>
</Empty.Root>
{/each}
</div>
</PageBody>
@@ -6,10 +6,10 @@
import ListChecksIcon from '@lucide/svelte/icons/list-checks';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import Box from '$lib/components/Box/index.svelte';
import TextChip from '$lib/components/Chip/Text.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import * as Empty from '@cio/ui/base/empty';
import { lessons, handleSaveLesson } from '$lib/components/Course/components/Lesson/store/lessons';
import { globalStore } from '$lib/utils/store/app';
@@ -188,14 +188,16 @@
</div>
</div>
{:else}
<Box>
<div class="flex justify-between flex-col items-center w-[90%] md:w-96">
<img src="/images/empty-lesson-icon.svg" alt="Lesson" class="my-2.5 mx-auto" />
<h2 class="text-xl my-1.5 font-normal">{$t('course.navItem.lessons.body_header')}</h2>
<p class="text-sm text-center text-slate-500">
<Empty.Root>
<Empty.Header>
<Empty.Media variant="icon">
<img src="/images/empty-lesson-icon.svg" alt="Lesson" class="size-6" />
</Empty.Media>
<Empty.Title>{$t('course.navItem.lessons.body_header')}</Empty.Title>
<Empty.Description>
{$t('course.navItem.lessons.body_content')}
</p>
</div>
</Box>
</Empty.Description>
</Empty.Header>
</Empty.Root>
{/each}
</section>
@@ -27,7 +27,7 @@
import { updateLesson, updateLessonSection } from '$lib/utils/services/courses';
import type { Lesson } from '$lib/utils/types';
import DeleteLessonConfirmation from '$lib/components/Course/components/Lesson/DeleteLessonConfirmation.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
type CrudParam = {
sectionId?: string;
@@ -7,7 +7,7 @@
import FileTextIcon from '@lucide/svelte/icons/file-text';
import { DocumentUploader } from '$lib/utils/services/courses/presign';
import { onDestroy, untrack } from 'svelte';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import { UpgradeBanner } from '$lib/features/ui';
import { isFreePlan } from '$lib/utils/store/org';
import { lesson } from '../../store/lessons';
import { CloseButton } from '$lib/components/Buttons/Close';
@@ -1,7 +1,7 @@
<script lang="ts">
import { Progress } from '@cio/ui/base/progress';
import { preventDefault } from '$lib/utils/functions/svelte';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import { UpgradeBanner } from '$lib/features/ui';
import { lesson, lessonVideoUpload, cancelVideoUpload } from '$lib/components/Course/components/Lesson/store/lessons';
import { isFreePlan } from '$lib/utils/store/org';
@@ -17,7 +17,7 @@
import Chip from '$lib/components/Chip/index.svelte';
import DeleteFeedConfirmation from './DeleteFeedConfirmation.svelte';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
interface Props {
feed: Feed;
@@ -13,7 +13,7 @@
import { qrInviteNodeStore } from './store';
import { ROLE } from '@cio/utils/constants';
import { getOrgTeam } from '$lib/utils/services/org';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import type { OrgTeamMember } from '$lib/utils/types/org';
@@ -37,10 +37,8 @@
let tutors: Tutor[] = $state([]);
let selectedIds: string[] = $state([]);
let isLoadingTutors = $state(false);
const selectedTutors = $derived(tutors.filter((tutor) => selectedIds.includes(tutor.id.toString())));
let copied = $state(false);
let qrImage = $state('');
let isLoadingQRDownload = $state(false);
@@ -108,18 +106,13 @@
if (!orgId) return;
untrack(async () => {
isLoadingTutors = true;
const { team, error } = await getOrgTeam(orgId);
if (error) {
console.error('Error fetching teams', error);
isLoadingTutors = false;
await orgApi.getOrgTeam(orgId);
if (orgApi.error) {
console.error('Error fetching teams', orgApi.error);
return;
}
tutors = getTutors(team);
isLoadingTutors = false;
tutors = getTutors(orgApi.teamMembers);
});
}
@@ -132,10 +125,6 @@
const link = getStudentInviteLink($course, $currentOrg.siteName, $currentOrgDomain);
copy(link);
copied = true;
setTimeout(() => {
copied = false;
}, 1000);
}
function closeModal() {
@@ -197,7 +186,7 @@
<form onsubmit={preventDefault(onSubmit)}>
<div class="mb-8">
<p class="mb-1 text-base font-semibold">{$t('course.navItem.people.invite_modal.invite')}</p>
<Select.Root type="multiple" bind:value={selectedIds} disabled={isLoadingTutors}>
<Select.Root type="multiple" bind:value={selectedIds} disabled={orgApi.isLoading}>
<Select.Trigger class="w-full">
<div>
{#if selectedTutors.length > 0}
@@ -231,7 +220,7 @@
{/each}
</Select.Content>
</Select.Root>
{#if isLoadingTutors}
{#if orgApi.isLoading}
<div class="mt-2 flex items-center gap-2">
<Spinner class="h-4 w-4" />
<span class="text-sm">Loading...</span>
@@ -287,7 +276,7 @@
</div>
</div>
<div class="absolute left-[-1000px] w-[40rem]">
<div class="w-160 absolute left-[-1000px]">
<ShareQrImage {qrImage} course={$course} currentOrg={$currentOrg} />
</div>
@@ -1,6 +1,6 @@
<script lang="ts">
import type { CurrentOrg } from '$lib/utils/types/org.ts';
import type { Course } from '$lib/utils/types/index.ts';
import type { AccountOrg } from '$lib/features/app/types';
import type { Course } from '$lib/utils/types';
import { isFreePlan } from '$lib/utils/store/org';
import { qrInviteNodeStore } from './store';
@@ -12,7 +12,7 @@
interface Props {
qrImage: string;
currentOrg: CurrentOrg;
currentOrg: AccountOrg;
course: Course;
}
@@ -22,7 +22,7 @@
<div
bind:this={node}
id="qr-container"
class="flex h-[60rem] w-[40rem] flex-col items-center justify-center rounded-xl bg-blue-900 p-10 pb-20"
class="h-160 w-160 flex flex-col items-center justify-center rounded-xl bg-blue-900 p-10 pb-20"
>
<div class="rounded-3xl bg-white p-6 pb-3 text-center">
<div class="my-4 rounded-xl bg-gray-100 p-2 text-xl">Scan QR</div>
@@ -13,7 +13,7 @@
import TextField from '$lib/components/Form/TextField.svelte';
import { Row, Grid, Column } from '$lib/components/Org/Settings/Layout';
import SectionTitle from '$lib/components/Org/SectionTitle.svelte';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import { UpgradeBanner } from '$lib/features/ui';
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
@@ -1,7 +1,7 @@
<script lang="ts">
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { Circle } from 'svelte-loading-spinners';
import { Spinner } from '@cio/ui/base/spinner';
import Navigation from '../Course/components/Navigation/index.svelte';
import Backdrop from '$lib/components/Backdrop/index.svelte';
import { course, group, courseStore } from '../Course/store';
@@ -65,7 +65,7 @@
{#if isFetching}
<Backdrop>
<Circle size="60" color="#1d4ed8" unit="px" duration="1s" />
<Spinner class="size-14! text-blue-700!" />
</Backdrop>
{/if}
@@ -7,7 +7,7 @@
import type { Course } from '$lib/utils/types';
import { NAV_ITEM_KEY } from '../../constants';
import { t } from '$lib/utils/functions/translations';
import { uploadAvatar } from '$lib/utils/services/courses';
import { uploadImage } from '$lib/utils/services/upload';
import UploadImage from '$lib/components/UploadImage/index.svelte';
@@ -18,18 +18,18 @@
let { course = $bindable(), setter }: Props = $props();
let avatar: string | undefined = $state();
let avatar: File | undefined = $state();
let templateUrl = $derived(get(course, 'metadata.certificate.templateUrl', '/images/certificate-template.svg'));
let isUploading = $state(false);
let show = $derived(get(course, `metadata.sectionDisplay.${NAV_ITEM_KEY.CERTIFICATE}`) ?? true);
async function onTemplateChange(_avatar: string | undefined) {
async function onTemplateChange(_avatar: File | undefined) {
untrack(async () => {
if (!_avatar || !course.id) return;
isUploading = true;
const logo = await uploadAvatar(course.id, _avatar);
const logo = await uploadImage(_avatar);
if (logo) {
templateUrl = logo;
@@ -3,7 +3,7 @@
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import { uploadAvatar } from '$lib/utils/services/courses';
import { uploadImage } from '$lib/utils/services/upload';
import type { Course } from '$lib/utils/types';
import { t } from '$lib/utils/functions/translations';
@@ -19,13 +19,14 @@
let imgUrl = $state(get(course, 'metadata.instructor.imgUrl'));
let description = $state(get(course, 'metadata.instructor.description'));
let courseNo = $state(get(course, 'metadata.instructor.courseNo'));
let avatar: string | undefined = $state();
let avatar: File | undefined = $state();
let isUploading = $state(false);
async function onAvatarChange(_avatar: string | undefined) {
async function onAvatarChange(_avatar: File | undefined) {
if (!_avatar || !course.id) return;
isUploading = true;
const logo = await uploadAvatar(course.id, _avatar);
const logo = await uploadImage(_avatar);
if (!logo) return;
@@ -5,26 +5,26 @@
import TrashIcon from '@lucide/svelte/icons/trash';
import { t } from '$lib/utils/functions/translations';
import { uploadAvatar } from '$lib/utils/services/courses';
import { uploadImage } from '$lib/utils/services/upload';
import { IconButton } from '$lib/components/IconButton';
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import UploadImage from '$lib/components/UploadImage/index.svelte';
let { courseId, reviews = $bindable([]), review = $bindable({}), errors = {}, onExpand = () => {} } = $props();
let { reviews = $bindable([]), review = $bindable({}), errors = {}, onExpand = () => {} } = $props();
let avatar = $state<string | undefined>();
let avatar = $state<File | undefined>();
// function to delete review
function deleteReviewData() {
reviews = reviews.filter((r) => r.id !== review.id);
}
async function onAvatarChange(_avatar) {
async function onAvatarChange(_avatar: File | undefined) {
if (!_avatar) return;
review.avatar_url = await uploadAvatar(courseId, _avatar);
review.avatar_url = await uploadImage(_avatar);
}
$effect(() => {
@@ -23,7 +23,7 @@
import ImageRenderer from '../Org/ImageRenderer.svelte';
import Avatar from '$lib/components/Avatar/index.svelte';
import PricingSection from './components/PricingSection.svelte';
import PoweredBy from '$lib/components/Upgrade/PoweredBy.svelte';
import { PoweredBy } from '$lib/features/ui';
import SectionsDisplay from './components/SectionsDisplay.svelte';
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
@@ -1,23 +0,0 @@
<script lang="ts">
import { Skeleton } from '@cio/ui/base/skeleton';
</script>
<div class="mb-4 mr-4 h-[370px] w-72 rounded-md border bg-gray-100 p-2 transition ease-in-out dark:bg-black">
<Skeleton class="h-[45%] w-full" />
<div class="flex h-[50%] flex-col justify-between pb-2 pt-4">
<!-- Title -->
<Skeleton class="h-4 w-2/4" />
<!-- Description -->
<div class="mb-6 mt-4 flex flex-col gap-2">
<Skeleton class=" h-4 w-full" />
<Skeleton class=" h-4 w-full" />
<Skeleton class=" h-4 w-full" />
</div>
<div class="flex justify-between">
<Skeleton class="h-4 w-1/4" />
<Skeleton class="h-4 w-1/4" />
</div>
</div>
</div>
@@ -1,263 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Badge } from '@cio/ui/base/badge';
import UserIcon from '@lucide/svelte/icons/user';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import CircleDotIcon from '@lucide/svelte/icons/circle-dot';
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import ImageRenderer from '$lib/components/Org/ImageRenderer.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { COURSE_TYPE } from '$lib/utils/types';
import { t } from '$lib/utils/functions/translations';
import { calcCourseDiscount } from '$lib/utils/functions/course';
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
import { copyCourseModal, deleteCourseModal } from '$lib/components/Courses/store';
interface Props {
bannerImage: string | undefined;
id?: string;
slug?: string;
title?: string;
description?: string;
isPublished?: boolean;
totalLessons?: number;
totalStudents?: number;
currency?: string;
isOnLandingPage?: boolean;
isLMS?: boolean;
isExplore?: boolean;
progressRate?: number;
type: COURSE_TYPE;
pricingData?: {
cost: number;
currency?: string;
showDiscount?: boolean;
discount?: number;
};
}
let {
bannerImage,
id = '',
slug = '',
title = '',
description = '',
isPublished = false,
totalLessons = 0,
totalStudents = 0,
currency = 'USD',
isOnLandingPage = false,
isLMS = false,
isExplore = false,
progressRate = 45,
type,
pricingData = {
cost: 0
}
}: Props = $props();
let formatter = $derived(getCurrencyFormatter(currency));
function handleCloneCourse() {
$copyCourseModal.open = true;
$copyCourseModal.id = id;
$copyCourseModal.title = title;
$copyCourseModal.description = description;
}
function handleShareCourse() {
goto(`/courses/${id}/settings#share`);
}
function handleInvite() {
goto(`/courses/${id}/people?add=true`);
}
function handleDeleteCourse() {
$deleteCourseModal.open = true;
$deleteCourseModal.id = id;
$deleteCourseModal.title = title;
}
const COURSE_TAG: Record<
string,
{
style: string;
label: string;
icon: any;
iconStyle?: string;
}
> = {
[COURSE_TYPE.LIVE_CLASS]: {
style: '',
label: $t('course.navItem.settings.live_class'),
icon: CircleDotIcon,
iconStyle: 'custom text-red-700'
},
[COURSE_TYPE.SELF_PACED]: {
style: '',
label: $t('course.navItem.settings.self_paced'),
icon: UserIcon,
iconStyle: 'custom text-primary-700'
},
SPECIALIZATION: {
style: '',
label: $t('specialization.course_tag'),
icon: TrendingUpIcon
}
};
let cost = $derived(calcCourseDiscount(pricingData.discount, pricingData.cost ?? 0, !!pricingData.showDiscount));
let courseUrl = $derived(
isOnLandingPage || isExplore ? `/course/${slug}` : `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`
);
</script>
<div
role="button"
tabindex="0"
onclick={() => {
goto(courseUrl);
}}
onkeydown={(e) => {
if (e.key === 'Enter') {
goto(courseUrl);
}
}}
class="border-gray group relative w-full max-w-[320px] rounded border text-black dark:border-neutral-600"
>
<div class="p-4">
<div class="relative mb-5">
{#if !isLMS && !isOnLandingPage}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="absolute right-2 top-2 z-40 flex items-center justify-center rounded-full bg-gray-200 p-2 opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100 dark:bg-neutral-800"
onclick={(e) => e.stopPropagation()}
>
<EllipsisVerticalIcon size={16} />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={handleCloneCourse}>
{$t('courses.course_card.context_menu.clone')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleShareCourse}>
{$t('courses.course_card.context_menu.share')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleInvite}>
{$t('courses.course_card.context_menu.invite')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item class="text-red-600" onclick={handleDeleteCourse}>
{$t('courses.course_card.context_menu.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
<ImageRenderer
src={bannerImage}
alt="Banner Image"
className="relative h-[200px] w-full rounded dark:border dark:border-neutral-600"
/>
{#if type}
{@const tag = COURSE_TAG[type]}
<span
class="bg-primary-50 absolute bottom-2 left-2 z-10 flex items-center gap-1 rounded-sm p-1 font-mono text-xs capitalize"
>
<tag.icon class={tag.iconStyle} />
{tag.label}
</span>
{/if}
</div>
<h3 class="title text-xl dark:text-white">{title}</h3>
<p class="description mt-2 text-sm text-gray-500 dark:text-gray-300">
{description}
</p>
</div>
<div
class="border-gray flex justify-between border border-b-0 border-l-0 border-r-0 px-4 py-2 dark:border-neutral-600 {isLMS &&
'items-center'}"
>
<div>
<p class="text-xs {!isLMS && 'pl-2'} dark:text-white">
{totalLessons}
{$t('courses.course_card.lessons_number')}
</p>
<p class="py-2 text-xs">
{#if isOnLandingPage}
<span class="px-2">
{#if !cost}
{$t('course.navItem.landing_page.pricing_section.free')}
{:else if pricingData.showDiscount}
{formatter.format(cost)}
<span class="line-through">
{formatter?.format(pricingData?.cost)}
</span>
{:else}
{formatter.format(cost)}
{/if}
</span>
{:else if isLMS}
{#if !isExplore}
<div class="flex items-center gap-2">
<div class=" relative h-1 w-[50px] bg-[#EAEAEA]">
<div style="width:{progressRate}%" class="bg-primary-700 absolute left-0 top-0 h-full"></div>
</div>
<p class="text-xs text-[#656565] dark:text-white">{progressRate}%</p>
</div>
{/if}
{:else}
<Badge variant={isPublished ? 'default' : 'secondary'}>
{#if isPublished}
{$t('courses.course_card.published')}
{:else}
{$t('courses.course_card.unpublished')}
{/if}
</Badge>
{/if}
</p>
</div>
{#if isLMS}
<PrimaryButton
label={isExplore ? $t('courses.course_card.learn_more') : $t('courses.course_card.continue_course')}
variant={VARIANTS.OUTLINED}
className="rounded-none"
/>
{:else if !isOnLandingPage}
<div class="flex flex-col justify-between">
<p class="pl-2 text-xs dark:text-white">
{totalStudents}
{$t('courses.course_card.students')}
</p>
<div></div>
</div>
{/if}
</div>
</div>
<style>
.title,
.description {
height: 42px;
line-height: 20px;
display: -webkit-box;
-webkit-box-orient: vertical;
-moz-box-orient: vertical;
-ms-box-orient: vertical;
box-orient: vertical;
-webkit-line-clamp: 2;
-moz-line-clamp: 2;
-ms-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
</style>
@@ -1,98 +0,0 @@
<script lang="ts">
import { preventDefault } from '$lib/utils/functions/svelte';
import { goto } from '$app/navigation';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import Modal from '$lib/components/Modal/index.svelte';
import { snackbar } from '$lib/components/Snackbar/store';
import TextField from '$lib/components/Form/TextField.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import { copyCourseModalInitialState, copyCourseModal } from '$lib/components/Courses/store';
import { classroomio } from '$lib/utils/services/api';
import generateSlug from '$lib/utils/functions/generateSlug';
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
// clone, show spinner and redirect to new course
async function createCourse() {
// Prevent double submission (Option 1: Early Return Guard)
if ($copyCourseModal.isSaving) return;
console.log('Create course');
$copyCourseModal.isSaving = true;
try {
// Generate unique slug from the title
const slug = generateSlug($copyCourseModal.title);
const response = await classroomio.course.clone.$post({
json: {
id: $copyCourseModal.id,
title: $copyCourseModal.title,
description: $copyCourseModal.description,
slug,
organizationId: $currentOrg.id
}
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || $t('courses.copy_course.error.failed_to_clone'));
}
// Option 3: Close modal and reset state before navigation
copyCourseModal.set(copyCourseModalInitialState);
// Navigate to the new course
goto(`/courses/${result.course.id}`);
$copyCourseModal.open = false;
} catch (error) {
console.error(error);
// IIFI of snackbar to Show notification error
(() => {
snackbar.error();
})();
// Stop loader
$copyCourseModal.isSaving = false;
}
}
</script>
<Modal
onClose={() => ($copyCourseModal.open = false)}
bind:open={$copyCourseModal.open}
width="w-96"
modalHeading={$t('courses.copy_course.title')}
>
<form onsubmit={preventDefault(createCourse)}>
<TextField
label={$t('courses.copy_course.course_name_label')}
bind:value={$copyCourseModal.title}
autoFocus={true}
placeholder={$t('courses.copy_course.course_name_placeholder')}
className="mb-4"
isRequired={true}
autoComplete={false}
/>
<TextArea
label={$t('courses.copy_course.course_description_label')}
bind:value={$copyCourseModal.description}
placeholder={$t('courses.copy_course.course_description_placeholder')}
className="mb-4"
rows={4}
/>
<div class="mt-5 flex flex-row-reverse items-center">
<PrimaryButton
className="px-6 py-3"
label={$t('courses.copy_course.create_button')}
type="submit"
isLoading={$copyCourseModal.isSaving}
/>
</div>
</form>
</Modal>
@@ -1,7 +0,0 @@
<script>
import { Skeleton } from '@cio/ui/base/skeleton';
</script>
<div class="my-2">
<Skeleton class="h-4 w-full" />
</div>
@@ -1,100 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Badge } from '@cio/ui/base/badge';
import * as Table from '@cio/ui/base/table';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { isMobile } from '$lib/utils/store/useMobile';
import { t } from '$lib/utils/functions/translations';
interface Props {
id?: string;
title?: string;
type?: string;
description?: string;
isPublished?: boolean;
totalLessons?: number;
totalStudents?: number;
}
let {
id = '',
title = '',
type = '',
description = '',
isPublished = false,
totalLessons = 0,
totalStudents = 0
}: Props = $props();
function handleCloneCourse(e) {
e.stopPropagation();
// TODO: Clone course functionality
alert('WIP: Clone course');
}
function handleShareCourse(e) {
e.stopPropagation();
// TODO: Share course functionality
alert('WIP: Share course');
}
function handleInvite(e) {
e.stopPropagation();
// TODO: Invite functionality
alert('WIP: Invite people to course');
}
function handleDeleteCourse(e) {
e.stopPropagation();
// TODO: Delete course functionality
alert('WIP: Delete course');
}
</script>
<Table.Row class="cursor-pointer" onclick={() => goto(`/courses/${id}`)}>
<Table.Cell class="truncate"><p class="font-semibold">{title}</p></Table.Cell>
<Table.Cell class="truncate">
<p>{description}</p>
</Table.Cell>
{#if !$isMobile}
<Table.Cell class="truncate">{type}</Table.Cell>
<Table.Cell>{totalLessons}</Table.Cell>
<Table.Cell>{totalStudents}</Table.Cell>
<Table.Cell>
<Badge variant={isPublished ? 'default' : 'secondary'}>
{#if isPublished}
{$t('courses.course_card.published')}
{:else}
{$t('courses.course_card.unpublished')}
{/if}
</Badge>
</Table.Cell>
{/if}
<Table.Cell class="text-center">
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="flex items-center justify-center rounded-md p-1 hover:bg-gray-100"
onclick={(e) => e.stopPropagation()}
>
<EllipsisVerticalIcon size={16} />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={handleCloneCourse}>
{$t('courses.course_card.context_menu.clone')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleShareCourse}>
{$t('courses.course_card.context_menu.share')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleInvite}>
{$t('courses.course_card.context_menu.invite')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item class="text-red-600" onclick={handleDeleteCourse}>
{$t('courses.course_card.context_menu.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
@@ -1,234 +0,0 @@
<script lang="ts">
import { preventDefault } from '$lib/utils/functions/svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { ComingSoon } from '$lib/features/ui';
import { validateForm } from '$lib/components/Courses/functions';
import { courses, createCourseModal } from '$lib/components/Courses/store';
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import Modal from '$lib/components/Modal/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { ROLE } from '@cio/utils/constants';
import { supabase } from '$lib/utils/functions/supabase';
import { t } from '$lib/utils/functions/translations';
import { addDefaultNewsFeed, addGroupMember } from '$lib/utils/services/courses';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { COURSE_TYPE, COURSE_VERSION } from '$lib/utils/types';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
let isLoading = $state(false);
let errors = $state({
title: '',
description: ''
});
let step = $state(0);
const options = [
{
title: $t('new_course_modal.live_class_label'),
subtitle: $t('new_course_modal.live_class_subtitle'),
type: COURSE_TYPE.LIVE_CLASS,
isDisabled: false
},
{
title: $t('new_course_modal.self_paced_label'),
subtitle: $t('new_course_modal.self_paced_subtitle'),
type: COURSE_TYPE.SELF_PACED,
isDisabled: false
}
];
let type = $state(options[0].type);
function onClose(redirectTo) {
goto(redirectTo);
createCourseModal.update(() => ({
title: '',
description: '',
type: '',
emails: '',
tutors: '',
students: ''
}));
}
async function createCourse() {
isLoading = true;
const { hasError, fieldErrors } = validateForm($createCourseModal);
errors = fieldErrors;
if (hasError) return;
const { title, description } = $createCourseModal;
// 1. Create group
const { data: newGroup } = await supabase
.from('group')
.insert({ name: title, description, organization_id: $currentOrg.id })
.select();
console.log(`newGroup`, newGroup);
if (!newGroup) return;
const { id: group_id } = newGroup[0];
// 2. Create course with group_id
const { data: newCourseData } = await supabase
.from('course')
.insert({
title,
description,
type: type,
version: COURSE_VERSION.V2,
group_id
})
.select();
console.log(`newCourse data`, newCourseData);
if (!newCourseData) return;
const newCourse = newCourseData[0];
courses.update((_courses) => [..._courses, newCourse]);
capturePosthogEvent('course_created', {
course_id: newCourse.id,
course_title: newCourse.title,
course_description: newCourse.description,
organization_id: $currentOrg.id,
organization_name: $currentOrg.name,
user_id: $profile.id,
user_email: $profile.email
});
// 3. Add group members
const { data } = await addGroupMember({
profile_id: $profile.id,
email: $profile.email,
group_id,
role_id: ROLE.TUTOR
});
// 4. Add default news feed.
if (Array.isArray(data) && data.length) {
const { id: authorId } = data[0];
console.log('Add news feed into course');
await addDefaultNewsFeed({
content: `<h2>Welcome to this course 🎉&nbsp;</h2>
<p>Thank you for joining this course and I hope you get the best out of it.</p>`,
course_id: newCourse.id,
is_pinned: true,
author_id: authorId
});
}
onClose(`/courses/${newCourse.id}`);
isLoading = false;
}
let open = $derived(new URLSearchParams(page.url.search).get('create') === 'true');
</script>
<svelte:head>
<title>Create a new course</title>
</svelte:head>
<Modal
onClose={() => onClose(page.url.pathname)}
bind:open
width="w-4/5 md:w-2/5 md:min-w-[600px]"
containerClass="max-w-2xl mx-auto"
modalHeading={$t('courses.new_course_modal.heading')}
>
{#if step === 0}
<div>
<h2 class="my-5 text-xl font-medium">
{$t('courses.new_course_modal.type_selector_title')}
</h2>
<div class="my-8 flex flex-col items-center justify-evenly gap-4 md:flex-row">
{#each options as option}
<button
class="w-11/12 rounded-md border-2 p-5 md:h-60 md:w-[261px] dark:bg-neutral-700 {option.type === type
? 'border-primary-400'
: `border-gray-200 dark:border-neutral-600 ${
!option.isDisabled && 'hover:scale-95'
}`} flex flex-col {option.isDisabled && 'cursor-not-allowed opacity-60'} transition-all ease-in-out"
type="button"
onclick={!option.isDisabled ? () => (type = option.type) : undefined}
>
<div class="flex h-[70%] w-full flex-row-reverse">
<CircleCheckIcon size={16} filled={option.type === type} />
</div>
<div>
<p class="flex items-center text-start">
<span class="mr-2 text-sm">{option.title}</span>
{#if option.isDisabled}
<ComingSoon />
{/if}
</p>
<p class="text-start text-xs font-light">{option.subtitle}</p>
</div>
</button>
{/each}
</div>
<div class="mt-8 flex flex-row-reverse items-center">
<PrimaryButton
className="px-6 py-3"
label={$t('courses.new_course_modal.next')}
onClick={() => (step = 1)}
isDisabled={!type}
/>
</div>
</div>
{:else}
<form onsubmit={preventDefault(createCourse)}>
<div class="mb-4 flex items-end space-x-2">
<TextField
label={$t('courses.new_course_modal.course_name')}
bind:value={$createCourseModal.title}
placeholder={$t('courses.new_course_modal.course_name_placeholder')}
className="w-full "
isRequired={true}
errorMessage={errors.title}
autoComplete={false}
/>
</div>
<TextArea
label={$t('courses.new_course_modal.short_description')}
bind:value={$createCourseModal.description}
rows={4}
placeholder={$t('courses.new_course_modal.short_description_placeholder')}
className="mb-4"
isRequired={true}
errorMessage={errors.description}
isAIEnabled={true}
initAIPrompt="Write a 30 word description for a course titled: {$createCourseModal.title}"
/>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
label={$t('courses.new_course_modal.back')}
variant={VARIANTS.OUTLINED}
onClick={() => (step = 0)}
/>
<PrimaryButton
className="px-6 py-3"
label={$t('courses.new_course_modal.button')}
type="submit"
isDisabled={isLoading}
{isLoading}
/>
</div>
</form>
{/if}
</Modal>
@@ -1,10 +0,0 @@
export const statuses = [
{
chipClassName: 'bg-green-600 text-white',
chipLabel: 'In progress'
},
{
chipClassName: 'bg-red-600 text-white',
chipLabel: 'Completed'
}
];
@@ -1,23 +0,0 @@
export function validateForm(fields) {
let hasError = false;
const { title, description } = fields;
const fieldErrors = {
title: '',
description: ''
};
if (!title) {
fieldErrors.title = 'Title is required';
hasError = true;
}
if (!description) {
fieldErrors.description = 'Description is required';
hasError = true;
}
return {
hasError,
fieldErrors
};
}
@@ -1,143 +0,0 @@
<script lang="ts">
import * as Table from '@cio/ui/base/table';
import Box from '../Box/index.svelte';
import Card from './components/Card/index.svelte';
import List from './components/List/index.svelte';
import CardLoader from './components/Card/Loader.svelte';
import CoursesEmptyIcon from '../Icons/CoursesEmptyIcon.svelte';
import DeleteModal from '$lib/components/Modal/DeleteModal.svelte';
import CopyCourseModal from './components/CopyCourseModal/index.svelte';
import { courseMetaDeta } from './store';
import type { Course } from '$lib/utils/types';
import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { deleteCourse } from '$lib/utils/services/courses';
import { deleteCourseModal, deleteCourseModalInitialState, courses as coursesStore } from './store';
interface Props {
courses?: Course[];
emptyTitle?: any;
emptyDescription?: any;
isExplore?: boolean;
}
let {
courses = [],
emptyTitle = $t('courses.course_card.empty_title'),
emptyDescription = $t('courses.course_card.empty_description'),
isExplore = false
}: Props = $props();
function calcProgressRate(progressRate?: number, totalLessons?: number): number {
if (!progressRate || !totalLessons) {
return 0;
}
return Math.round((progressRate / totalLessons) * 100);
}
async function handleDeleteCourse() {
if (!$deleteCourseModal.id) return;
$deleteCourseModal.isDeleting = true;
try {
await deleteCourse($deleteCourseModal.id);
// Remove the course from the courses store
$coursesStore = $coursesStore.filter((course) => course.id !== $deleteCourseModal.id);
// Show success message
snackbar.success('snackbar.course_deleted');
// Close modal and reset state
deleteCourseModal.set(deleteCourseModalInitialState);
} catch (error) {
console.error('Error deleting course:', error);
snackbar.error('snackbar.course_settings.error.went_wrong');
// Stop deleting state on error
$deleteCourseModal.isDeleting = false;
}
}
</script>
<CopyCourseModal />
<DeleteModal
onDelete={handleDeleteCourse}
bind:open={$deleteCourseModal.open}
isLoading={$deleteCourseModal.isDeleting}
/>
<div class="mx-auto my-4 w-full">
{#if $courseMetaDeta.isLoading}
<section class={`${$courseMetaDeta.isLoading || courses ? 'cards-container' : ''} `}>
<CardLoader />
<CardLoader />
<CardLoader />
</section>
{:else if $courseMetaDeta.view === 'list'}
<div class="w-full overflow-hidden rounded-md border">
<Table.Root class="w-full table-fixed">
<Table.Header>
<Table.Row>
<Table.Head class="w-[20%]">{$t('courses.course_card.list_view.title')}</Table.Head>
<Table.Head class="w-[30%]">{$t('courses.course_card.list_view.description')}</Table.Head>
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.type')}</Table.Head>
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.lessons')}</Table.Head>
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.students')}</Table.Head>
<Table.Head class="w-[12%]">{$t('courses.course_card.list_view.published')}</Table.Head>
<Table.Head class="w-[8%]"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each courses as courseData}
<List
id={courseData.id}
title={courseData.title}
type={$t(`course.navItem.settings.${courseData.type.toLowerCase()}`)}
description={courseData.description}
isPublished={courseData.is_published}
totalLessons={courseData.total_lessons}
totalStudents={courseData.total_students}
/>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
<section class={`relative ${$courseMetaDeta.isLoading || courses ? 'cards-container' : ''} `}>
{#each courses as courseData}
{#key courseData.id}
<Card
id={courseData.id}
slug={courseData.slug}
bannerImage={courseData.logo || '/images/classroomio-course-img-template.jpg'}
title={courseData.title}
description={courseData.description}
isPublished={courseData.is_published}
type={courseData.type}
currency={courseData.currency}
totalLessons={courseData.total_lessons}
totalStudents={courseData.total_students}
isLMS={$globalStore.isOrgSite}
{isExplore}
progressRate={calcProgressRate(courseData.progress_rate, courseData.total_lessons)}
/>
{/key}
{/each}
</section>
{/if}
</div>
{#if !$courseMetaDeta.isLoading && !courses.length}
<Box className="w-full">
<CoursesEmptyIcon />
<h3 class="my-5 text-2xl dark:text-white">{emptyTitle}</h3>
<p class="w-1/3 text-center dark:text-white">
{emptyDescription}
</p>
</Box>
{/if}
@@ -1,57 +0,0 @@
import { writable, derived } from 'svelte/store';
import type { Course } from '$lib/utils/types';
export const courses = writable<Course[]>([]);
export const view = writable('grid');
export const coursesInProgress = derived(courses, ($courses) =>
$courses.length > 0
? $courses.filter((course) => {
return course.total_lessons !== course.progress_rate;
})
: []
);
export const coursesComplete = derived(courses, ($courses) =>
$courses.length > 0
? $courses.filter((course) => {
return course.total_lessons === course.progress_rate;
})
: []
);
export const courseMetaDeta = writable<{
isLoading: boolean;
view: 'grid' | 'list';
}>({
isLoading: true,
view: 'grid'
});
export const createCourseModal = writable({
title: '',
type: '',
description: '',
emails: '',
tutors: '',
students: ''
});
export const copyCourseModalInitialState = {
open: false,
courseIdToCopy: null,
id: '',
title: '',
description: '',
isSaving: false,
error: null
};
export const copyCourseModal = writable({ ...copyCourseModalInitialState });
export const deleteCourseModalInitialState = {
open: false,
id: '',
title: '',
isDeleting: false
};
export const deleteCourseModal = writable({ ...deleteCourseModalInitialState });
@@ -2,9 +2,10 @@
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { goto } from '$app/navigation';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import CoursesEmptyIcon from '$lib/components/Icons/CoursesEmptyIcon.svelte';
import { courses } from '$lib/components/Courses/store';
import { courses } from '$lib/features/course/utils/store';
import { t } from '$lib/utils/functions/translations';
import { Empty } from '@cio/ui/custom/empty';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
const gotoCourse = (id: string | undefined) => {
if (!id) return;
@@ -54,11 +55,7 @@
{/each}
</div>
{:else}
<div class="flex flex-col items-center">
<CoursesEmptyIcon size={16} />
<h3 class="text-center">{$t('dashboard.no_courses')}</h3>
<p class="w-[75%] text-center">{$t('dashboard.start_course')}</p>
</div>
<Empty title={$t('dashboard.no_courses')} description={$t('dashboard.start_course')} icon={BookOpenIcon} />
{/if}
</div>
</section>
@@ -64,7 +64,7 @@
message = $t('username already exists');
}
snackbar.error(`${$t('snackbar.lms.error.update')}: ${message}`);
snackbar.error(`${$t('snackbar.update_failed')}: ${message}`);
loading = false;
} finally {
@@ -42,7 +42,7 @@
{#if open}
<div
class="dialog fixed inset-0 z-50 flex items-center justify-center overflow-x-hidden bg-gray-100 bg-opacity-50"
class="dialog z-100 fixed inset-0 flex items-center justify-center overflow-x-hidden bg-gray-100 bg-opacity-50"
onclick={onClose}
role="presentation"
>
@@ -1,7 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
interface Props {
@@ -11,45 +9,24 @@
onMobileClick?: (() => void) | undefined;
}
let {
disableSignup = false,
redirect = '',
isMobile = false,
onMobileClick = undefined
}: Props = $props();
function handleLogin() {
if (onMobileClick) onMobileClick();
goto('/login' + redirect);
}
function handleSignup() {
if (onMobileClick) onMobileClick();
goto('/signup' + redirect);
}
let { disableSignup = false, redirect = '', isMobile = false, onMobileClick = undefined }: Props = $props();
</script>
<div class="flex {isMobile ? 'flex-col items-center justify-center space-y-3' : ''}">
<li class={isMobile ? '' : 'block'}>
<div class="flex">
<PrimaryButton
label={$t('navigation.login')}
variant={VARIANTS.TEXT}
onClick={handleLogin}
className={isMobile ? 'w-full justify-center' : ''}
/>
<Button variant="link" size="sm" href={'/login' + redirect} onclick={onMobileClick}>
{$t('navigation.login')}
</Button>
</div>
</li>
{#if !disableSignup}
<li class={isMobile ? '' : 'block'}>
<div class="flex">
<PrimaryButton
label={$t('navigation.signup')}
variant={VARIANTS.CONTAINED}
onClick={handleSignup}
className={isMobile ? 'w-full justify-center' : ''}
/>
<Button variant="link" size="sm" href={'/signup' + redirect} onclick={onMobileClick}>
{$t('navigation.signup')}
</Button>
</div>
</li>
{/if}
@@ -1,5 +1,6 @@
<script lang="ts">
import type { TCustomLinks } from './types';
import { Button } from '@cio/ui/base/button';
interface Props {
customLinks?: TCustomLinks | undefined;
@@ -18,17 +19,16 @@
<div class={isMobile ? 'space-y-4' : 'mr-4 hidden items-center space-x-6 lg:flex'}>
{#each customLinks.links as link}
<li>
<a
<Button
href={link.url}
target={link.openInNewTab ? '_blank' : '_self'}
rel={link.openInNewTab ? 'noopener noreferrer' : ''}
class={isMobile
? 'hover:text-primary-600 block rounded-md px-3 py-2 font-medium text-gray-700 transition-colors duration-200 hover:bg-gray-50'
: 'hover:text-primary-600 rounded-md px-4 py-2 font-medium text-gray-700 no-underline transition-all duration-200 ease-in-out hover:bg-gray-100'}
variant="link"
size="sm"
onclick={handleLinkClick}
>
{link.label}
</a>
</Button>
</li>
{/each}
</div>
@@ -4,7 +4,6 @@
import { isCoursePage } from '$lib/utils/functions/app';
import { t } from '$lib/utils/functions/translations';
import type { TCustomLinks } from './types';
import { ModeSwitcher } from '@cio/ui/base/dark-mode';
import Logo from './Logo.svelte';
import CustomLinks from './CustomLinks.svelte';
@@ -77,8 +76,6 @@
{:else if !isOrgSite && !page.url.pathname?.includes('/404')}
<AuthButtons {disableSignup} {redirect} />
{/if}
<ModeSwitcher />
</ul>
{#if showLinks}
@@ -2,104 +2,29 @@
import { preventDefault } from '$lib/utils/functions/svelte';
import Modal from '$lib/components/Modal/index.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { supabase } from '$lib/utils/functions/supabase';
import { profile } from '$lib/utils/store/user';
import { Input } from '@cio/ui/base/input';
import { DomainInput } from '@cio/ui/custom/domain-input';
import { Button } from '@cio/ui/base/button';
import * as Field from '@cio/ui/base/field';
import { generateSitename } from '$lib/utils/functions/org';
import { getOrganizations } from '$lib/utils/services/org';
import { blockedSubdomain } from '$lib/utils/constants/app';
import { newOrgModal } from '../store';
import { snackbar } from '$lib/components/Snackbar/store';
import { createOrgValidation } from '$lib/utils/functions/validator';
import { goto } from '$app/navigation';
import { t } from '$lib/utils/functions/translations';
import { orgApi } from '$lib/features/org/api/org.svelte';
type Error = {
orgName: string;
siteName: string;
};
let loading = $state(false);
let orgName = $state('');
let siteName = $derived(generateSitename(orgName));
let errors: Error = $state({
orgName: '',
siteName: ''
});
function resetForm() {
orgName = '';
siteName = '';
loading = false;
errors = {
orgName: '',
siteName: ''
};
orgApi.errors = {};
}
async function createNewOrg() {
errors = createOrgValidation({
orgName,
siteName
}) as Error;
await orgApi.create({ name: orgName, siteName });
if (Object.values(errors).length) {
loading = false;
return;
}
// Validate if domain is among our seculeded subdomains
if (blockedSubdomain.includes(siteName || '')) {
errors.siteName = 'Sitename already exists.';
loading = false;
return;
}
const { data: org, error } = await supabase
.from('organization')
.insert({
name: orgName,
siteName: siteName
})
.select();
console.log('Create organisation', org);
if (error) {
console.log('Error: create organisation', error);
errors.siteName = 'Sitename already exists.';
loading = false;
return;
}
if (Array.isArray(org) && org.length) {
const orgData = org[0];
const { data, error } = await supabase
.from('organizationmember')
.insert({
organization_id: orgData.id,
profile_id: $profile.id,
role_id: 1
})
.select();
console.log('Create organisation member', data);
if (error) {
console.log('Error: create organisation member', error);
errors.siteName = $t('add_org.error_organization');
// Delete organization so it can be recreated.
await supabase.from('organization').delete().match({ siteName });
loading = false;
return;
}
snackbar.success();
await getOrganizations($profile.id);
goto(`/org/${siteName}`);
if (orgApi.success) {
$newOrgModal.open = false;
resetForm();
}
}
@@ -112,32 +37,28 @@
modalHeading={$t('add_org.create_org')}
>
<form onsubmit={preventDefault(createNewOrg)} class="px-2">
<TextField
label={$t('add_org.name')}
bind:value={orgName}
autoFocus={true}
placeholder="e.g Pepsi Co"
className="mb-4"
isRequired={true}
errorMessage={errors.orgName}
autoComplete={false}
/>
<!-- Org Site Name -->
<TextField
label={$t('add_org.org_sitename')}
helperMessage={`https://${siteName || ''}.classroomio.com`}
bind:value={siteName}
name="sitename"
type="text"
placeholder="e.g edforall"
className="mb-5 w-full"
labelClassName="text-lg font-normal"
errorMessage={errors.siteName}
isRequired={true}
/>
<Field.Group>
<Field.Field>
<Field.Label>{$t('add_org.name')}</Field.Label>
<Input bind:value={orgName} placeholder="e.g Pepsi Co" autofocus />
{#if orgApi.errors.name || orgApi.errors.orgName}
<Field.Error>{orgApi.errors.name || orgApi.errors.orgName}</Field.Error>
{/if}
</Field.Field>
<div class="mt-5 flex flex-row-reverse items-center">
<PrimaryButton className="px-6 py-3" label={$t('add_org.create')} type="submit" isLoading={loading} />
</div>
<Field.Field>
<Field.Label>{$t('add_org.org_sitename')}</Field.Label>
<DomainInput bind:value={siteName} placeholder="myschool" prefix="https://" suffix=".classroomio.com" />
{#if orgApi.errors.siteName || orgApi.errors.general}
<Field.Error>{orgApi.errors.siteName || orgApi.errors.general}</Field.Error>
{/if}
</Field.Field>
<Field.Field orientation="horizontal">
<Button type="submit" loading={orgApi.isLoading} class="ml-auto">
{$t('add_org.create')}
</Button>
</Field.Field>
</Field.Group>
</form>
</Modal>
@@ -1,128 +0,0 @@
<script lang="ts">
import { Input } from '@cio/ui/base/input';
import * as Table from '@cio/ui/base/table';
import { page as pageStore } from '$app/state';
import Search from '@lucide/svelte/icons/search';
import { Skeleton } from '@cio/ui/base/skeleton';
import * as Pagination from '@cio/ui/base/pagination';
import { t } from '$lib/utils/functions/translations';
import { currentOrg, orgAudience } from '$lib/utils/store/org';
import Avatar from '$lib/components/Avatar/index.svelte';
interface Props {
isLoading?: boolean;
}
let { isLoading = false }: Props = $props();
const headers = [
{ key: 'name', value: $t('audience.name') },
{ key: 'email', value: $t('audience.email') },
{ key: 'date_joined', value: $t('audience.date_joined') }
];
let pageSize = $state(5);
let currentPage = $state(1);
let searchValue = $state('');
const filteredRows = $derived(
searchValue
? $orgAudience.filter((row) =>
Object.values(row).some((val) => String(val).toLowerCase().includes(searchValue.toLowerCase()))
)
: $orgAudience
);
const totalPages = $derived(Math.ceil(filteredRows.length / pageSize));
const startIndex = $derived((currentPage - 1) * pageSize);
const endIndex = $derived(startIndex + pageSize);
const paginatedRows = $derived(filteredRows.slice(startIndex, endIndex));
$effect(() => {
if (filteredRows.length > 0 && currentPage > totalPages) {
currentPage = 1;
}
});
</script>
<div class="w-full space-y-4">
<!-- Search Toolbar -->
<div class="flex items-center gap-2">
<div class="relative flex-1">
<Search class="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input type="text" placeholder="Search..." bind:value={searchValue} />
</div>
</div>
<!-- Table -->
{#if isLoading}
<div class="space-y-2">
{#each Array(pageSize) as _}
<Skeleton class="h-12 w-full" />
{/each}
</div>
{:else}
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
{#each headers as header}
<Table.Head>{header.value}</Table.Head>
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each paginatedRows as row}
<Table.Row>
<Table.Cell>
<a
href={`${pageStore.url.href}/${row.id}/${$currentOrg.id}`}
class="text-primary-700 flex items-center gap-2 hover:underline"
>
<Avatar src={row.avatar_url} width="w-5" height="h-5" />
{row.name}
</a>
</Table.Cell>
<Table.Cell>{row.email}</Table.Cell>
<Table.Cell>{row.date_joined}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
<!-- Pagination -->
<div class="flex items-center justify-between">
<Pagination.Root
count={filteredRows.length}
perPage={pageSize}
page={currentPage}
onPageChange={(page) => (currentPage = page)}
>
{#snippet children({ pages, currentPage: activePage })}
<Pagination.Content>
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type === 'ellipsis'}
<Pagination.Item>
<Pagination.Ellipsis />
</Pagination.Item>
{:else}
<Pagination.Item>
<Pagination.Link {page} isActive={activePage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
</div>
@@ -1,39 +0,0 @@
<script>
import Box from '$lib/components/Box/index.svelte';
import AudienceList from './AudienceList.svelte';
import AudienceEmptyIcon from '$lib/components/Icons/AudienceEmptyIcon.svelte';
import { getOrgAudience } from '$lib/utils/services/org';
import { orgAudience, currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { untrack } from 'svelte';
let isLoading = $state(false);
function fetchInitData(orgId) {
if (!orgId) return;
untrack(async () => {
isLoading = true;
await getOrgAudience(orgId);
isLoading = false;
});
}
$effect(() => {
fetchInitData($currentOrg.id);
});
</script>
<div class="m-auto my-4 flex flex-wrap items-center justify-center lg:justify-start">
{#if $orgAudience.length || isLoading}
<AudienceList {isLoading} />
{:else}
<Box>
<AudienceEmptyIcon size={16} />
<h3 class="my-5 text-center text-2xl dark:text-white">{$t('audience.no_audience')}!</h3>
<p class="w-1/3 text-center dark:text-white">
{$t('audience.manage')}.
</p>
</Box>
{/if}
</div>
@@ -1,54 +0,0 @@
<script lang="ts">
import Modal from '$lib/components/Modal/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { t } from '$lib/utils/functions/translations';
interface Props {
onDelete?: any;
onCancel?: any;
open?: boolean;
isQuestion?: boolean;
isDeleting?: boolean;
}
let {
onDelete = () => {},
onCancel = () => {},
open = $bindable(false),
isQuestion = false,
isDeleting = false
}: Props = $props();
</script>
<Modal
onClose={() => (open = false)}
bind:open
width="w-96"
modalHeading="{$t('community.delete.title')} {isQuestion
? $t('community.delete.question')
: $t('community.delete.comment')}"
>
<div>
<p class="mt-0 text-base dark:text-white">
{$t('community.delete.sure')}
{isQuestion ? $t('community.delete.question') : $t('community.delete.comment')}?
</p>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label={$t('community.delete.no')}
onClick={onCancel}
/>
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.CONTAINED}
label={$t('community.delete.yes')}
onClick={onDelete}
isLoading={isDeleting}
/>
</div>
</div>
</Modal>
@@ -1,26 +0,0 @@
<script lang="ts">
import { Skeleton } from '@cio/ui/base/skeleton';
</script>
<div
class="relative mx-4 my-2 flex h-[70px] w-full rounded-md border border-gray-100 px-3 pt-3 transition ease-in-out dark:border-neutral-600 dark:bg-neutral-800"
>
<div class="mr-2 flex w-10 flex-col justify-evenly rounded-md px-1">
<Skeleton class="h-[40%] w-full rounded-sm" />
<Skeleton class="h-[40%] w-full rounded-sm" />
<Skeleton class="h-[40%] w-full rounded-sm" />
</div>
<div class="mt-1.5 flex w-9/12 flex-col justify-evenly rounded-md">
<!-- first text -->
<div class="h-full w-3/6">
<Skeleton class="h-[50%] w-full rounded-sm" />
</div>
<!-- second text -->
<Skeleton class="h-4 w-1/4 rounded-sm" />
</div>
<div class="ml-auto mt-1.5 w-10 flex-col justify-evenly rounded-md px-1">
<Skeleton class="h-[41%] w-full rounded-sm" />
</div>
</div>
@@ -1,185 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import { Input } from '@cio/ui/base/input';
import * as Select from '@cio/ui/base/select';
import Search from '@lucide/svelte/icons/search';
import MessageCirclePlusIcon from '@lucide/svelte/icons/message-circle-plus';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import { courses } from '$lib/components/Courses/store';
import { calDateDiff } from '$lib/utils/functions/date';
import { supabase } from '$lib/utils/functions/supabase';
import { fetchCourses } from '$lib/utils/services/courses';
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import CommunityLoader from './Loader.svelte';
import Box from '$lib/components/Box/index.svelte';
import Vote from '$lib/components/Vote/index.svelte';
import CoursesEmptyIcon from '$lib/components/Icons/CoursesEmptyIcon.svelte';
interface Props {
isLMS?: boolean;
}
let { isLMS = false }: Props = $props();
let isLoading = $state(false);
let discussions = $state([]);
let searchValue = $state('');
let allCourses: any[] = $state([]);
let selectedId = $state('');
function fetchCommunityQuestions(orgId?: string, profileId?: string) {
if (!orgId || !profileId) return;
untrack(async () => {
isLoading = true;
if ($courses.length) {
allCourses = [...$courses];
} else {
const courseResult = (await fetchCourses(profileId, orgId)) || { allCourses: [] };
allCourses = courseResult.allCourses;
}
const courseIds = allCourses.map((course) => course.id);
const courseIdsFilter = `(${courseIds.join(',')})`;
const { data, error } = await supabase
.from('community_question')
.select(
`
organization_id,
course_id,
title,
votes,
created_at,
slug,
comments:community_answer(count),
author:profile(
fullname
),
course!inner (
title
)
`
)
.filter('course_id', 'in', courseIdsFilter)
.order('created_at', { ascending: false });
console.log('data', data);
console.log('error', error);
isLoading = false;
if (error) {
console.error('Error loading community', error);
return goto(isLMS ? '/lms' : $currentOrgPath);
}
discussions =
data?.map((discussion) => ({
title: discussion.title,
courseId: discussion.course_id,
courseTitle: discussion.course?.title,
slug: discussion.slug,
author: discussion?.author?.fullname,
comments: discussion.comments?.[0]?.count || 0,
votes: discussion.votes,
createdAt: calDateDiff(discussion.created_at)
})) || [];
});
}
$effect(() => {
fetchCommunityQuestions($currentOrg.id, $profile.id);
});
let filteredDiscussions = $derived(
discussions.filter(
(discussion) =>
discussion.title.toLowerCase().includes(searchValue.toLowerCase()) &&
(!selectedId || discussion.courseId === selectedId)
)
);
</script>
<div class="flex w-full gap-2">
<div class="relative flex-1">
<Search class="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<Input
type="text"
placeholder={$t('community.find_question')}
bind:value={searchValue}
class="bg-gray-100 dark:bg-neutral-800"
/>
</div>
<div class="w-[25%]">
<Select.Root type="single" bind:value={selectedId}>
<Select.Trigger class="w-full bg-gray-100 dark:bg-neutral-800">
<p class="truncate">
{selectedId ? allCourses.find((course) => course.id === selectedId)?.title : $t('community.all')}
</p>
</Select.Trigger>
<Select.Content>
<Select.Item value="">{$t('community.all')}</Select.Item>
{#each allCourses as course}
<Select.Item value={course.id}>{course.title}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
<div
class="border-c m-auto my-4 flex flex-wrap items-center justify-center rounded bg-gray-100 lg:justify-start dark:bg-neutral-800"
>
{#if isLoading}
<CommunityLoader />
<CommunityLoader />
<CommunityLoader />
<CommunityLoader />
{:else}
{#each filteredDiscussions as discussion}
<div class="border-bottom-c flex w-full p-3">
<Vote value={discussion.votes} />
<div class="flex flex-col gap-y-0.5 text-sm">
<h4 class="mt-0">
<a class="text-black dark:text-white" href="{isLMS ? '/lms' : $currentOrgPath}/community/{discussion.slug}">
{discussion.title}
</a>
</h4>
<span class="text-gray-600 dark:text-white">
{discussion.author} asked {discussion.createdAt}
</span>
<a class="m-0" href="/courses/{discussion.courseId}">
<span class="text-primary-200 text-primary-700 p-0 text-xs dark:text-black">
#{discussion.courseTitle}
</span>
</a>
</div>
<div class="flex-grow"></div>
<div class="flex items-center">
<MessageCirclePlusIcon size={16} />
<span class="ml-1">{discussion.comments}</span>
</div>
</div>
{:else}
<Box className="">
<CoursesEmptyIcon size={16} />
<h3 class="dark:text-white text-2xl my-5">{$t('community.no_question')}</h3>
<p class="dark:text-white w-1/3 text-center">{$t('community.ask_a_question')}</p>
</Box>
{/each}
{/if}
</div>
<style>
h4 {
font-size: 16px;
font-weight: 900;
word-break: break-word;
overflow-wrap: break-word;
margin: 0;
}
</style>
@@ -1,6 +1,6 @@
<script>
import { Skeleton } from '@cio/ui/base/skeleton';
import CardLoader from '$lib/components/Courses/components/Card/Loader.svelte';
import { CourseCardLoader } from '$lib/features/course/components';
</script>
<section class="h-screen w-screen p-5">
@@ -19,8 +19,8 @@
</div>
<!-- cards -->
<div class="my-4 flex flex-wrap items-center justify-center gap-5 overflow-hidden rounded-md">
<CardLoader />
<CardLoader />
<CardLoader />
<CourseCardLoader />
<CourseCardLoader />
<CourseCardLoader />
</div>
</section>
@@ -12,32 +12,29 @@
import LinkedinIcon from '@lucide/svelte/icons/linkedin';
import { preventDefault } from '$lib/utils/functions/svelte';
import Box from '$lib/components/Box/index.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import Navigation from '$lib/components/Navigation/index.svelte';
import PoweredBy from '$lib/components/Upgrade/PoweredBy.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import Card from '$lib/components/Courses/components/Card/index.svelte';
import { PoweredBy } from '$lib/features/ui';
import { CourseCard, CourseCardLoader } from '$lib/features/course/components';
import { landingPageSettings } from '$lib/components/Org/Settings/store';
import CoursesEmptyIcon from '$lib/components/Icons/CoursesEmptyIcon.svelte';
import CardLoader from '$lib/components/Courses/components/Card/Loader.svelte';
import PageLoader from './PageLoader.svelte';
import { Row, Grid, Column } from '../Settings/Layout';
import * as Accordion from '@cio/ui/base/accordion';
import * as Button from '@cio/ui/base/button';
import * as Field from '@cio/ui/base/field';
import { Input } from '@cio/ui/base/input';
import { Textarea } from '@cio/ui/base/textarea';
import { Empty } from '@cio/ui/custom/empty';
import { t } from '$lib/utils/functions/translations';
import type { CurrentOrg } from '$lib/utils/types/org';
import { getSupabase } from '$lib/utils/functions/supabase';
import { getCourseBySiteName } from '$lib/utils/services/org';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import type { AccountOrg } from '$lib/features/app/types';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { validateEmail } from '$lib/utils/functions/validateEmail';
import { courseMetaDeta, courses } from '$lib/components/Courses/store';
import { courseMetaDeta, courses } from '$lib/features/course/utils/store';
import { orgLandingpageValidation } from '$lib/utils/functions/validator';
interface Props {
orgSiteName?: string;
org: CurrentOrg | null;
org: AccountOrg;
}
let { orgSiteName = '', org }: Props = $props();
@@ -57,20 +54,11 @@
});
let contactError: Record<string, string> = $state({});
const supabase = getSupabase();
async function handleSubmit() {
if (!email || !validateEmail(email) || !org) return;
isAdding = true;
const { error } = await supabase.from('organization_emaillist').insert({
email,
organization_id: org.id
});
if (error) {
console.error('Error', error);
}
// TODO: Implement email list submission
success = true;
setTimeout(() => {
@@ -89,18 +77,7 @@
}
// Save to db
const { error } = await supabase.from('organization_contacts').insert({
name: contact.name,
email: contact.email,
phone: contact.phone,
message: contact.message,
organization_id: org?.id
});
if (error) {
console.error('Something went wrong', error, '\n\nContact', contact);
isContactSubmiting = false;
}
// TODO: Implement contact submission
isContactSubmiting = false;
successContactSaved = true;
@@ -123,11 +100,17 @@
untrack(async () => {
try {
$courseMetaDeta.isLoading = true;
const coursesResult = await getCourseBySiteName(siteName);
courses.set(coursesResult);
await orgApi.getCourseBySiteName(siteName);
if (orgApi.error) {
console.error('Error fetching courses', orgApi.error);
courses.set([]);
} else {
courses.set(orgApi.orgSiteCourses);
}
$courseMetaDeta.isLoading = false;
} catch (error) {
console.log('error', error);
$courseMetaDeta.isLoading = false;
}
});
}
@@ -157,18 +140,18 @@
}
function getBgImage(settings) {
const defaultImg = '/images/org-landingpage-banner.jpeg';
const { show, image } = settings.header.background || {
show: true,
image: '/images/org-landingpage-banner.jpeg'
image: defaultImg
};
if (!show) {
return undefined;
}
if (image) {
return `url('${image}')`;
}
return `url('${image || defaultImg}')`;
}
$effect(() => {
@@ -195,7 +178,7 @@
{:else}
<main>
<Navigation
logo={org.avatar_url}
logo={org.avatarUrl}
orgName={org.name}
disableSignup={true}
isOrgSite={true}
@@ -205,12 +188,12 @@
{#if $landingPageSettings.header.show}
<header
id="header"
class={`relative mb-10 h-[100vh] w-full md:h-[90vh] ${
class={`relative mb-10 h-screen w-full md:h-[90vh] ${
$landingPageSettings.header.background?.show ? 'bg-cover bg-center' : 'border-b border-gray-300'
}`}
style="background-image: {getBgImage($landingPageSettings)}"
>
<div class="absolute top-0 z-10 h-[100vh] w-full bg-white opacity-80 md:h-[90vh]"></div>
<div class="absolute top-0 z-10 h-screen w-full bg-white opacity-80 md:h-[90vh]"></div>
{#if $landingPageSettings.header.banner.show}
<div class="flex items-center justify-center py-2 md:h-full">
<div
@@ -230,13 +213,14 @@
{$landingPageSettings.header.subtitle}
</p>
<PrimaryButton
label={$landingPageSettings.header.action.label}
className="mt-2 md:mt-5 px-10 w-fit"
onClick={() => {
<Button.Root
class="mt-2 w-fit px-10 md:mt-5"
onclick={() => {
$landingPageSettings.header.action.redirect && goto($landingPageSettings.header.action.link);
}}
/>
>
{$landingPageSettings.header.action.label}
</Button.Root>
</div>
<div class="flex w-5/6 md:w-1/2 md:max-w-[650px]">
@@ -275,13 +259,14 @@
{$landingPageSettings.header.subtitle}
</p>
<PrimaryButton
label={$landingPageSettings.header.action.label}
className="mt-5 px-10 w-fit"
onClick={() => {
<Button.Root
class="mt-5 w-fit px-10"
onclick={() => {
$landingPageSettings.header.action.redirect && goto($landingPageSettings.header.action.link);
}}
/>
>
{$landingPageSettings.header.action.label}
</Button.Root>
</div>
</div>
{/if}
@@ -322,14 +307,14 @@
</div>
{#if $courseMetaDeta.isLoading}
<div class="cards-container mx-2 my-4">
<CardLoader />
<CardLoader />
<CardLoader />
<CourseCardLoader />
<CourseCardLoader />
<CourseCardLoader />
</div>
{:else if $courses.length > 0}
<div class="cards-container mx-2 my-4">
{#each $courses.slice(0, viewAll ? $courses.length : 3) as courseData}
<Card
<CourseCard
id={courseData.id}
slug={courseData.slug}
bannerImage={courseData.logo || '/images/classroomio-course-img-template.jpg'}
@@ -350,25 +335,19 @@
{/each}
</div>
{:else}
<Box>
<CoursesEmptyIcon />
<h3 class="my-5 text-2xl dark:text-white">
{$t('course.navItem.landing_page.no_course_published')}
</h3>
<p class="w-1/3 text-center dark:text-white">
{$t('course.navItem.landing_page.coming_your_way')}
</p>
</Box>
<Empty
icon={CoursesEmptyIcon}
title={$t('course.navItem.landing_page.no_course_published')}
description={$t('course.navItem.landing_page.coming_your_way')}
variant="page"
/>
{/if}
{#if $courses.length > 3}
<div class="mt-3 flex w-full justify-center">
<PrimaryButton
variant={VARIANTS.OUTLINED}
onClick={() => (viewAll = !viewAll)}
label={viewAll ? $t('course.navItem.landing_page.view_less') : $t('course.navItem.landing_page.view_all')}
className="px-10 py-5 w-fit"
/>
<Button.Root variant="outline" onclick={() => (viewAll = !viewAll)} class="w-fit px-10 py-5">
{viewAll ? $t('course.navItem.landing_page.view_less') : $t('course.navItem.landing_page.view_all')}
</Button.Root>
</div>
{/if}
</section>
@@ -386,13 +365,11 @@
<Accordion.Root type="single" class="w-full">
{#each $landingPageSettings.faq.questions as faq, index}
<Accordion.Item value="item-{index}">
<Accordion.Trigger class="text-left text-lg font-medium">
<Accordion.Trigger>
{faq.title}
</Accordion.Trigger>
<Accordion.Content>
<p class="text-lg">
{faq.content}
</p>
{faq.content}
</Accordion.Content>
</Accordion.Item>
{/each}
@@ -412,30 +389,30 @@
</h1>
<p class="text-md text-center">{$landingPageSettings.contact.subtitle}</p>
</div>
<Grid class="max-w-[700px] pb-10">
<div class="mx-auto max-w-[700px] pb-10">
<!-- Contact Details -->
<Row>
<Column
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div
class="mx-2 flex cursor-pointer flex-col items-center justify-center break-all rounded-lg py-2 text-center transition-all duration-500 hover:shadow-xl"
>
<MapPinIcon size={16} class="filled" />
<p class="mt-3 max-w-[200px] text-xs md:text-sm">
{$landingPageSettings.contact.address}
</p>
</Column>
<Column
</div>
<div
class="mx-2 flex cursor-pointer flex-col items-center justify-center break-all rounded-lg py-2 text-center transition-all duration-500 hover:shadow-xl"
>
<PhoneIcon size={16} />
<p class="mt-3 text-xs md:text-sm">{$landingPageSettings.contact.phone}</p>
</Column>
<Column
</div>
<div
class="mx-2 flex cursor-pointer flex-col items-center justify-center break-all rounded-lg py-2 text-center transition-all duration-500 hover:shadow-xl"
>
<MailIcon size={16} />
<p class="mt-3 text-xs md:text-sm">{$landingPageSettings.contact.email}</p>
</Column>
</Row>
</div>
</div>
<!-- Contact Form -->
<div class="mt-8 rounded-lg bg-white p-7">
@@ -447,48 +424,69 @@
<form onsubmit={preventDefault(handleContactSubmit)}>
<div class="flex w-full flex-col justify-between md:flex-row">
<div class="mr-5 w-full md:w-2/4">
<TextField
label={$t('course.navItem.landing_page.name')}
bind:value={contact.name}
errorMessage={contactError.name}
className="mb-5"
labelClassName="font-bold"
placeholder="Elon Musk"
/>
<TextField
label={$t('course.navItem.landing_page.email')}
bind:value={contact.email}
errorMessage={contactError.email}
className="text-xs font-normal mb-5"
placeholder="musk@x.com"
/>
<TextField
label={$t('course.navItem.landing_page.phone')}
bind:value={contact.phone}
errorMessage={contactError.phone}
className="text-xs font-normal mb-5"
placeholder="+1194802480"
/>
<Field.Field class="mb-5">
<Field.Label class="font-bold">
{$t('course.navItem.landing_page.name')}
</Field.Label>
<Input bind:value={contact.name} placeholder="Elon Musk" aria-invalid={!!contactError.name} />
{#if contactError.name}
<Field.Error>{contactError.name}</Field.Error>
{/if}
</Field.Field>
<Field.Field class="mb-5">
<Field.Label class="text-xs font-normal">
{$t('course.navItem.landing_page.email')}
</Field.Label>
<Input
bind:value={contact.email}
type="email"
placeholder="musk@x.com"
aria-invalid={!!contactError.email}
/>
{#if contactError.email}
<Field.Error>{contactError.email}</Field.Error>
{/if}
</Field.Field>
<Field.Field class="mb-5">
<Field.Label class="text-xs font-normal">
{$t('course.navItem.landing_page.phone')}
</Field.Label>
<Input
bind:value={contact.phone}
placeholder="+1194802480"
aria-invalid={!!contactError.phone}
/>
{#if contactError.phone}
<Field.Error>{contactError.phone}</Field.Error>
{/if}
</Field.Field>
</div>
<div class="w-full md:w-2/4">
<TextArea
label={$t('course.navItem.landing_page.message')}
bind:value={contact.message}
errorMessage={contactError.message}
rows={9}
placeholder={$t('course.navItem.landing_page.your_message')}
/>
<Field.Field>
<Field.Label>
{$t('course.navItem.landing_page.message')}
</Field.Label>
<Textarea
bind:value={contact.message}
rows={9}
placeholder={$t('course.navItem.landing_page.your_message')}
aria-invalid={!!contactError.message}
/>
{#if contactError.message}
<Field.Error>{contactError.message}</Field.Error>
{/if}
</Field.Field>
</div>
</div>
<PrimaryButton className="w-full mx-auto mt-5 md:mt-0" type="submit" isLoading={isContactSubmiting}>
<Button.Root class="mx-auto mt-5 w-full md:mt-0" type="submit" loading={isContactSubmiting}>
<span class="text-md mr-2">{$t('course.navItem.landing_page.submit')}</span>
<RocketIcon size={16} />
</PrimaryButton>
</Button.Root>
</form>
{/if}
</div>
</Grid>
</div>
</div>
</section>
{/if}
@@ -496,38 +494,35 @@
<!-- Waitlist Section -->
{#if $landingPageSettings.mailinglist.show}
<section id="waitlist" transition:fade class="mx-auto my-10 w-[95%] max-w-6xl">
<div class="bg-primary-700 flex flex-col rounded-lg px-4 py-14 md:px-10 lg:flex-row lg:items-center">
<div class="w-full md:mr-4 md:w-[65%]">
<h1 class="mb-5 mt-0 text-4xl text-white">
{$landingPageSettings.mailinglist.title}
</h1>
<p class="text-lg text-white">
{$landingPageSettings.mailinglist.subtitle}
</p>
</div>
<form onsubmit={preventDefault(handleSubmit)} class="my-4 w-full md:w-fit">
<div class="flex flex-col items-center sm:flex-row">
{#if success}
<p class="text-white">{$t('course.navItem.landing_page.successful_sub')}</p>
{:else}
<TextField
bind:value={email}
type="email"
placeholder={$t('course.navItem.landing_page.enter')}
className="sm:mr-3 my-0 w-full md:w-fit"
isRequired={true}
isDisabled={isAdding}
inputClassName="py-2"
/>
<PrimaryButton
className="my-1 w-full mt-2"
variant={VARIANTS.CONTAINED_LIGHT}
type="submit"
isLoading={isAdding}>{$landingPageSettings.mailinglist.buttonLabel}</PrimaryButton
>
{/if}
<div class="bg-primary-700 gap-4 rounded-lg px-4 py-14 md:px-10 lg:flex-row lg:items-center">
<div class="mx-auto flex max-w-[700px] flex-col lg:flex-row lg:items-center lg:justify-between">
<div class="w-3/5">
<h1 class="mb-5 mt-0 text-4xl text-white">
{$landingPageSettings.mailinglist.title}
</h1>
<p class="text-md text-white">
{$landingPageSettings.mailinglist.subtitle}
</p>
</div>
</form>
<form onsubmit={preventDefault(handleSubmit)} class="">
<div class="flex max-w-60 flex-col items-center">
{#if success}
<p class="text-white">{$t('course.navItem.landing_page.successful_sub')}</p>
{:else}
<Input
bind:value={email}
type="email"
placeholder={$t('course.navItem.landing_page.enter')}
required={true}
disabled={isAdding}
/>
<Button.Root class="my-1 mt-2 w-full" type="submit" loading={isAdding}>
{$landingPageSettings.mailinglist.buttonLabel}
</Button.Root>
{/if}
</div>
</form>
</div>
</div>
</section>
{/if}
@@ -543,7 +538,7 @@
<div class="logo">
<a href="/" title={`Go to ${org.name} Home`} id="logo" data-hveid="8" class="flex items-center">
<img
src={org.avatar_url || '/logo-192.png'}
src={org.avatarUrl || '/logo-192.png'}
alt={`${org.name} logo`}
class="mx-auto inline-block max-h-10 w-10 rounded"
data-atf="1"
@@ -1,6 +1,6 @@
<script>
import { onMount } from 'svelte';
import { SyncLoader } from 'svelte-loading-spinners';
import { Spinner } from '@cio/ui/base/spinner';
import PlayContainer from './Container.svelte';
import PlayHeader from './Header/index.svelte';
import { quizStore, playQuizStore } from '$lib/utils/store/org';
@@ -43,7 +43,7 @@
<div class="">
<p>2. Enter Pin</p>
{#if isGettingPin}
<SyncLoader size="50" color="#1d4ed8" unit="px" duration="1s" />
<Spinner class="size-10! text-blue-700!" />
{:else}
<h3>{$quizStore.pin}</h3>
{/if}
@@ -1,7 +1,7 @@
<script>
import { onMount } from 'svelte';
import { fly } from 'svelte/transition';
import { SyncLoader } from 'svelte-loading-spinners';
import { Spinner } from '@cio/ui/base/spinner';
import { themeImages, STEPS } from '$lib/utils/constants/quiz';
import { quizStore, playQuizStore } from '$lib/utils/store/org';
import ConnectToPlay from './ConnectToPlay.svelte';
@@ -39,7 +39,7 @@
<Podium />
{:else}
<div class="m-auto w-full">
<SyncLoader size="20" color="#1d4ed8" unit="px" duration="1s" />
<Spinner class="size-8! text-blue-700!" />
</div>
{/if}
</div>
@@ -37,7 +37,7 @@
if (error) {
const message = error?.message || 'snackbar.lms.error.try_again';
console.error('Error updating customizations', message);
snackbar.error('snackbar.lms.error.update');
snackbar.error('snackbar.update_failed');
} else {
snackbar.success('snackbar.success_update');
}
@@ -25,7 +25,7 @@
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import { ComingSoon } from '$lib/features/ui';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import { UpgradeBanner } from '$lib/features/ui';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import VisitOrgSiteButton from '$lib/components/Buttons/VisitOrgSite.svelte';
@@ -139,8 +139,8 @@
.match({ id: $currentOrg.id });
if (error) {
const message = error?.message || 'snackbar.lms.error.try_again';
snackbar.error(`snackbar.lms.error.update ${message}`);
const message = error?.message || $t('snackbar.lms.error.try_again');
snackbar.error(`${$t('snackbar.update_failed')}: ${message}`);
} else {
$currentOrg.landingpage = $landingPageSettings as any;
snackbar.success('snackbar.success_update');
@@ -139,7 +139,7 @@
message = $t('snackbar.lms.error.username_exists');
}
snackbar.error(`${$t('snackbar.lms.error.update')} ${message}`);
snackbar.error(`${$t('snackbar.update_failed')}: ${message}`);
loading = false;
} finally {
loading = false;
@@ -1,225 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte';
import { Label } from '@cio/ui/base/label';
import * as Select from '@cio/ui/base/select';
import { Circle } from 'svelte-loading-spinners';
import { profile } from '$lib/utils/store/user';
import { isFreePlan } from '$lib/utils/store/org';
import { getOrgTeam } from '$lib/utils/services/org';
import { t } from '$lib/utils/functions/translations';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$lib/components/Snackbar/store';
import type { OrgTeamMember } from '$lib/utils/types/org';
import { currentOrg, orgTeam } from '$lib/utils/store/org';
import { ROLE_LABEL } from '$lib/utils/constants/roles';
import { ROLE } from '@cio/utils/constants';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { validateEmailInString } from '$lib/utils/functions/validator';
import { triggerSendEmail, NOTIFICATION_NAME } from '$lib/utils/services/notification/notification';
import { Row, Grid, Column } from './Layout';
import SectionTitle from '../SectionTitle.svelte';
import TextChip from '$lib/components/Chip/Text.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import { ComingSoon } from '$lib/features/ui';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
let emailsStr = $state('');
let errorMessage = $state('');
let role = $state(ROLE.TUTOR.toString());
let isFetching = $state(false);
let isLoading = $state(false);
let isRemoving: number | null = $state(null);
async function onSendInvite() {
const { hasError, error: _error, emails } = validateEmailInString(emailsStr);
if (hasError) {
errorMessage = _error;
return;
}
isLoading = true;
let apiError = '';
emails.forEach(async (email: string, index: number) => {
if (apiError) return;
const doesEmailExist = $orgTeam.some((teamMember) => teamMember.email.toLowerCase() === email.toLowerCase());
if (doesEmailExist) {
snackbar.error('snackbar.team_members.user_exists');
isLoading = false;
return;
}
const { data, error } = await supabase
.from('organizationmember')
.insert({
organization_id: $currentOrg.id,
email,
role_id: role,
verified: false
})
.select();
console.log('data', data);
if (error) {
apiError = `${error}`;
console.error('onSendInvite:', error);
snackbar.error(`snackbar.team_members.invite_fail`);
isLoading = false;
return;
}
const [newMember] = data || [];
if (newMember) {
orgTeam.update((team) => [
{
id: newMember.id,
email: newMember?.email,
fullname: newMember?.fullname || '',
verified: newMember?.verified,
role: ROLE_LABEL[newMember?.role_id] || '',
isAdmin: newMember?.role_id === ROLE.ADMIN
},
...team
]);
}
triggerSendEmail(NOTIFICATION_NAME.INVITE_TEACHER, {
email: newMember.email,
org: {
id: $currentOrg.id,
name: $currentOrg.name,
siteName: $currentOrg.siteName
}
});
const isLast = index === emails.length - 1;
if (isLast) {
snackbar.success('snackbar.team_members.invite_sent');
emailsStr = '';
isLoading = false;
}
});
}
async function onRemove(id: number) {
console.log('onRemove called');
isRemoving = id;
const { error } = await supabase.from('organizationmember').delete().match({ id });
if (error) {
console.error('onRemove:', error);
snackbar.error('snackbar.team_members.remove_fail');
} else {
orgTeam.update((team) => [...team.filter((member) => member.id !== id)]);
}
isRemoving = null;
}
const fetchTeam = (id: string) => {
if (!id) return;
untrack(async () => {
isFetching = true;
await getOrgTeam(id);
isFetching = false;
isFetching = false;
});
};
const isTeamMemberAdmin = (members: OrgTeamMember[], profileId: string | undefined) => {
return members.some((member) => member.profileId === profileId && member.isAdmin);
};
$effect(() => {
fetchTeam($currentOrg.id);
});
</script>
<Grid class="relative mt-5 w-full rounded border border-gray-200 dark:border-neutral-600">
<Row class="border-bottom-c py-7">
<Column sm={4} md={4} lg={4} class="text-lg"
><SectionTitle>{$t('course.navItem.people.teams.add')}</SectionTitle></Column
>
<Column sm={8} md={8} lg={8}>
<p class="text-md mb-5 text-gray-500 dark:text-white">
{$t('course.navItem.people.teams.add_team')}
</p>
<div class="">
<TextField
label={$t('course.navItem.people.teams.invite')}
placeholder={$t('course.navItem.people.teams.placeholder')}
bind:value={emailsStr}
className="mb-3"
isDisabled={$isFreePlan}
{errorMessage}
/>
<div class="mb-5">
<Label class="mb-2 block">{$t('course.navItem.people.teams.role')}</Label>
<Select.Root type="single" bind:value={role} disabled={$isFreePlan}>
<Select.Trigger class="w-40">
<p>{role ? $t(ROLE_LABEL[role]) : $t('course.navItem.people.teams.select_role')}</p>
</Select.Trigger>
<Select.Content>
<Select.Item value={ROLE.ADMIN.toString()}>{$t(ROLE_LABEL[ROLE.ADMIN])}</Select.Item>
<Select.Item value={ROLE.TUTOR.toString()}>{$t(ROLE_LABEL[ROLE.TUTOR])}</Select.Item>
</Select.Content>
</Select.Root>
</div>
<PrimaryButton
label={$t('course.navItem.people.teams.send_invite')}
onClick={onSendInvite}
{isLoading}
isDisabled={isLoading || $isFreePlan}
/>
</div>
</Column>
</Row>
<Row class="border-bottom-c py-7">
<Column sm={4} md={4} lg={4} class="text-lg"
><SectionTitle>{$t('course.navItem.people.teams.members')}</SectionTitle></Column
>
<Column sm={8} md={8} lg={8}>
{#if isFetching}
<Circle />
{:else}
{#each $orgTeam as teamMember}
<div class="mb-5 flex items-center justify-between">
<div class="flex">
<p class="mr-3 text-sm text-gray-500 dark:text-white">
{teamMember.email}
</p>
<TextChip value={$t(teamMember.role)} className="text-xs mr-3" size="sm" />
{#if !teamMember.verified}
<TextChip
value={$t('course.navItem.people.teams.invite_sent')}
className="text-xs bg-yellow-200 text-yellow-700"
size="sm"
/>
{:else if teamMember.profileId === $profile.id}
<ComingSoon label={$t('course.navItem.people.teams.you')} />
{/if}
</div>
{#if teamMember.profileId !== $profile.id && isTeamMemberAdmin($orgTeam, $profile.id)}
<PrimaryButton
label={$t('course.navItem.people.teams.remove')}
variant={VARIANTS.TEXT_DANGER}
onClick={() => onRemove(teamMember.id)}
isLoading={isRemoving === teamMember.id}
isDisabled={isRemoving === teamMember.id}
/>
{/if}
</div>
{/each}
{/if}
</Column>
</Row>
</Grid>
@@ -1,42 +0,0 @@
<script lang="ts">
import { profile } from '$lib/utils/store/user';
import { group } from '../Course/store';
import { isOrgAdmin } from '$lib/utils/store/org';
interface Props {
allowedRoles?: number[];
onDenied?: any;
// Since orgAdmin can see all, we need a way to show the content only to students
onlyStudent?: boolean;
children?: import('svelte').Snippet;
}
let { allowedRoles = [], onDenied = () => {}, onlyStudent = false, children }: Props = $props();
const userRole: number = $derived.by(() => {
const user = $group.people.find((person) => person.profile_id === $profile.id);
return user ? user.role_id : 0;
});
const show = $derived.by(() => {
if (onlyStudent) {
return isAllowed(userRole);
} else {
return isAllowed(userRole) || !!$isOrgAdmin;
}
});
function isAllowed(userRole) {
return allowedRoles.includes(userRole);
}
$effect(() => {
if (!$isOrgAdmin && $group.people.length && !isAllowed(userRole)) {
onDenied();
}
});
</script>
{#if show}
{@render children?.()}
{/if}
@@ -1,11 +1,11 @@
<script lang="ts">
import { Editor } from '@cio/ui/custom/editor';
import type { Content, TiptapEditor } from '@cio/ui/custom/editor';
import type { HTMLContent, TiptapEditor } from '@cio/ui/custom/editor';
interface Props {
placeholder?: string | ((node: any) => string);
// Content of the editor
content?: Content;
content?: HTMLContent;
// Whether the toolbar should be visible
showToolBar?: boolean;
// Whether the editor is editable
@@ -21,7 +21,7 @@
// CSS class for the editor itself
editorClass?: string;
// Callback functions
onChange?: (content: Content) => void;
onChange?: (content: HTMLContent) => void;
onReady?: (editor: TiptapEditor) => void;
onEditorDestroy?: () => void;
}
@@ -1,28 +0,0 @@
<script lang="ts">
import { openUpgradeModal } from '$lib/utils/functions/org';
import ZapIcon from '@lucide/svelte/icons/zap';
import { isFreePlan } from '$lib/utils/store/org';
interface Props {
className?: string;
onClick?: any;
children?: import('svelte').Snippet;
}
let { className = '', onClick = () => {}, children }: Props = $props();
</script>
{#if $isFreePlan}
<button
class="flex w-full items-center gap-2 rounded-md border border-blue-700 bg-blue-50 px-2 py-2 transition-all ease-in-out hover:scale-95 {className}"
onclick={() => {
onClick();
openUpgradeModal();
}}
>
<ZapIcon size={16} class="filled" />
<p class="text-sm font-semibold text-blue-700">
{@render children?.()}
</p>
</button>
{/if}
@@ -1,227 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { Progress } from '@cio/ui/base/progress';
import CheckIcon from '@lucide/svelte/icons/check';
import { PLANS } from '@cio/utils/plans';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { toggleConfetti } from '$lib/components/Confetti/store';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
import Modal from '$lib/components/Modal/index.svelte';
import Confetti from '$lib/components/Confetti/index.svelte';
import StepDoneIcon from '$lib/components/Icons/StepDoneIcon.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
const disabledClass = 'bg-gray-300 text-gray-400 hover:cursor-not-allowed';
const planNames = Object.keys(PLANS);
let isLoadingPlan: string | null = $state(null);
let upgraded = $state(false);
let isYearlyPlan = $state(false);
const query = $derived(new URLSearchParams(page.url.search));
let open = $derived((query.get('upgrade') || '') === 'true');
let isConfirming = $derived((query.get('confirmation') || '') === 'true');
async function handleClick(plan, planName: string) {
if (plan.CTA.IS_DISABLED || !$profile.id || !$profile.email) {
return;
}
if (!plan.CTA.PRODUCT_ID && plan.CTA.DASHBOARD_LINK) {
window.open(plan.CTA.DASHBOARD_LINK, '_blank');
return;
}
isLoadingPlan = planName;
try {
const checkoutURL = new URL('/api/polar/subscribe', page.url);
checkoutURL.searchParams.set('productId', isYearlyPlan ? plan.CTA.PRODUCT_ID_YEARLY : plan.CTA.PRODUCT_ID);
checkoutURL.searchParams.set('customerEmail', $profile.email);
checkoutURL.searchParams.set('customerName', $profile.fullname);
checkoutURL.searchParams.set(
'metadata',
JSON.stringify({
triggeredBy: $currentOrg.roleId,
orgId: $currentOrg.id,
orgSlug: $currentOrg.siteName
})
);
window.location.href = checkoutURL.toString();
} catch (error) {
console.error('Error subscribing', error);
snackbar.error('snackbar.upgrade.failed');
}
isLoadingPlan = null;
}
function onUpgrade() {
untrack(() => {
upgraded = true;
});
toggleConfetti();
setTimeout(() => {
toggleConfetti();
}, 1000);
}
function onClose() {
const path = upgraded ? $currentOrgPath : page.url.pathname;
goto(path);
}
function onLearnMore() {
window.open('https://classroomio.com/blog/early-adopter', '_blank');
}
function toggleIsYearlyPlan() {
isYearlyPlan = !isYearlyPlan;
}
function handleUpgradeSuccess(upgradeSuccessful: boolean) {
if (upgradeSuccessful) {
onUpgrade();
}
}
$effect(() => {
handleUpgradeSuccess(Boolean($currentOrg.id && !$isFreePlan && open));
});
</script>
{#if upgraded}
<Confetti />
{/if}
<Modal
{onClose}
{open}
width="w-4/5"
maxWidth={upgraded || isConfirming ? 'max-w-[600px]' : undefined}
modalHeading={$t('pricing.modal.heading')}
containerClass="pt-4"
>
{#if upgraded}
<div class="relative mb-4 flex w-full flex-col items-center justify-center">
<StepDoneIcon />
<h4 class="text-2xl">{$t('pricing.modal.thanks')}</h4>
<p class="mb-4 text-center">
{$t('pricing.modal.plan')}
</p>
<div class="flex items-center gap-4">
<PrimaryButton label={$t('pricing.modal.close')} variant={VARIANTS.OUTLINED} onClick={onClose} />
<PrimaryButton label={$t('pricing.modal.learn')} variant={VARIANTS.CONTAINED_DARK} onClick={onLearnMore} />
</div>
</div>
{:else if isConfirming}
<div class="relative mb-4 flex w-full flex-col items-center justify-center">
<Progress />
</div>
{:else}
<div class="my-2 flex h-full flex-col items-center justify-center">
<div class="relative mb-2 flex items-center rounded-[30px] border p-1">
<button
style="background-color: {isYearlyPlan ? 'initial' : '#1D4EE2'}; color: {isYearlyPlan ? '#5e636b' : '#fff'}"
class="rounded-[30px] bg-blue-700 px-3 py-1 text-xs text-white transition-all duration-500 ease-in-out lg:px-4 lg:py-2"
onclick={toggleIsYearlyPlan}
>
{$t('pricing.modal.monthly')}
</button>
<button
style="background-color: {isYearlyPlan ? '#1D4EE2' : ''}; color: {isYearlyPlan ? '#fff' : '#5e636b'}"
class="relative rounded-[30px] px-3 py-1 text-xs text-white transition-all duration-500 ease-in-out lg:px-4 lg:py-2"
onclick={toggleIsYearlyPlan}
>
{$t('pricing.modal.annually')}
<div class="absolute -top-4 right-[-40%] rounded-full bg-[#006600] px-1.5 py-1 text-[0.7rem] text-white">
{$t('pricing.modal.save')}
</div>
</button>
</div>
<div class="isolate grid grid-cols-1 gap-3 overflow-y-scroll p-2 md:overflow-y-hidden lg:grid-cols-3">
{#each planNames as planName}
<div
class="max-w-xl rounded-xl {planName === 'EARLY_ADOPTER' &&
'cio-bg-blue'} 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'} lg:text-xl dark:text-white"
>
{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}
{isYearlyPlan ? PLANS[planName]?.PRICE?.YEARLY : PLANS[planName]?.PRICE?.MONTHLY}
</p>
<p
class=" mt-4 text-sm font-light leading-6 {planName === 'EARLY_ADOPTER'
? 'text-white'
: 'text-black'} lg:leading-6 dark:text-gray-300"
>
{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'} flex items-center justify-center py-3 text-center font-medium hover:no-underline lg:rounded-md lg:py-3 lg:text-lg lg:font-semibold"
onclick={() => {
if (isLoadingPlan === planName) return;
handleClick(PLANS[planName], planName);
}}
>
{#if isLoadingPlan === planName}
<Progress />
{: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>
<CheckIcon
class="custom mr-2 size-4 lg:mr-3 {planName === 'EARLY_ADOPTER'
? 'text-white'
: 'text-[#1D4EE2]'}"
/>
</div>
<p class="text-sm">
{features}
</p>
</li>
{/each}
</ul>
</div>
{/each}
</div>
</div>
{/if}
</Modal>
@@ -1,27 +0,0 @@
<script>
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import { t } from '$lib/utils/functions/translations';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
</script>
{#if $isFreePlan}
<a
href={`https://classroomio.com?utm_source=${$currentOrg.siteName}.classroomio.com`}
target="_blank"
class="group fixed bottom-14 right-9 z-50 hover:no-underline"
>
<span
class="flex items-center gap-1 overflow-hidden rounded-md border border-gray-50 bg-white px-2 py-1 text-sm font-medium text-black shadow-md transition duration-500 group-hover:border-blue-600 group-hover:bg-blue-600 group-hover:text-white dark:text-black"
>
<ArrowUpRightIcon
size={16}
class="
absolute left-3 -translate-x-full translate-y-full text-white
opacity-0 transition duration-500 group-hover:translate-x-0 group-hover:translate-y-0 group-hover:opacity-100"
/>
<img src="/logo-192.png" alt="logo" class="h-[24px] opacity-100 group-hover:opacity-0" />
{$t('course.navItem.landing_page.powered_by')} ClassroomIO
</span>
</a>
{/if}
@@ -3,7 +3,6 @@
import { onMount } from 'svelte';
import Tabs from '$lib/components/Tabs/index.svelte';
import { getSupabase } from '$lib/utils/functions/supabase';
import TabContent from '$lib/components/TabContent/index.svelte';
import { snackbar } from '../Snackbar/store';
import Modal from '$lib/components/Modal/index.svelte';
@@ -11,6 +10,7 @@
import { queryUnsplash } from './utils';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { t } from '$lib/utils/functions/translations';
import { uploadImage } from '$lib/utils/services/upload';
interface Props {
imageURL?: string;
@@ -19,8 +19,6 @@
let { imageURL = $bindable(''), onchange }: Props = $props();
const supabase = getSupabase();
const tabs = [
{ label: 'Unsplash', value: 'unsplash' },
{ label: 'Upload', value: 'upload' }
@@ -65,28 +63,20 @@
let reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
uploadImage(file);
handleUploadImage(file);
};
}
};
const uploadImage = async (image: File) => {
const handleUploadImage = async (image: File) => {
isUploading = true;
if (!image) {
return;
}
const filename = `uploadwidget/${Date.now()}` + image.name;
const { data } = await supabase.storage.from('avatars').upload(filename, image, {
cacheControl: '3600',
upsert: false
});
if (data) {
const { data: response } = await supabase.storage.from('avatars').getPublicUrl(filename);
imageURL = response.publicUrl;
imageURL = await uploadImage(image);
onchange?.(response.publicUrl);
}
onchange?.(imageURL);
isUploading = false;
snackbar.success(`snackbar.landing_page_settings.success.complete`);