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
+191 -11
View File
@@ -1,4 +1,6 @@
/*Write your global styles here, in PostCSS syntax */
@import url('https://fonts.googleapis.com/css2?family=Geist:[email protected]&display=swap');
@import 'tailwindcss';
@import '@cio/ui/output.css';
@@ -44,17 +46,6 @@ html .dark {
border: 2px solid var(--scrollbarBG);
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: revert;
font-weight: revert;
margin: 0.8em 0;
}
.prose h1,
.prose h2,
.prose h3,
@@ -809,6 +800,7 @@ svg.lucide-icon:not(.custom) {
--animate-roll: quizRoll 0.8s ease-in-out;
--animate-gather: audienceGather 0.7s ease-in-out;
--animate-setup-configure: setupConfigure 1s ease-in-out;
--animate-border: border 4s linear infinite;
}
@keyframes shakeAnnouncement {
@@ -1152,3 +1144,191 @@ svg.lucide-icon:not(.custom) {
transform: scale(1) rotate(0deg);
}
}
/* Animated Gradient Border */
@property --border-angle {
inherits: false;
initial-value: 0deg;
syntax: '<angle>';
}
@keyframes border {
to {
--border-angle: 360deg;
}
}
/* Animated Gradient Border Class */
.animate-gradient-border {
border: 1px solid transparent;
background:
linear-gradient(45deg, white, white) padding-box,
conic-gradient(
from var(--border-angle),
rgb(148 163 184 / 0.3) 75%,
rgb(37 99 235) 82%,
rgb(96 165 250) 88%,
rgb(37 99 235) 92%,
rgb(148 163 184 / 0.3) 100%
)
border-box;
transition: --border-angle 3s ease-in-out;
}
.animate-gradient-border:hover {
animation: border 4s ease-in-out infinite;
}
.dark .animate-gradient-border {
background:
linear-gradient(45deg, rgb(0 0 0), rgb(0 0 0)) padding-box,
conic-gradient(
from var(--border-angle),
rgb(71 85 105 / 0.48) 75%,
rgb(37 99 235) 82%,
rgb(96 165 250) 88%,
rgb(37 99 235) 92%,
rgb(71 85 105 / 0.48) 100%
)
border-box;
transition: --border-angle 3s ease-in-out;
}
.dark .animate-gradient-border:hover {
animation: border 4s ease-in-out infinite;
}
/**
Rocket launch animation.
**/
.rocket-launch {
transform-origin: center bottom;
transform-box: fill-box;
}
/* Full sequence runs once per hover */
.animate-icon:hover .rocket-launch {
animation: rocket-sequence 5s ease-in-out infinite;
}
/* Optional: flame flicker during pre-launch + launch */
.animate-icon:hover .rocket-launch path:first-of-type {
animation: flame-sequence 5s ease-in-out infinite;
}
/*
Rocket:
- 0–60% (~3s): jitter/pre-launch, small moves along nose direction
- 60–70%: launch along ~60° diagonal (up-right), fade out
- 70–80%: invisible pause
- 80–100%: re-enter from opposite side (down-left)
*/
@keyframes rocket-sequence {
0% {
transform: translate(0, 0) scale(1);
opacity: 1;
}
10% {
transform: translate(1px, -2px) scale(1.02);
opacity: 1;
}
20% {
transform: translate(0, 0) scale(1);
opacity: 1;
}
30% {
transform: translate(2px, -3px) scale(1.03);
opacity: 1;
}
40% {
transform: translate(0, 0) scale(1);
opacity: 1;
}
50% {
transform: translate(1px, -2px) scale(1.02);
opacity: 1;
}
60% {
transform: translate(0, 0) scale(1.05);
opacity: 1;
}
/* Launch along a ~60° diagonal: up (-Y) and to the right (+X) */
70% {
transform: translate(100%, -173%) scale(1.1);
opacity: 0;
}
/* Off-screen / invisible pause (~1s, 70–80%) */
80% {
/* Reposition off-screen in the opposite direction: down-left */
transform: translate(-100%, 173%) scale(1.1);
opacity: 0;
}
/* Fly back into view from below-left to original position */
100% {
transform: translate(0, 0) scale(1);
opacity: 1;
}
}
/*
Flame sequence:
- Moves opposite rocket travel (down-left: -X, +Y) to match exhaust
- No vertical-only scaling; just uniform scale + diagonal translation
- Visible 0–70%, then off
*/
@keyframes flame-sequence {
0% {
stroke: var(--main-primary-color);
opacity: 1;
transform: translate(-0.5px, 0.9px) scale(1.02);
}
10% {
opacity: 1;
transform: translate(0, 0) scale(1);
}
20% {
opacity: 1;
transform: translate(0, 0) scale(1.02);
}
30% {
opacity: 0.8;
transform: translate(-0.5px, 0.9px) scale(1);
}
40% {
opacity: 0.8;
transform: translate(0, 0) scale(1.02);
}
50% {
opacity: 1;
transform: translate(-0.5px, 0.9px) scale(1);
}
60% {
opacity: 0.8;
transform: translate(0, 0) scale(1.02);
}
70% {
opacity: 0;
transform: translate(-0.5px, 0.9px) scale(1.1);
}
100% {
opacity: 0;
transform: translate(-1px, 1.7px) scale(1.12);
}
}
@@ -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';
@@ -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,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,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="[email protected]"
/>
<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="[email protected]"
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,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>
@@ -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`);
@@ -1,13 +1,13 @@
import type { AccountOrg } from '$lib/features/app/types';
import type { Cookies } from '@sveltejs/kit';
import type { CurrentOrg } from '$lib/utils/types/org';
import { OrgApiServer } from '$lib/features/org/api/org.server';
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
import { blockedSubdomain } from '$lib/utils/constants/app';
import { env } from '$env/dynamic/private';
import { getCurrentOrg } from '$lib/utils/services/org';
export interface OrgSiteInfo {
isOrgSite: boolean;
org: CurrentOrg | null;
org: AccountOrg | null;
subdomain: string;
orgSiteName: string;
}
@@ -26,14 +26,14 @@ export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise<OrgSit
// Student dashboard
if (subdomain) {
const org = (await getCurrentOrg(subdomain, true)) || null;
const org = await OrgApiServer.getOrgBySiteName(subdomain);
// Organization by subdomain not found
if (!org) {
return response;
}
response.org = org;
response.org = org as AccountOrg;
response.isOrgSite = true;
response.orgSiteName = subdomain;
response.subdomain = subdomain;
@@ -59,12 +59,14 @@ export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise<OrgSit
// Custom domain
if (isURLCustomDomain(url)) {
response.org = (await getCurrentOrg(url.host, true, true)) || null;
const orgs = await OrgApiServer.getOrgsByCustomDomain(url.host, true);
if (!response.org) {
if (!orgs || orgs.length === 0) {
return response;
}
const org = orgs[0];
response.org = org as AccountOrg;
response.isOrgSite = true;
response.orgSiteName = response.org?.siteName || '';
response.subdomain = subdomain;
@@ -82,7 +84,11 @@ export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise<OrgSit
response.isOrgSite = debugMode || !!subdomain;
response.orgSiteName = debugMode ? _orgSiteName : subdomain;
response.org = (await getCurrentOrg(response.orgSiteName, true)) || null;
if (response.orgSiteName) {
const org = await OrgApiServer.getOrgBySiteName(response.orgSiteName);
response.org = (org as AccountOrg) || null;
}
const shouldDeleteCookie = !response.org && _orgSiteName;
if (shouldDeleteCookie) {
@@ -0,0 +1 @@
// Components exports will be added here when needed
@@ -0,0 +1,133 @@
<script lang="ts">
import * as Table from '@cio/ui/base/table';
import { page as pageStore } from '$app/state';
import { Skeleton } from '@cio/ui/base/skeleton';
import { Search } from '@cio/ui/custom/search';
import * as Pagination from '@cio/ui/base/pagination';
import UsersIcon from '@lucide/svelte/icons/users';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { t } from '$lib/utils/functions/translations';
import { Empty } from '@cio/ui/custom/empty';
import { UpgradeBanner } from '$lib/features/ui';
import { currentOrg, currentOrgMaxAudience } from '$lib/utils/store/org';
import Avatar from '$lib/components/Avatar/index.svelte';
import * as Page from '@cio/ui/base/page';
$effect(() => {
orgApi.getOrgAudience($currentOrg.id);
});
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
? orgApi.audience.filter((row) =>
Object.values(row).some((val) => String(val).toLowerCase().includes(searchValue.toLowerCase()))
)
: orgApi.audience
);
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>
{#if orgApi.audience.length >= $currentOrgMaxAudience}
<UpgradeBanner>{$t('audience.upgrade')}</UpgradeBanner>
{/if}
<Page.BodyHeader>
<Search placeholder="Search..." bind:value={searchValue} class="" />
</Page.BodyHeader>
{#if orgApi.audience.length || orgApi.isLoading}
<div class="w-full space-y-4">
<!-- Table -->
{#if orgApi.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>
{:else}
<Empty title={$t('audience.no_audience')} description={$t('audience.manage')} icon={UsersIcon} variant="page" />
{/if}
@@ -0,0 +1,2 @@
export { default as AudiencePage } from './audience.svelte';
export { default as UserAnalyticsPage } from './user-analytics.svelte';
@@ -0,0 +1,206 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { Badge } from '@cio/ui/base/badge';
import UnfoldVerticalIcon from '@lucide/svelte/icons/unfold-vertical';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import ChartLineIcon from '@lucide/svelte/icons/chart-line';
import Progress from '$lib/components/Progress/index.svelte';
import { ActivityCard, HeroProfileCard, LoadingPage } from '$lib/components/Analytics';
import { t } from '$lib/utils/functions/translations';
import { currentOrgPath } from '$lib/utils/store/org';
import { snackbar } from '$lib/components/Snackbar/store';
import { getAccessToken } from '$lib/utils/functions/supabase';
import type { UserAnalytics } from '$lib/utils/types/analytics';
let { data } = $props();
let userAnalytics: UserAnalytics | undefined = $state();
let courseFilter = $state('all');
function toggleCourseFilter(filter: 'completed' | 'incomplete') {
if (courseFilter === filter) {
courseFilter = 'all';
} else {
courseFilter = filter;
}
}
async function fetchUserAnalytics() {
const accessToken = await getAccessToken();
const response = await fetch('/api/analytics/user', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
},
body: JSON.stringify(data)
});
if (!response.ok) {
console.error(response);
snackbar.error('Failed to fetch analytics data');
}
userAnalytics = (await response.json()) as UserAnalytics;
}
onMount(() => {
fetchUserAnalytics();
});
let userMetrics = $derived([
{
icon: BookOpenIcon,
title: $t('analytics.enrolled_courses'),
description: $t('analytics.enrolled_courses_description'),
percentage: userAnalytics?.courses?.length || 0,
hidePercentage: true
},
{
icon: ChartLineIcon,
title: $t('analytics.overall_course_progress'),
description: $t('analytics.overall_course_progress_description'),
percentage: userAnalytics?.overallCourseProgress || 0
},
{
icon: UnfoldVerticalIcon,
title: $t('analytics.total_average_grade'),
description: $t('analytics.total_average_grade_description'),
percentage: userAnalytics?.overallAverageGrade || 0
}
]);
let completedCourses = $derived(
userAnalytics?.courses?.filter((course) => course.lessons_count === course.lessons_completed)?.length
);
let incompleteCourses = $derived(
userAnalytics?.courses?.filter((course) => course.lessons_count !== course.lessons_completed)?.length
);
let filteredCourses = $derived(
userAnalytics?.courses?.filter((course) => {
if (courseFilter === 'all') {
return true;
}
return (course.lessons_count === course.lessons_completed) === (courseFilter === 'completed');
})
);
</script>
{#if userAnalytics}
<div class="p-5">
<a class="text-md flex items-center text-gray-500 dark:text-white" href={`${$currentOrgPath}/audience`}>
<ArrowLeftIcon size={16} />
{$t('community.ask.go_back')}
</a>
</div>
<div class="px-5 py-1">
<HeroProfileCard user={userAnalytics!.user} />
<div class="mt-5 px-0">
<div class="mt-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each userMetrics as activity}
<ActivityCard {activity} />
{/each}
</div>
</div>
<div class="mt-5 rounded-md border p-3 md:p-5 dark:border-neutral-600">
<h3 class="text-2xl">
{$t('analytics.courses')}
</h3>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<p class="text-sm font-medium text-gray-600 dark:text-gray-200">
{$t('analytics.progress')}
</p>
<p class="text-sm font-medium text-gray-600 dark:text-gray-200">
{$t('analytics.progress_description', {
value: completedCourses,
total: userAnalytics!.courses.length
})}
</p>
</div>
<Progress value={userAnalytics!.overallCourseProgress} />
<div class="flex items-center justify-between">
<Badge
type={courseFilter === 'incomplete' ? 'secondary' : 'outline'}
class="text-yellow-700 dark:text-yellow-500"
onclick={() => toggleCourseFilter('incomplete')}
>
{incompleteCourses}
{$t('analytics.incomplete')}
</Badge>
<Badge
type={courseFilter === 'completed' ? 'secondary' : 'outline'}
class="text-green-700 dark:text-green-500"
onclick={() => toggleCourseFilter('completed')}
>
{completedCourses}
{$t('analytics.complete')}
</Badge>
</div>
</div>
{#each filteredCourses as course, index}
{#key index}
<div
class={`mt-5 w-full rounded-md border border-gray-200 p-5 ${
course.lessons_count === course.lessons_completed
? 'border-green-200 bg-green-50 dark:bg-green-100'
: 'border-yellow-200 bg-yellow-50 dark:bg-yellow-100'
}`}
transition:fade={{ duration: 300 }}
>
<div class="flex items-center justify-between gap-4">
<div class="flex w-4/5 items-center gap-4">
<img
src={course.logo || '/images/classroomio-course-img-template.jpg'}
alt={course.title}
class="h-20 w-24 rounded-md"
/>
<div class="mb-4 gap-4">
<a href={`/courses/${course.id}`}>
<p class="text-lg font-semibold text-gray-600">
{course.title}
</p>
</a>
<p class="line-clamp-2 text-sm text-gray-600">
{course.description}
</p>
</div>
</div>
<Badge
class={`${
course.lessons_count === course.lessons_completed
? 'bg-green-200 text-green-700'
: 'bg-yellow-200 text-yellow-700'
}`}
>
{course.lessons_count === course.lessons_completed
? $t('analytics.completed')
: $t('analytics.incomplete')}
</Badge>
</div>
<div class="flex w-full items-center gap-1">
<Progress value={course.progress_percentage} />
<p>{course.progress_percentage}%</p>
</div>
</div>
{/key}
{/each}
</div>
</div>
{:else}
<LoadingPage />
{/if}
@@ -4,13 +4,12 @@ import { ZChangeEmail, ZProfileUpdateForm } from '@cio/utils/validation/account'
import type { TProfileUpdateForm as TProfileUpdateFormSchema } from '@cio/utils/validation/account';
import { authClient } from '$lib/utils/services/auth/client';
import generateUUID from '$lib/utils/functions/generateUUID';
import { handleLocaleChange } from '$lib/utils/functions/translations';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { profile as profileStore } from '$lib/utils/store/user';
import { snackbar } from '$lib/components/Snackbar/store';
import { supabase } from '$lib/utils/functions/supabase';
import { t } from '$lib/utils/functions/translations';
import { uploadImage } from '$lib/utils/services/upload';
export interface TProfileUpdateForm extends Omit<TProfileUpdateFormSchema, 'avatarUrl'> {
avatar?: string | File | undefined;
@@ -37,24 +36,6 @@ export class ProfileApi extends BaseApiWithErrors {
return true;
}
private async uploadAvatar(avatar: File): Promise<string | undefined> {
const filename = `user/${generateUUID()}.webp`;
const { data: uploadData, error: uploadError } = await supabase.storage.from('avatars').upload(filename, avatar, {
cacheControl: '3600',
upsert: false
});
if (uploadError) throw uploadError;
if (uploadData) {
const { data: urlData } = await supabase.storage.from('avatars').getPublicUrl(filename);
return urlData.publicUrl;
}
return undefined;
}
private async updateUserInfo(
fields: TProfileUpdateForm,
currentProfile: TProfile,
@@ -124,11 +105,11 @@ export class ProfileApi extends BaseApiWithErrors {
const message = error instanceof Error ? error.message : `${error}`;
if (message.includes('profile_username_key')) {
this.errors.username = 'snackbar.lms.error.username_exists';
this.errors.username = t.get('snackbar.lms.error.username_exists');
snackbar.error('snackbar.lms.error.username_exists');
} else {
this.errors.general = message;
snackbar.error(`snackbar.lms.error.update: ${message}`);
snackbar.error(`${t.get('snackbar.update_failed')}: ${message}`);
}
}
@@ -145,7 +126,7 @@ export class ProfileApi extends BaseApiWithErrors {
// Handle avatar upload if provided
let avatarUrl: string | undefined;
if (fields.avatar instanceof File) {
avatarUrl = await this.uploadAvatar(fields.avatar);
avatarUrl = await uploadImage(fields.avatar);
}
// Update user info via Better Auth (name and image)
@@ -185,7 +166,7 @@ export class ProfileApi extends BaseApiWithErrors {
const { error } = await authClient.changeEmail({
newEmail: fields.newEmail,
callbackURL: window.location.origin
callbackURL: fields.callbackURL
});
if (error) throw new Error(error.message);
@@ -197,7 +178,7 @@ export class ProfileApi extends BaseApiWithErrors {
const message = error instanceof Error ? error.message : `${error}`;
this.errors.email = message;
snackbar.error(`${t.get('snackbar.lms.error.update')} ${message}`);
snackbar.error(`${t.get('snackbar.update_failed')}: ${message}`);
} finally {
this.isLoading = false;
}
@@ -0,0 +1,22 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import { goto } from '$app/navigation';
import { currentOrgPath } from '$lib/utils/store/org';
import { Button } from '@cio/ui/base/button';
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
import PlusIcon from '@lucide/svelte/icons/plus';
function askCommunity() {
goto(`${$currentOrgPath}/community/ask`);
}
</script>
{#if isMobileStore.current}
<Button onclick={askCommunity}>
<PlusIcon size={16} />
</Button>
{:else}
<Button onclick={askCommunity}>
{$t('community.ask_button')}
</Button>
{/if}
@@ -0,0 +1,22 @@
<script lang="ts">
import { Skeleton } from '@cio/ui/base/skeleton';
</script>
<div class="relative flex h-[70px] w-full rounded-md border p-2 transition ease-in-out dark:border-neutral-600">
<div class="mr-2 flex w-10 flex-col justify-evenly gap-2 rounded-md px-1">
<Skeleton class="h-2/6 w-full rounded-sm" />
<Skeleton class="h-2/6 w-full rounded-sm" />
<Skeleton class="h-2/6 w-full rounded-sm" />
</div>
<div class="flex w-9/12 flex-col gap-2 rounded-md">
<!-- first text -->
<Skeleton class="h-4 w-3/6 rounded-sm" />
<!-- second text -->
<Skeleton class="h-4 w-1/4 rounded-sm" />
</div>
<div class="ml-auto w-10 flex-col justify-evenly rounded-md px-1">
<Skeleton class="h-4 w-full rounded-sm" />
</div>
</div>
@@ -0,0 +1,3 @@
export { default as CommunityListLoader } from './community-list-loader.svelte';
export { default as CommunityDeleteModal } from './delete-modal.svelte';
export { default as AskCommunityButton } from './ask-community-button.svelte';
@@ -1,23 +1,26 @@
<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 { Search } from '@cio/ui/custom/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 { courses } from '$lib/features/course/utils/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 MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
import CommunityLoader from './Loader.svelte';
import Box from '$lib/components/Box/index.svelte';
import { CommunityListLoader } from '../components';
import Vote from '$lib/components/Vote/index.svelte';
import CoursesEmptyIcon from '$lib/components/Icons/CoursesEmptyIcon.svelte';
import { Empty } from '@cio/ui/custom/empty';
import * as Page from '@cio/ui/base/page';
import { AskCommunityButton } from '../components';
interface Props {
isLMS?: boolean;
@@ -105,42 +108,34 @@
);
</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}
<Page.BodyHeader>
<Search placeholder={$t('community.find_question')} bind:value={searchValue} />
<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>
</Page.BodyHeader>
{#if isLoading}
<CommunityListLoader />
<CommunityListLoader />
<CommunityListLoader />
<CommunityListLoader />
{:else}
{#each filteredDiscussions as discussion}
<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"
>
<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">
@@ -164,15 +159,18 @@
<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>
</div>
{:else}
<Empty
title={$t('community.no_question')}
description={$t('community.ask_a_question')}
icon={MessageSquareMoreIcon}
variant="page"
>
<AskCommunityButton />
</Empty>
{/each}
{/if}
<style>
h4 {
@@ -0,0 +1 @@
export { default as CommunityPage } from './community.svelte';
@@ -2,7 +2,7 @@
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">
<div class="mb-4 mr-4 h-[370px] w-72 rounded-md border p-2 transition ease-in-out">
<Skeleton class="h-[45%] w-full" />
<div class="flex h-[50%] flex-col justify-between pb-2 pt-4">
<!-- Title -->
@@ -15,7 +15,7 @@
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';
import { copyCourseModal, deleteCourseModal } from '$lib/features/course/utils/store';
interface Props {
bannerImage: string | undefined;
@@ -7,7 +7,7 @@
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 { copyCourseModalInitialState, copyCourseModal } from '$lib/features/course/utils/store';
import { classroomio } from '$lib/utils/services/api';
import generateSlug from '$lib/utils/functions/generateSlug';
import { currentOrg } from '$lib/utils/store/org';
@@ -0,0 +1,28 @@
<script lang="ts">
import { Button, type ButtonProps } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import { isOrgAdmin } from '$lib/utils/store/org';
import PlusIcon from '@lucide/svelte/icons/plus';
import { goto } from '$app/navigation';
import { currentOrgPath } from '$lib/utils/store/org';
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
let {
variant,
isResponsive = false
}: ButtonProps & {
isResponsive?: boolean;
} = $props();
function onClick() {
goto(`${$currentOrgPath}/courses?create=true`);
}
</script>
{#if isResponsive && isMobileStore.current}
<Button variant="outline" size="icon" disabled={!$isOrgAdmin} onclick={onClick}>
<PlusIcon size={16} />
</Button>
{:else}
<Button {variant} onclick={onClick} disabled={!$isOrgAdmin}>{$t('courses.heading_button')}</Button>
{/if}
@@ -0,0 +1,7 @@
export { default as CourseCard } from './card.svelte';
export { default as CourseCardLoader } from './card-loader.svelte';
export { default as CopyCourseModal } from './copy-course-modal.svelte';
export { default as CreateCourseButton } from './create-course-button.svelte';
export { default as CourseList } from './list.svelte';
export { default as CourseListLoader } from './list-loader.svelte';
export { default as NewCourseModal } from './new-course-modal.svelte';
@@ -5,7 +5,7 @@
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 { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
import { t } from '$lib/utils/functions/translations';
interface Props {
@@ -58,7 +58,7 @@
<Table.Cell class="truncate">
<p>{description}</p>
</Table.Cell>
{#if !$isMobile}
{#if !isMobileStore.current}
<Table.Cell class="truncate">{type}</Table.Cell>
<Table.Cell>{totalLessons}</Table.Cell>
<Table.Cell>{totalStudents}</Table.Cell>
@@ -4,8 +4,8 @@
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 { validateForm } from '../utils/functions';
import { courses, createCourseModal } from '../utils/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';
@@ -1,36 +1,72 @@
<script lang="ts">
import * as Table from '@cio/ui/base/table';
import { Empty } from '@cio/ui/custom/empty';
import { Search } from '@cio/ui/custom/search';
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
import * as Page from '@cio/ui/base/page';
import * as Select from '@cio/ui/base/select';
import GridIcon from '@lucide/svelte/icons/grid-2x2';
import ListIcon from '@lucide/svelte/icons/list';
import { IconButton } from '$lib/components/IconButton';
import {
CourseCard,
CourseCardLoader,
CreateCourseButton,
CourseList,
CopyCourseModal,
NewCourseModal
} from '$lib/features/course/components';
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';
import {
deleteCourseModal,
deleteCourseModalInitialState,
courses as coursesStore,
courseMetaDeta
} from '../utils/store';
import { browser } from '$app/environment';
interface Props {
courses?: Course[];
emptyTitle?: any;
emptyDescription?: any;
isExplore?: boolean;
searchValue?: string;
selectedId?: string;
}
let {
courses = [],
emptyTitle = $t('courses.course_card.empty_title'),
emptyDescription = $t('courses.course_card.empty_description'),
isExplore = false
isExplore = false,
searchValue = $bindable(''),
selectedId = $bindable('0')
}: Props = $props();
const filterOptions = $derived([
{ value: '0', label: $t('courses.course_filter.date_created') },
{ value: '1', label: $t('courses.course_filter.published') },
{ value: '2', label: $t('courses.course_filter.lessons') }
]);
const selectedLabel = $derived(
filterOptions.find((opt) => opt.value === selectedId)?.label || filterOptions[0].label
);
const setViewPreference = (preference: 'grid' | 'list') => {
$courseMetaDeta.view = preference;
if (browser) {
localStorage.setItem('courseView', preference);
}
};
function calcProgressRate(progressRate?: number, totalLessons?: number): number {
if (!progressRate || !totalLessons) {
return 0;
@@ -65,20 +101,50 @@
}
</script>
<NewCourseModal />
<CopyCourseModal />
<DeleteModal
onDelete={handleDeleteCourse}
bind:open={$deleteCourseModal.open}
isLoading={$deleteCourseModal.isDeleting}
/>
<div class="mx-auto my-4 w-full">
<Page.BodyHeader align="right">
<Search placeholder={$t('courses.search_placeholder')} bind:value={searchValue} />
<Select.Root type="single" bind:value={selectedId}>
<Select.Trigger class="min-w-[150px]">
<p>{selectedId ? selectedLabel : filterOptions[0].label}</p>
</Select.Trigger>
<Select.Content>
{#each filterOptions as option}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if $courseMetaDeta.view === 'list'}
<IconButton onClick={() => setViewPreference('grid')}>
<GridIcon size={16} />
</IconButton>
{:else}
<IconButton onClick={() => setViewPreference('list')}>
<ListIcon size={16} />
</IconButton>
{/if}
</Page.BodyHeader>
<div class="mx-auto w-full flex-1">
{#if $courseMetaDeta.isLoading}
<section class={`${$courseMetaDeta.isLoading || courses ? 'cards-container' : ''} `}>
<CardLoader />
<CardLoader />
<CardLoader />
<CourseCardLoader />
<CourseCardLoader />
<CourseCardLoader />
</section>
{:else if !courses.length}
<Empty title={emptyTitle} description={emptyDescription} icon={LibraryBigIcon} variant="page">
<CreateCourseButton isResponsive />
</Empty>
{:else if $courseMetaDeta.view === 'list'}
<div class="w-full overflow-hidden rounded-md border">
<Table.Root class="w-full table-fixed">
@@ -95,7 +161,7 @@
</Table.Header>
<Table.Body>
{#each courses as courseData}
<List
<CourseList
id={courseData.id}
title={courseData.title}
type={$t(`course.navItem.settings.${courseData.type.toLowerCase()}`)}
@@ -112,7 +178,7 @@
<section class={`relative ${$courseMetaDeta.isLoading || courses ? 'cards-container' : ''} `}>
{#each courses as courseData}
{#key courseData.id}
<Card
<CourseCard
id={courseData.id}
slug={courseData.slug}
bannerImage={courseData.logo || '/images/classroomio-course-img-template.jpg'}
@@ -132,12 +198,3 @@
</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}
@@ -0,0 +1 @@
export { default as CoursesPage } from './courses.svelte';
@@ -0,0 +1,77 @@
import type { TCancelOrgPlan, TCreateOrgPlan, TUpdateOrgPlan } from '@cio/utils/validation/organization';
import { classroomio } from '$lib/utils/services/api';
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
/**
* Server-side API methods for organization plan operations
* These methods use API key authentication and should only be used in server-side files
* (+server.ts, +layout.server.ts) to prevent API keys from being exposed to the client.
*/
export class OrgPlanApiServer {
/**
* Creates a new organization plan (server-side)
* @param params Organization plan creation parameters
* @returns Response data or null on error
*/
static async createOrgPlan(params: TCreateOrgPlan) {
try {
const response = await classroomio.organization.plan.$post(
{
json: params
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success ? data.data : null;
} catch (error) {
console.error('Error creating org plan (server):', error);
return null;
}
}
/**
* Updates an organization plan (server-side)
* @param params Update parameters (subscriptionId, payload)
* @returns Response data or null on error
*/
static async updateOrgPlan(params: TUpdateOrgPlan) {
try {
const response = await classroomio.organization.plan.$put(
{
json: params
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success ? data.data : null;
} catch (error) {
console.error('Error updating org plan (server):', error);
return null;
}
}
/**
* Cancels an organization plan (server-side)
* @param params Cancel parameters (subscriptionId, payload)
* @returns Response data or null on error
*/
static async cancelOrgPlan(params: TCancelOrgPlan) {
try {
const response = await classroomio.organization.plan.cancel.$post(
{
json: params
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success ? data.data : null;
} catch (error) {
console.error('Error canceling org plan (server):', error);
return null;
}
}
}
@@ -1,9 +1,9 @@
import { BaseApi, classroomio } from '$lib/utils/services/api';
import type { TNewOrganizationPlan } from '@cio/db/types';
import type { TCancelOrgPlan, TCreateOrgPlan, TUpdateOrgPlan } from '@cio/utils/validation/organization';
/**
* API class for organization plan operations
* API class for organization plan operations (client-side)
* For server-side operations, use OrgPlanApiServer from './org-plan.server.ts'
*/
class OrgPlanApi extends BaseApi {
/**
@@ -11,7 +11,7 @@ class OrgPlanApi extends BaseApi {
* @param params Organization plan creation parameters
* @returns Created organization plan
*/
async createOrgPlan(params: TNewOrganizationPlan) {
async createOrgPlan(params: TCreateOrgPlan) {
return this.execute({
requestFn: () => classroomio.organization.plan.$post({ json: params }),
logContext: 'creating organization plan'
@@ -23,7 +23,7 @@ class OrgPlanApi extends BaseApi {
* @param params Update parameters (subscriptionId, payload)
* @returns Updated organization plan
*/
async updateOrgPlan(params: { subscriptionId: string; payload: Record<string, unknown> }) {
async updateOrgPlan(params: TUpdateOrgPlan) {
return this.execute({
requestFn: () => classroomio.organization.plan.$put({ json: params }),
logContext: 'updating organization plan'
@@ -35,7 +35,7 @@ class OrgPlanApi extends BaseApi {
* @param params Cancel parameters (subscriptionId, payload)
* @returns Updated organization plan
*/
async cancelOrgPlan(params: { subscriptionId: string; payload: Record<string, unknown> }) {
async cancelOrgPlan(params: TCancelOrgPlan) {
return this.execute({
requestFn: () => classroomio.organization.plan.cancel.$post({ json: params }),
logContext: 'canceling organization plan'
@@ -0,0 +1,57 @@
import { classroomio } from '$lib/utils/services/api';
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
/**
* Server-side API methods for organization operations
* These methods use API key authentication and should only be used in server-side files
* (+server.ts, +layout.server.ts) to prevent API keys from being exposed to the client.
*/
export class OrgApiServer {
/**
* Gets organization by siteName (server-side)
* @param siteName Organization site name
* @returns Organization or null
*/
static async getOrgBySiteName(siteName: string) {
try {
const response = await classroomio.organization.$get(
{
query: { siteName }
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success && data.data && data.data.length > 0 ? data.data[0] : null;
} catch (error) {
console.error('Error fetching organization by siteName (server):', error);
return null;
}
}
/**
* Gets organizations by custom domain (server-side)
* @param customDomain Custom domain
* @param isCustomDomainVerified Whether the domain is verified
* @returns Array of organizations
*/
static async getOrgsByCustomDomain(customDomain: string, isCustomDomainVerified = true) {
try {
const response = await classroomio.organization.$get(
{
query: {
customDomain,
isCustomDomainVerified
}
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success && data.data ? data.data : [];
} catch (error) {
console.error('Error fetching organizations by custom domain (server):', error);
return [];
}
}
}
@@ -1,20 +1,45 @@
import { BaseApi, classroomio } from '$lib/utils/services/api';
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type {
CoursesByOrganizationSiteName,
DeleteTeamResponse,
GetAudienceResponse,
GetCoursesBySiteNameResponse,
InviteTeamResponse,
OrganizationAudience,
OrganizationTeamMembers
} from '../utils/types';
import type { TCreateOrganization, TGetOrganizations, TUpdateOrganization } from '@cio/utils/validation/organization';
import { ZCreateOrganization, ZUpdateOrganization } from '@cio/utils/validation/organization';
import { currentOrg, orgs } from '$lib/utils/store/org';
import type { AccountOrg } from '$lib/features/app/types';
import type { GetTeamResponse } from '../utils/types';
import type { TGetOrganizations } from '@cio/utils/validation/organization';
import { orgs } from '$lib/utils/store/org';
import { ROLE } from '@cio/utils/constants';
import { ROLE_LABEL } from '$lib/utils/constants/roles';
import type { UpdateOrganizationRequestType } from './types';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { resolve } from '$app/paths';
import { snackbar } from '$lib/components/Snackbar/store';
import { t } from '$lib/utils/functions/translations';
import { uploadImage } from '$lib/utils/services/upload';
export interface TOrgUpdateForm {
name?: string;
avatar?: string | File | undefined;
theme?: string;
landingpage?: AccountOrg['landingpage'];
siteName?: string;
customDomain?: string | null;
isCustomDomainVerified?: boolean;
customization?: AccountOrg['customization'];
}
/**
* API class for organization operations
*/
class OrgApi extends BaseApi {
class OrgApi extends BaseApiWithErrors {
teamMembers = $state<OrganizationTeamMembers>([]);
audience = $state<OrganizationAudience>([]);
orgSiteCourses = $state<CoursesByOrganizationSiteName>([]);
@@ -32,7 +57,16 @@ class OrgApi extends BaseApi {
}),
logContext: 'fetching organization team',
onSuccess: (response) => {
this.teamMembers = response.data;
// Map API response to include role and isAdmin directly in the API layer
const mappedTeam = response.data.map((member) => ({
...member,
role: ROLE_LABEL[member.roleId] || '',
isAdmin: member.roleId === ROLE.ADMIN,
verified: member.verified ?? false
}));
// Update both API state and store
this.teamMembers = mappedTeam;
}
});
}
@@ -42,7 +76,9 @@ class OrgApi extends BaseApi {
* @param orgId Organization ID
* @returns Audience array
*/
async getOrgAudience(orgId: string) {
async getOrgAudience(orgId?: string) {
if (!orgId) return;
return this.execute<GetAudienceResponse>({
requestFn: () =>
classroomio.organization[':orgId'].audience.$get({
@@ -103,6 +139,186 @@ class OrgApi extends BaseApi {
return result;
}
/**
* Creates a new organization with the current user as owner
* @param fields Organization creation data (name, siteName)
*/
async create(fields: TCreateOrganization) {
const result = ZCreateOrganization.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'organization');
return;
}
await this.execute<typeof classroomio.organization.$post>({
requestFn: () =>
classroomio.organization.$post({
json: result.data
}),
logContext: 'creating organization',
onSuccess: (response) => {
if (!response.data) {
return;
}
const { organization, organizations } = response.data;
// Update stores
orgs.set(organizations);
const createdOrg = organizations.find((org) => org.id === organization.id);
if (createdOrg) {
currentOrg.set(createdOrg);
}
snackbar.success('snackbar.success_update');
goto(resolve(`/org/${organization.siteName}`, {}));
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error(result);
return;
}
if ('error' in result && 'field' in result) {
// Field-specific error automatically mapped to this.errors[field]
this.errors[result.field as string] = result.error;
} else if ('error' in result) {
this.errors.general = result.error;
}
}
});
}
/**
* Updates an organization
* @param orgId Organization ID
* @param fields Organization update data (name, avatar, theme, landingpage)
*/
async update(orgId: string, fields: TOrgUpdateForm) {
const result = ZUpdateOrganization.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'organization');
return;
}
this.isLoading = true;
// Handle avatar upload if provided
let avatarUrl: string | undefined;
if (fields.avatar instanceof File) {
avatarUrl = await uploadImage(fields.avatar);
} else if (typeof fields.avatar === 'string') {
avatarUrl = fields.avatar;
}
// Build update payload
fields.avatar = undefined;
const updates: TUpdateOrganization = {
...fields,
avatarUrl
};
await this.execute<UpdateOrganizationRequestType>({
requestFn: () =>
classroomio.organization[':orgId'].$put({
param: { orgId },
// @ts-expect-error - the json type is not inferred correctly
json: updates
}),
logContext: 'updating organization',
onSuccess: (response) => {
if (!response.data) {
return;
}
orgs.update((_orgs) =>
_orgs.map((org) => {
if (org.id === orgId) {
return { ...org, ...response.data };
}
return org;
})
);
const currentOrgData = get(currentOrg);
if (currentOrgData.id === orgId) {
currentOrg.update((org) => ({
...org,
...response.data
}));
}
snackbar.success('snackbar.course_settings.success.update_successful');
this.success = true;
this.errors = {};
},
onError: (error) => {
console.error('Error updating organization:', error);
const message = error instanceof Error ? error.message : `${error}`;
this.errors.general = message;
snackbar.error(`${t.get('snackbar.update_failed')}: ${message}`);
}
});
}
/**
* Invites team members to the organization
* @param orgId Organization ID
* @param emails Array of email addresses
* @param roleId Role ID (ADMIN or TUTOR)
*/
async inviteTeamMembers(orgId: string, emails: string[], roleId: number) {
return this.execute<InviteTeamResponse>({
requestFn: () =>
classroomio.organization[':orgId'].team.invite.$post({
param: { orgId },
// @ts-expect-error - the json type is not inferred correctly
json: { emails, roleId }
}),
logContext: 'inviting team members',
onSuccess: () => {
this.getOrgTeam(orgId);
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error(result);
return;
}
if ('error' in result && 'field' in result) {
this.errors[result.field as string] = result.error;
}
}
});
}
/**
* Removes a team member from the organization
* @param orgId Organization ID
* @param memberId Member ID to remove
*/
async removeTeamMember(orgId: string, memberId: number) {
return this.execute<DeleteTeamResponse>({
requestFn: () =>
classroomio.organization[':orgId'].team[':memberId'].$delete({
param: { orgId, memberId: memberId.toString() }
}),
logContext: 'removing team member',
onSuccess: () => {
this.getOrgTeam(orgId);
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error(result);
}
}
});
}
}
export const orgApi = new OrgApi();
@@ -0,0 +1,5 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type UpdateOrganizationRequestType = (typeof classroomio.organization)[':orgId']['$put'];
export type UpdateOrganizationResponse = InferResponseType<UpdateOrganizationRequestType>;
export type UpdateOrganizationSuccess = Extract<UpdateOrganizationResponse, { success: true }>;
@@ -5,7 +5,16 @@ export type GetTeamResponse = (typeof classroomio.organization)[':orgId']['team'
export type OrganizationTeamResponse = InferResponseType<GetTeamResponse> | null;
export type OrganizationTeamSuccess = Extract<InferResponseType<GetTeamResponse>, { success: true }>;
export type OrganizationTeamMembers = OrganizationTeamSuccess['data'];
export type OrganizationTeamMembersRaw = OrganizationTeamSuccess['data'];
// Mapped team members with role and isAdmin (mapped in API layer)
export type OrganizationTeamMembers = Array<
OrganizationTeamMembersRaw[number] & {
role: string;
isAdmin: boolean;
verified: boolean;
}
>;
// Audience types
export type GetAudienceResponse = (typeof classroomio.organization)[':orgId']['audience']['$get'];
@@ -23,3 +32,10 @@ export type CoursesByOrganizationSiteNameSuccess = Extract<
>;
export type CoursesByOrganizationSiteName = CoursesByOrganizationSiteNameSuccess['data'];
export type InviteTeamResponse = (typeof classroomio.organization)[':orgId']['team']['invite']['$post'];
export type InviteTeamSuccess = Extract<InferResponseType<InviteTeamResponse>, { success: true }>;
export type InviteTeamData = InviteTeamSuccess['data'];
export type DeleteTeamResponse = (typeof classroomio.organization)[':orgId']['team'][':memberId']['$delete'];
export type DeleteTeamSuccess = Extract<InferResponseType<DeleteTeamResponse>, { success: true }>;
@@ -0,0 +1,41 @@
<script lang="ts">
interface Props {
sm?: number;
md?: number;
lg?: number;
class?: string;
children?: import('svelte').Snippet;
}
let { sm = 8, md = 8, lg = 8, class: className = '', children }: Props = $props();
const getColSpan = (cols: number) => {
const spanMap = {
1: 'col-span-1',
2: 'col-span-2',
3: 'col-span-3',
4: 'col-span-4',
5: 'col-span-5',
6: 'col-span-6',
7: 'col-span-7',
8: 'col-span-8',
9: 'col-span-9',
10: 'col-span-10',
11: 'col-span-11',
12: 'col-span-12',
13: 'col-span-13',
14: 'col-span-14',
15: 'col-span-15',
16: 'col-span-16'
};
return spanMap[cols] || 'col-span-full';
};
const smClass = getColSpan(sm);
const mdClass = `md:${getColSpan(md)}`;
const lgClass = `lg:${getColSpan(lg)}`;
</script>
<div class="{smClass} {mdClass} {lgClass} {className} grid">
{@render children?.()}
</div>
@@ -0,0 +1,12 @@
<script lang="ts">
interface Props {
class?: string;
children?: import('svelte').Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<div class="w-full {className}">
{@render children?.()}
</div>
@@ -0,0 +1,13 @@
<script lang="ts">
interface Props {
id?: string;
class?: string;
children?: import('svelte').Snippet;
}
let { id, class: className = '', children }: Props = $props();
</script>
<div {id} class="lg:grid-cols-16 grid grid-cols-1 gap-5 px-3 {className}">
{@render children?.()}
</div>
@@ -0,0 +1,3 @@
export { default as Grid } from './Grid.svelte';
export { default as Row } from './Row.svelte';
export { default as Column } from './Column.svelte';
@@ -0,0 +1,2 @@
export { default as LanguagePicker } from './language-picker.svelte';
export * from './Layout';
@@ -0,0 +1,39 @@
<script lang="ts">
import { Label } from '@cio/ui/base/label';
import * as Select from '@cio/ui/base/select';
import type { TLocale } from '@cio/db/types';
import { LANGUAGES } from '$lib/utils/constants/translation';
import { t, initialized } from '$lib/utils/functions/translations';
interface Props {
className?: string;
value?: TLocale;
hasLangChanged?: boolean;
change?: () => void;
}
let { className = '', value = $bindable('en'), hasLangChanged = $bindable(false), change }: Props = $props();
function handleSelect(selectedValue: string) {
value = selectedValue as TLocale;
hasLangChanged = true;
change?.();
}
</script>
{#if $initialized}
<div class={className}>
<Label class="mb-2 block">{$t('content.toggle_label')}</Label>
<Select.Root type="single" bind:value onValueChange={handleSelect}>
<Select.Trigger class="w-full">
<p>{value ? LANGUAGES.find((lang) => lang.id === value)?.text : 'Pick a Language'}</p>
</Select.Trigger>
<Select.Content>
{#each LANGUAGES as language}
<Select.Item value={language.id}>{language.text}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
@@ -0,0 +1,80 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { currentOrgPlan, isFreePlan } from '$lib/utils/store/org';
import HourglassIcon from '@lucide/svelte/icons/hourglass';
import { openUpgradeModal } from '$lib/utils/functions/org';
import ReceiptIcon from '@lucide/svelte/icons/receipt';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import { Button } from '@cio/ui/base/button';
import * as Item from '@cio/ui/base/item';
let isRedirecting = $state(false);
async function onOpenBilling() {
console.log({
plan: $currentOrgPlan
});
try {
if ($currentOrgPlan?.provider === 'polar') {
const url = new URL('/api/polar/portal', window.location.origin);
url.searchParams.set('customerId', $currentOrgPlan?.customerId || '');
window.open(url.toString(), '_blank');
}
if ($currentOrgPlan?.provider === 'lmz') {
isRedirecting = true;
const url = new URL('/api/lmz/subscription', window.location.origin);
url.searchParams.set('subscriptionId', $currentOrgPlan?.subscriptionId);
const response = await fetch(url.toString());
const result = await response.json();
isRedirecting = false;
window.open(result.customerPortal, '_blank');
}
} catch (error) {
isRedirecting = false;
console.error(error);
snackbar.error($t('settings.billing.error'));
}
}
</script>
<div class="max-w-lg! flex w-full flex-col gap-4 px-2">
<p class="text-md font-medium">{$t('settings.billing.manage')}</p>
{#if $isFreePlan}
<Item.Root variant="outline">
<Item.Media variant="icon">
<HourglassIcon />
</Item.Media>
<Item.Content>
<Item.Description>{$t('settings.billing.active')}</Item.Description>
</Item.Content>
<Item.Actions>
<Button size="sm" variant="outline" onclick={openUpgradeModal}>Upgrade</Button>
</Item.Actions>
</Item.Root>
{:else}
<Item.Root variant="outline">
<Item.Media variant="icon">
<ReceiptIcon />
</Item.Media>
<Item.Content>
<Item.Description>{$t('settings.billing.provider')}</Item.Description>
</Item.Content>
<Item.Actions>
<Button size="sm" variant="outline" onclick={onOpenBilling} loading={isRedirecting}>
<ExternalLinkIcon class="size-4" />
{$t('settings.billing.open_billing')}
</Button>
</Item.Actions>
</Item.Root>
{/if}
</div>
@@ -0,0 +1,127 @@
<script lang="ts">
import { Switch } from '@cio/ui/base/switch';
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { handleOpenWidget } from '$lib/components/CourseLandingPage/store';
import { Button } from '@cio/ui/base/button';
import { Input } from '@cio/ui/base/input';
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
import * as Field from '@cio/ui/base/field';
let widgetKey = $state('');
function widgetControl(key: string) {
widgetKey = key;
$handleOpenWidget.open = true;
}
export async function handleSave() {
await orgApi.update($currentOrg.id, {
customization: $currentOrg.customization
});
}
</script>
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Legend>{$t('components.settings.customize_lms.dashboard.title')}</Field.Legend>
<Field.Group>
<Field.Field orientation="horizontal">
<Field.Label>{$t('components.settings.customize_lms.dashboard.community')}</Field.Label>
<Switch bind:checked={$currentOrg.customization.dashboard.community} />
<Field.Description class="text-sm">
{$currentOrg.customization.dashboard.community
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Field.Description>
</Field.Field>
<Field.Field orientation="horizontal">
<Field.Label>{$t('components.settings.customize_lms.dashboard.exercises')}</Field.Label>
<Switch bind:checked={$currentOrg.customization.dashboard.exercise} />
<Field.Description class="text-sm">
{$currentOrg.customization.dashboard.exercise
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Field.Description>
</Field.Field>
<Field.Field>
<Field.Label>{$t('components.settings.customize_lms.dashboard.banner_image')}</Field.Label>
<Button variant="outline" onclick={() => widgetControl('banner-image')}>
{$t('components.settings.customize_lms.dashboard.banner_image_btn')}
</Button>
{#if $currentOrg.customization.dashboard.bannerImage}
<img alt="Banner" src={$currentOrg.customization.dashboard.bannerImage} class="mt-2 w-full rounded-md" />
{/if}
{#if $handleOpenWidget.open && widgetKey === 'banner-image'}
<UploadWidget bind:imageURL={$currentOrg.customization.dashboard.bannerImage} />
{/if}
</Field.Field>
<Field.Field>
<Field.Label>{$t('components.settings.customize_lms.dashboard.banner_text')}</Field.Label>
<Input
placeholder={$t('components.settings.customize_lms.dashboard.banner_text_placeholder')}
bind:value={$currentOrg.customization.dashboard.bannerText}
/>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('components.settings.customize_lms.course.title')}</Field.Legend>
<Field.Group>
<Field.Field orientation="horizontal">
<Field.Label>{$t('components.settings.customize_lms.course.newsfeed')}</Field.Label>
<Switch bind:checked={$currentOrg.customization.course.newsfeed} />
<Field.Description class="text-gray-600">
{$currentOrg.customization.course.newsfeed
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Field.Description>
</Field.Field>
<Field.Field orientation="horizontal">
<Field.Label>{$t('components.settings.customize_lms.course.grading')}</Field.Label>
<Switch bind:checked={$currentOrg.customization.course.grading} />
<Field.Description class="text-gray-600">
{$currentOrg.customization.course.grading
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Field.Description>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('components.settings.customize_lms.apps.title')}</Field.Legend>
<Field.Group>
<Field.Field orientation="horizontal">
<Field.Label>{$t('components.settings.customize_lms.apps.poll')}</Field.Label>
<Switch bind:checked={$currentOrg.customization.apps.poll} />
<Field.Description class="text-gray-600">
{$currentOrg.customization.apps.poll
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Field.Description>
</Field.Field>
<Field.Field orientation="horizontal">
<Field.Label>{$t('components.settings.customize_lms.apps.live_comment')}</Field.Label>
<Switch bind:checked={$currentOrg.customization.apps.comments} />
<Field.Description class="text-gray-600">
{$currentOrg.customization.apps.comments
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Field.Description>
</Field.Field>
</Field.Group>
</Field.Set>
</Field.Group>
@@ -0,0 +1,360 @@
<script lang="ts">
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import TrashIcon from '@lucide/svelte/icons/trash';
import Copy from '@lucide/svelte/icons/copy';
import isValidDomain from 'is-valid-domain';
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { parse } from 'tldts';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { blockedSubdomain } from '$lib/utils/constants/app';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import { copyToClipboard } from '$lib/utils/functions/formatYoutubeVideo';
import { updateOrgSiteNameValidation } from '$lib/utils/functions/validator';
import { sanitizeDomain, sendDomainRequest } from '$lib/utils/functions/domain';
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
import { Textarea } from '@cio/ui/base/textarea';
import { DomainInput } from '@cio/ui/custom/domain-input';
import { ComingSoon } from '$lib/features/ui';
import { UpgradeBanner } from '$lib/features/ui';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import VisitOrgSiteButton from '$lib/components/Buttons/VisitOrgSite.svelte';
import * as Field from '@cio/ui/base/field';
let siteName = $derived($currentOrg.siteName);
let customDomain = $state('');
let customCode = $state('');
let favicon = $state('');
let isLoading = $state(false);
let isCustomDomainLoading = $state(false);
let isRefreshing = $state(false);
const isDomainValid = $derived(isValidDomain(sanitizeDomain(customDomain), { subdomain: true }));
type Error = {
siteName: string;
customDomain: string;
};
let errors: Error = $state({
siteName: '',
customDomain: ''
});
async function handleSaveSiteName() {
errors = updateOrgSiteNameValidation(siteName) as Error;
if (Object.values(errors).length) {
isLoading = false;
return;
}
if (blockedSubdomain.includes(siteName || '')) {
errors.siteName = 'Sitename already exists.';
return;
}
isLoading = true;
await orgApi.update($currentOrg.id, {
siteName
});
if (orgApi.success) {
$currentOrg.siteName = siteName;
goto(`/org/${$currentOrg.siteName}/settings/domains`);
} else {
if (orgApi.errors.general) {
errors.siteName = orgApi.errors.general;
} else {
errors.siteName = $t('add_org.sitename');
}
}
isLoading = false;
}
async function handleSaveCustomDomain() {
if (!isDomainValid) return;
const sanitizedDomain = sanitizeDomain(customDomain);
const details = parse(sanitizedDomain);
if (!details.subdomain) {
errors.customDomain = $t('components.settings.domains.custom_domain_error');
return;
}
if (sanitizedDomain.includes('classroomio.com')) {
errors.customDomain = $t('components.settings.domains.custom_domain_not_classroomio');
return;
}
isCustomDomainLoading = true;
await orgApi.update($currentOrg.id, {
customDomain: sanitizedDomain
});
if (!orgApi.success) {
errors.customDomain = orgApi.errors.general || 'Failed to update custom domain';
isCustomDomainLoading = false;
return;
}
try {
const response = await sendDomainRequest('add_domain', sanitizedDomain);
const data = await response.json();
console.log('added domain to vercel', data);
} catch (error) {
console.log('Error: adding domain to vercel', error);
snackbar.error(error as string);
isCustomDomainLoading = false;
return;
}
$currentOrg.customDomain = sanitizedDomain;
isCustomDomainLoading = false;
}
async function handleRemoveCustomDomain() {
if (!$currentOrg.customDomain) return;
isCustomDomainLoading = true;
await orgApi.update($currentOrg.id, {
customDomain: null,
isCustomDomainVerified: false
});
if (!orgApi.success) {
snackbar.error(orgApi.errors.general || 'Failed to remove custom domain');
isCustomDomainLoading = false;
return;
}
try {
const response = await sendDomainRequest('remove_domain', $currentOrg.customDomain);
const data = await response.json();
if (!data.success) {
throw new Error(data.message);
}
console.log('removed domain from vercel', data);
} catch (error) {
console.log('Error: removing domain from vercel', error);
snackbar.error(error as string);
isCustomDomainLoading = false;
return;
}
$currentOrg.customDomain = '';
$currentOrg.isCustomDomainVerified = false;
isCustomDomainLoading = false;
}
async function handleRefreshCustomDomain() {
isRefreshing = true;
try {
const response = await sendDomainRequest('verify_domain', $currentOrg.customDomain || '');
const data = await response.json();
console.log('data', data);
if (data.verified && !$currentOrg.isCustomDomainVerified) {
await orgApi.update($currentOrg.id, {
isCustomDomainVerified: true
});
if (orgApi.success) {
$currentOrg.isCustomDomainVerified = true;
}
}
} catch (error) {
console.log('Error: refreshing domain', error);
snackbar.error(error as string);
}
isRefreshing = false;
}
function resetErrors(_siteName: string, _customDomain: string) {
untrack(() => {
if (errors.siteName) {
errors.siteName = '';
}
if (errors.customDomain) {
errors.customDomain = '';
}
});
}
function getSubdomain() {
const details = parse($currentOrg.customDomain || '');
return details.subdomain;
}
$effect(() => {
resetErrors(siteName, customDomain);
});
</script>
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Legend>{$t('components.settings.domains.add')}</Field.Legend>
<Field.Description class="">{$t('settings.organization.organization_profile.custom_domain.body')}</Field.Description
>
<Field.Field>
<Field.Label>URL</Field.Label>
<DomainInput bind:value={siteName} placeholder="myschool" prefix="https://" suffix=".classroomio.com" />
{#if errors.siteName}
<Field.Error>{errors.siteName}</Field.Error>
{/if}
</Field.Field>
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={handleSaveSiteName} disabled={isLoading}>
{$t('components.settings.domains.update')}
</Button>
<VisitOrgSiteButton />
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('components.settings.domains.custom')}</Field.Legend>
<UpgradeBanner>{$t('upgrade.domain')}</UpgradeBanner>
<Field.Group>
{#if $currentOrg.customDomain}
<Field.Field>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<p class="text-md flex items-center gap-2 font-medium">
{$currentOrg.customDomain}
<Button variant="outline" size="icon-sm" onclick={() => goto(`https://${$currentOrg.customDomain}`)}>
<ArrowUpRightIcon size={16} />
</Button>
</p>
<div
class="mt-1 h-2 w-2 rounded-full bg-{$currentOrg.isCustomDomainVerified ? 'green' : 'yellow'}-400"
></div>
</div>
{#if $currentOrg.isCustomDomainVerified}
<Badge variant="default" class="bg-green-500 px-3 text-xs text-white">Verified</Badge>
{:else}
<Badge variant="default" class="bg-yellow-500 px-3 text-xs text-white">Pending verification</Badge>
{/if}
</div>
</Field.Field>
<Field.Description>{$t('components.settings.domains.dns_description')}</Field.Description>
<Field.Field>
<div class="flex items-center gap-10 rounded-md border px-4 py-2">
<div class="flex h-[72px] flex-col justify-evenly">
<Field.Label class="font-light">{$t('components.settings.domains.dns_type')}</Field.Label>
<p class="flex h-[40px] items-center">CNAME</p>
</div>
<div class="flex h-[72px] flex-col justify-evenly">
<Field.Label class="font-light">{$t('components.settings.domains.dns_name')}</Field.Label>
<p class="flex h-[40px] items-center">
{getSubdomain()}
</p>
</div>
<div class="flex h-[72px] flex-col justify-evenly">
<Field.Label class="font-light">{$t('components.settings.domains.dns_value')}</Field.Label>
<p class=" flex items-center gap-1">
cname.vercel-dns.com
<Button variant="ghost" size="icon-sm" onclick={() => copyToClipboard('cname.vercel-dns.com')}>
<p>cname.vercel-dns.com</p>
<Copy />
</Button>
</p>
</div>
</div>
</Field.Field>
<Field.Field orientation="horizontal">
<Button
variant="outline"
class="flex items-center gap-2 py-2"
onclick={handleRefreshCustomDomain}
loading={isRefreshing}
>
{#if !isRefreshing}
<RotateCcwIcon size={16} />
{/if}
{$t('components.settings.domains.refresh')}
</Button>
<Button
variant="destructive"
class="flex items-center gap-2 py-2"
onclick={handleRemoveCustomDomain}
loading={isCustomDomainLoading}
>
{#if !isCustomDomainLoading}
<TrashIcon size={16} />
{/if}
{$t('components.settings.domains.remove')}
</Button>
</Field.Field>
{:else}
<Field.Field>
<Field.Label class="font-bold">{$t('components.settings.domains.your_domain')}</Field.Label>
<DomainInput bind:value={customDomain} placeholder="courses.yourwebsite" disabled={$isFreePlan} />
{#if errors.customDomain}
<Field.Error>{errors.customDomain}</Field.Error>
{/if}
</Field.Field>
<Field.Field orientation="horizontal">
<Button
onclick={$isFreePlan ? () => {} : handleSaveCustomDomain}
loading={isCustomDomainLoading}
disabled={isLoading || !isDomainValid}
>
{$t('components.settings.domains.save')}
</Button>
</Field.Field>
{/if}
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('components.settings.domains.custom_favicon')}</Field.Legend>
<ComingSoon />
<Field.Field>
<UploadImage
shape="rounded-md"
bind:avatar={favicon}
src={$currentOrg.favicon ?? '/logo-512.png'}
widthHeight="w-16 h-16 lg:w-24 lg:h-24"
flexDirection="flex-row"
isDisabled={true}
/>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('components.settings.domains.custom_code')}</Field.Legend>
<ComingSoon />
<Field.Field>
<Textarea
bind:value={customCode}
placeholder="e.g <link rel='stylesheet' href='https://example.com/style.css' />"
class="w-4/5"
rows={7}
disabled={true}
/>
</Field.Field>
</Field.Set>
</Field.Group>
@@ -0,0 +1,8 @@
export { default as ProfilePage } from './profile.svelte';
export { default as DomainsPage } from './domains.svelte';
export { default as TeamsPage } from './teams.svelte';
export { default as CustomizeLmsPage } from './customize-lms.svelte';
export { default as OrgPage } from './org.svelte';
export { default as LandingpagePage } from './landingpage.svelte';
export { default as BillingPage } from './billing.svelte';
export { default as IntegrationsPage } from './integrations.svelte';
@@ -0,0 +1,72 @@
<script lang="ts">
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
// import { snackbar } from '$lib/components/Snackbar/store';
import { Input } from '@cio/ui/base/input';
import { Button } from '@cio/ui/base/button';
import ArrowUpRightIcon from '$lib/components/Icons/ArrowTopRight.svelte';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import * as Field from '@cio/ui/base/field';
let chatId: number | null = $state(null);
async function addChatId() {
if (!chatId || `${chatId}`.length < 5) return;
// TODO: Update profile with telegram chat id
// if (error) {
// console.log(error);
// snackbar.error('snackbar.course_settings.error.failed_integration');
// } else {
// $profile.telegramChatId = chatId;
// chatId = null;
// snackbar.success('snackbar.course_settings.success.successful_integration');
// }
}
async function deleteChatId() {
// const { error } = await supabase.from('profile').update({ telegram_chat_id: null }).match({ id: $profile.id });
// if (error) {
// snackbar.error('snackbar.course_settings.error.failed_deletion');
// console.log(error);
// } else {
// $profile.telegramChatId = null;
// snackbar.success('snackbar.course_settings.success.successful_deletion');
// }
}
</script>
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Legend>
<img src="/telegram-svg.svg" alt="" class="mr-2 w-10" />
{$t('settings.integrations.heading')}
</Field.Legend>
{#if $profile.telegramChatId !== null}
<Field.Field>
<div class="flex items-center">
<CircleCheckIcon size={16} filled />
<Field.Description>{$t('settings.integrations.success_message')}</Field.Description>
</div>
<Button variant="destructive" onclick={deleteChatId}>
{$t('settings.integrations.disconnect')}
</Button>
</Field.Field>
{:else}
<Field.Group>
<Field.Description>{$t('settings.integrations.sub_heading')}</Field.Description>
<Field.Description>{$t('settings.integrations.step_authenticate')}</Field.Description>
<Button variant="outline" onclick={() => window.open('https://t.me/classroomio_bot', '_blank')}>
<span class="mr-2">{$t('settings.integrations.open_bot_button')}</span>
<ArrowUpRightIcon />
</Button>
<Field.Description>{$t('settings.integrations.step_chatId')}</Field.Description>
<Field.Field>
<Input bind:value={chatId} placeholder="360111" class="w-full lg:w-60" type="number" required />
</Field.Field>
<Button variant="default" onclick={addChatId}>{$t('settings.integrations.connect_button')}</Button>
</Field.Group>
{/if}
</Field.Set>
</Field.Group>
@@ -0,0 +1,762 @@
<script lang="ts">
import { Label } from '@cio/ui/base/label';
import { Switch } from '@cio/ui/base/switch';
import TrashIcon from '@lucide/svelte/icons/trash';
import * as RadioGroup from '@cio/ui/base/radio-group';
import { landingPageSettings } from '../utils/store';
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { handleOpenWidget } from '$lib/components/CourseLandingPage/store';
import { Checkbox } from '@cio/ui/base/checkbox';
import { Input } from '@cio/ui/base/input';
import { Textarea } from '@cio/ui/base/textarea';
import { Button } from '@cio/ui/base/button';
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
import * as Field from '@cio/ui/base/field';
import type { OrgLandingPageJson } from '$lib/utils/types/org';
import type { AccountOrg } from '$lib/features/app/types';
let creatingNewQuestion = $state(false);
let creatingNewCustomLink = $state(false);
let hasUnsavedChanges = $state(false);
let widgetKey = $state('');
const banner = [
{ value: 'video', label: `${$t('settings.landing_page.actions.banner_type.video')}` },
{ value: 'image', label: `${$t('settings.landing_page.actions.banner_type.image')}` }
];
let newQuestion = $state({
title: '',
content: ''
});
let newCustomLink = $state({
label: '',
url: '',
openInNewTab: false
});
function widgetControl(key: string) {
widgetKey = key;
$handleOpenWidget.open = true;
}
function createNewFaq() {
newQuestion = {
title: '',
content: ''
};
creatingNewQuestion = true;
}
function saveNewFAQ() {
if (newQuestion.title !== '' && newQuestion.content !== '') {
$landingPageSettings.faq.questions = [
...$landingPageSettings.faq.questions,
{
id: new Date().getTime(),
title: newQuestion.title,
content: newQuestion.content
}
];
creatingNewQuestion = false;
}
}
function cancelNewFAQ() {
creatingNewQuestion = false;
}
function deleteFaq(id: number) {
let faqs = $landingPageSettings.faq.questions;
const filteredFaq = faqs.filter((faq) => faq.id !== id);
$landingPageSettings.faq.questions = filteredFaq;
}
function createNewCustomLink() {
newCustomLink = {
label: '',
url: '',
openInNewTab: false
};
creatingNewCustomLink = true;
}
function saveNewCustomLink() {
if (newCustomLink.label !== '' && newCustomLink.url !== '') {
$landingPageSettings.customLinks.links = [
...$landingPageSettings.customLinks.links,
{
id: new Date().getTime(),
label: newCustomLink.label,
url: newCustomLink.url,
openInNewTab: newCustomLink.openInNewTab
}
];
creatingNewCustomLink = false;
}
}
function cancelNewCustomLink() {
creatingNewCustomLink = false;
}
function deleteCustomLink(id: number) {
let customLinks = $landingPageSettings.customLinks.links;
const filteredCustomLinks = customLinks.filter((link) => link.id !== id);
$landingPageSettings.customLinks.links = filteredCustomLinks;
}
const checkPrefix = (inputValue: string) => {
if (!inputValue) return;
if (inputValue.trim() !== '') {
if (!inputValue.startsWith('https://')) {
inputValue = 'https://' + inputValue;
}
}
return inputValue;
};
export async function handleSave() {
$landingPageSettings.footer.twitter = checkPrefix($landingPageSettings.footer.twitter) || '';
$landingPageSettings.footer.linkedin = checkPrefix($landingPageSettings.footer.linkedin) || '';
$landingPageSettings.footer.facebook = checkPrefix($landingPageSettings.footer.facebook) || '';
await orgApi.update($currentOrg.id, {
landingpage: $landingPageSettings as AccountOrg['landingpage']
});
if (orgApi.success) {
$currentOrg.landingpage = $landingPageSettings as AccountOrg['landingpage'];
hasUnsavedChanges = false;
}
}
// Set default from store
currentOrg.subscribe((cOrg) => {
if (cOrg.landingpage && Object.keys(cOrg.landingpage).length) {
const landingpage = { ...(cOrg.landingpage as unknown as OrgLandingPageJson) };
// Fallbacks for new keys we added into JSON, in case a user already saved the old JSON
if (!landingpage?.header?.banner) {
landingpage.header.banner = $landingPageSettings.header.banner;
}
if (!landingpage?.header?.background) {
landingpage.header.background = $landingPageSettings.header.background;
}
if (!landingpage.customLinks) {
landingpage.customLinks = $landingPageSettings.customLinks;
}
$landingPageSettings = {
...landingpage
};
}
});
</script>
<UnsavedChanges bind:hasUnsavedChanges />
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Legend>{$t('settings.landing_page.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.header.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Field.Label class="text-gray-600">
{$landingPageSettings.header.show
? $t('settings.landing_page.show_section')
: $t('settings.landing_page.hide_section')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.landing_page.title')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.header.title}
oninput={() => (hasUnsavedChanges = true)}
class="w-full"
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.title_highlight')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.highlight')}
class="w-full"
bind:value={$landingPageSettings.header.titleHighlight}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.subtitle')}</Field.Label>
<Textarea
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
class="w-full"
bind:value={$landingPageSettings.header.subtitle}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.actions.heading')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.label')}
class="w-full"
bind:value={$landingPageSettings.header.action.label}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.actions.link')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.link')}
bind:value={$landingPageSettings.header.action.link}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch
bind:checked={$landingPageSettings.header.action.redirect}
onCheckedChange={() => (hasUnsavedChanges = true)}
/>
<Field.Label class="text-gray-600">
{$landingPageSettings.header.action.redirect
? $t('settings.landing_page.actions.redirect')
: $t('settings.landing_page.actions.no_redirect')}
</Field.Label>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.actions.banner_type.heading')}</Field.Label>
<RadioGroup.Root
bind:value={$landingPageSettings.header.banner.type}
onValueChange={() => {
if (hasUnsavedChanges) return;
hasUnsavedChanges = true;
}}
>
{#each banner as item}
<div class="flex items-center space-x-2">
<RadioGroup.Item value={item.value} id={item.value} />
<Label for={item.value} class="cursor-pointer">{item.label}</Label>
</div>
{/each}
</RadioGroup.Root>
</Field.Field>
{#if $landingPageSettings.header.banner.type === 'video'}
<Field.Field>
<Field.Label>{$t('settings.landing_page.actions.link')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.video')}
bind:value={$landingPageSettings.header.banner.video}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
{:else}
<Field.Field>
<Button variant="outline" onclick={() => widgetControl('header.banner.image')}>
{$t('settings.landing_page.about.select_image')}
</Button>
</Field.Field>
{/if}
{#if $landingPageSettings.header.banner.image && $landingPageSettings.header.banner.type === 'image'}
<img alt="bannerImage" src={$landingPageSettings.header.banner.image} class="mt-2 w-full rounded-md" />
{/if}
<Field.Field orientation="horizontal">
<Switch
bind:checked={$landingPageSettings.header.banner.show}
onCheckedChange={() => (hasUnsavedChanges = true)}
/>
<Field.Label class="text-gray-600">
{$landingPageSettings.header.banner.show
? $t('settings.landing_page.actions.show_banner')
: $t('settings.landing_page.actions.hide_banner')}
</Field.Label>
</Field.Field>
{#if $handleOpenWidget.open && widgetKey === 'banner'}
<UploadWidget
bind:imageURL={$landingPageSettings.header.banner.image}
onchange={() => (hasUnsavedChanges = true)}
/>
{/if}
<Field.Field>
<Field.Label>{$t('settings.landing_page.background.title')}</Field.Label>
<Button variant="outline" onclick={() => widgetControl('background')}>
{$t('settings.landing_page.about.select_image')}
</Button>
</Field.Field>
{#if $landingPageSettings.header.background?.image}
<img alt="backgroundImage" src={$landingPageSettings.header.background.image} class="mt-2 w-full rounded-md" />
{/if}
<Field.Field orientation="horizontal">
<Switch
checked={$landingPageSettings.header.background?.show}
onCheckedChange={() => {
hasUnsavedChanges = true;
if (!$landingPageSettings.header.background) {
$landingPageSettings.header.background = {
image: '',
show: true
};
} else {
$landingPageSettings.header.background.show = !$landingPageSettings.header.background.show;
}
}}
/>
<Field.Label class="text-gray-600">
{$landingPageSettings.header.background?.show
? $t('settings.landing_page.background.show_background')
: $t('settings.landing_page.background.hide_background')}
</Field.Label>
</Field.Field>
{#if $handleOpenWidget.open && widgetKey === 'background'}
<UploadWidget
imageURL={$landingPageSettings.header.background?.image}
onchange={(imgUrl) => {
hasUnsavedChanges = true;
if (!$landingPageSettings.header.background) {
$landingPageSettings.header.background = {
image: imgUrl,
show: true
};
} else {
$landingPageSettings.header.background.image = imgUrl;
}
}}
/>
{/if}
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.landing_page.about.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.aboutUs.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Field.Label class="text-gray-600">
{$landingPageSettings.aboutUs.show
? $t('settings.landing_page.show_section')
: $t('settings.landing_page.hide_section')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.landing_page.about.title')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.aboutUs.title}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.about.content')}</Field.Label>
<Textarea
placeholder={$t('course.navItem.lessons.exercises.all_exercises.content')}
bind:value={$landingPageSettings.aboutUs.content}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label class="font-bold">{$t('settings.landing_page.about.upload_an_image')}</Field.Label>
<Button variant="outline" onclick={() => widgetControl('about-us')}>
{$t('settings.landing_page.about.select_image')}
</Button>
{#if $landingPageSettings.aboutUs.imageUrl}
<img alt="About us" src={$landingPageSettings.aboutUs.imageUrl} class="mt-2 w-full rounded-md" />
{/if}
{#if $handleOpenWidget.open && widgetKey === 'about-us'}
<UploadWidget bind:imageURL={$landingPageSettings.aboutUs.imageUrl} />
{/if}
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.landing_page.courses.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.courses.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Field.Label class="text-gray-600">
{$landingPageSettings.courses.show
? $t('settings.landing_page.courses.show_section')
: $t('settings.landing_page.courses.hide_section')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.landing_page.courses.title')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.courses.title}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.courses.title_highlight')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.highlight')}
bind:value={$landingPageSettings.courses.titleHighlight}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.courses.subtitle')}</Field.Label>
<Textarea
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
bind:value={$landingPageSettings.courses.subtitle}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.landing_page.faq.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.faq.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Field.Label class="text-gray-600">
{$landingPageSettings.faq.show
? $t('settings.landing_page.faq.show_section')
: $t('settings.landing_page.faq.hide_section')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.landing_page.faq.title')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.faq.title}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
{#each $landingPageSettings.faq.questions as item (item.id)}
<Field.Field>
<Field.Label>{$t('settings.landing_page.faq.question')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_question_here')}
bind:value={item.title}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.faq.answer')}</Field.Label>
<Textarea
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_answer_here')}
bind:value={item.content}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Button variant="ghost" size="icon" onclick={() => deleteFaq(item.id)}>
<TrashIcon size={16} />
</Button>
{/each}
{#if creatingNewQuestion}
<Field.Field>
<Field.Label>{$t('settings.landing_page.faq.question')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_question_here')}
bind:value={newQuestion.title}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.faq.answer')}</Field.Label>
<Textarea
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_answer_here')}
bind:value={newQuestion.content}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={saveNewFAQ}>
{$t('settings.landing_page.faq.save')}
</Button>
<Button variant="outline" onclick={cancelNewFAQ}>
{$t('settings.landing_page.faq.cancel')}
</Button>
</Field.Field>
{:else}
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={createNewFaq}>
{$t('settings.landing_page.faq.button')}
</Button>
</Field.Field>
{/if}
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.landing_page.contact_us.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.contact.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Field.Label class="text-gray-600">
{$landingPageSettings.contact.show
? $t('settings.landing_page.contact_us.show_section')
: $t('settings.landing_page.contact_us.hide_section')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.landing_page.contact_us.title')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.contact.title}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.contact_us.title_highlight')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.highlight')}
bind:value={$landingPageSettings.contact.titleHighlight}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.contact_us.subtitle')}</Field.Label>
<Textarea
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
bind:value={$landingPageSettings.contact.subtitle}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.contact_us.address')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.address')}
bind:value={$landingPageSettings.contact.address}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.contact_us.phone_number')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.number')}
bind:value={$landingPageSettings.contact.phone}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.contact_us.email')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.email')}
bind:value={$landingPageSettings.contact.email}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.landing_page.mailing_list.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.mailinglist.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Field.Label class="text-gray-600">
{$landingPageSettings.mailinglist.show
? $t('settings.landing_page.mailing_list.show_section')
: $t('settings.landing_page.mailing_list.hide_section')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.landing_page.mailing_list.title')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.mailinglist.title}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.mailing_list.subtitle')}</Field.Label>
<Textarea
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
bind:value={$landingPageSettings.mailinglist.subtitle}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.mailing_list.button_label')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.label')}
bind:value={$landingPageSettings.mailinglist.buttonLabel}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.landing_page.custom_links.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.customLinks.show} />
<Field.Label class="text-gray-600">
{$landingPageSettings.customLinks.show
? $t('settings.landing_page.custom_links.show_links')
: $t('settings.landing_page.custom_links.hide_links')}
</Field.Label>
</Field.Field>
<Field.Description>{$t('settings.landing_page.custom_links.description')}</Field.Description>
<Field.Group>
{#each $landingPageSettings.customLinks.links as link (link.id)}
<div class="flex flex-col gap-2">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field.Field>
<Field.Label>{$t('settings.landing_page.custom_links.label')}</Field.Label>
<Input placeholder={$t('settings.landing_page.custom_links.label_placeholder')} bind:value={link.label} />
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.custom_links.url')}</Field.Label>
<Input placeholder={$t('settings.landing_page.custom_links.url_placeholder')} bind:value={link.url} />
</Field.Field>
</div>
<div class="flex items-center justify-between">
<Field.Field orientation="horizontal">
<Checkbox id="new-tab-{link.id}" bind:checked={link.openInNewTab} />
<Field.Label class="text-sm">{$t('settings.landing_page.custom_links.new_tab')}</Field.Label>
</Field.Field>
<Button variant="ghost" size="icon" onclick={() => deleteCustomLink(link.id)}>
<TrashIcon size={16} />
</Button>
</div>
<Field.Separator />
</div>
{/each}
{#if creatingNewCustomLink}
<div class="mb-4 rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="mb-3 grid grid-cols-1 gap-4 md:grid-cols-2">
<Field.Field>
<Field.Label>{$t('settings.landing_page.custom_links.label')}</Field.Label>
<Input
placeholder={$t('settings.landing_page.custom_links.label_placeholder')}
bind:value={newCustomLink.label}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.custom_links.url')}</Field.Label>
<Input
placeholder={$t('settings.landing_page.custom_links.url_placeholder')}
bind:value={newCustomLink.url}
/>
</Field.Field>
</div>
<div class="flex items-center justify-between">
<Field.Field orientation="horizontal">
<input type="checkbox" bind:checked={newCustomLink.openInNewTab} class="mr-2" />
<Field.Label class="text-sm">{$t('settings.landing_page.custom_links.new_tab')}</Field.Label>
</Field.Field>
<div class="flex gap-2">
<Button variant="outline" onclick={saveNewCustomLink} class="px-3 py-1 text-sm">
{$t('settings.landing_page.custom_links.save')}
</Button>
<Button variant="outline" onclick={cancelNewCustomLink} class="px-3 py-1 text-sm">
{$t('settings.landing_page.custom_links.cancel')}
</Button>
</div>
</div>
</div>
{:else}
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={createNewCustomLink}>
{$t('settings.landing_page.custom_links.add')}
</Button>
</Field.Field>
{/if}
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.landing_page.footer.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.footer.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Field.Label class="text-gray-600">
{$landingPageSettings.footer.show
? $t('settings.landing_page.footer.show_section')
: $t('settings.landing_page.footer.hide_section')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.landing_page.footer.facebook')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.facebook')}
bind:value={$landingPageSettings.footer.facebook}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<!-- <TextField
label="Instagram"
placeholder="Write your Instagram link here"
className="mb-5"
bind:value={$landingPageSettings.footer.instagram}
/> -->
<Field.Field>
<Field.Label>{$t('settings.landing_page.footer.twitter')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.twitter')}
bind:value={$landingPageSettings.footer.twitter}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.footer.linkedin')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.linkedin')}
bind:value={$landingPageSettings.footer.linkedin}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
</Field.Group>
</Field.Set>
</Field.Group>
<style>
:global(.dark) {
--cp-text-color: #fff;
--cp-border-color: white;
--cp-text-color: white;
--cp-input-color: #555;
--cp-button-hover-color: #777;
}
:global(.dark .alpha) {
background: #333 !important;
}
</style>
@@ -0,0 +1,256 @@
<script lang="ts">
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import ZapIcon from '@lucide/svelte/icons/zap';
import debounce from 'lodash/debounce';
import ColorPicker from 'svelte-awesome-color-picker';
import { t } from '$lib/utils/functions/translations';
import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
import { injectCustomTheme, setCustomTheme, setTheme } from '$lib/utils/functions/theme';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { Input } from '@cio/ui/base/input';
import { Button } from '@cio/ui/base/button';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
import * as Field from '@cio/ui/base/field';
let avatar = $state<string | File | undefined>();
let hasUnsavedChanges = $state(false);
let hex = $state('');
$effect(() => {
const theme = $currentOrg.theme;
// if not a custom theme, don't update hex
if (!theme || theme.includes('theme-')) return;
// only update hex with value of `theme` when theme changes not when HEX changes
untrack(() => {
hex = theme;
});
});
const themes = {
rose: 'theme-rose',
green: 'theme-green',
orange: 'theme-orange',
violet: 'theme-violet',
default: ''
};
const saveTheme = debounce(async (theme: string) => {
await orgApi.update($currentOrg.id, {
theme
});
}, 700);
function handleChangeTheme(t = '') {
return async () => {
document.body.className = document.body.className
.split(' ')
.filter((c) => !c.includes('theme'))
.join(' ')
.concat(t ? ' ' : '', t);
$currentOrg.theme = t;
hex = '';
setTheme(t);
saveTheme(t);
};
}
async function handleCustomTheme() {
if (!hex) return;
injectCustomTheme(hex);
setCustomTheme('theme-custom');
$currentOrg.theme = hex;
saveTheme(hex);
}
export async function handleUpdate() {
await orgApi.update($currentOrg.id, {
name: $currentOrg.name,
avatar
});
if (orgApi.success) {
hasUnsavedChanges = false;
avatar = undefined;
}
}
function gotoSettings(pathname: string) {
goto(`${$currentOrgPath}/settings${pathname}`);
}
let isCustomTheme = $derived(hex && !hex.includes('theme-'));
</script>
<UnsavedChanges bind:hasUnsavedChanges />
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.organization.organization_profile.organization_name')}</Field.Label>
<Input bind:value={$currentOrg.name} oninput={() => (hasUnsavedChanges = true)} class="w-full lg:w-60" />
{#if orgApi.errors.name}
<Field.Error>{$t(orgApi.errors.name)}</Field.Error>
{/if}
</Field.Field>
<Field.Field>
<UploadImage
bind:avatar
src={$currentOrg.avatarUrl}
shape="rounded-md"
widthHeight="w-24 h-24"
change={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.organization.organization_profile.theme.heading')}</Field.Legend>
<Field.Description>{$t('settings.organization.organization_profile.theme.sub_heading')}</Field.Description>
<Field.Field>
<div class="flex items-center gap-5">
<button
class="rounded-full border-2 {$currentOrg.theme === themes.default &&
'border-[#1d4ee2]'} flex h-fit items-center justify-center"
onclick={handleChangeTheme(themes.default)}
aria-label="Default blue theme"
>
<div class="m-1 h-6 w-6 rounded-full bg-[#1d4ee2] md:h-6 md:w-6"></div>
</button>
<button
class="rounded-full border-2 {$currentOrg.theme === themes.rose &&
'border-[#be1241]'} flex h-fit items-center justify-center"
onclick={handleChangeTheme(themes.rose)}
aria-label="Rose theme"
>
<div class="m-1 h-6 w-6 rounded-full bg-[#be1241] md:h-6 md:w-6"></div>
</button>
<button
class="rounded-full border-2 {$currentOrg.theme === themes.green &&
'border-[#0c891b]'} flex h-fit items-center justify-center"
onclick={handleChangeTheme(themes.green)}
aria-label="Green theme"
>
<div class="m-1 h-6 w-6 rounded-full bg-[#0c891b] md:h-6 md:w-6"></div>
</button>
<button
class="rounded-full border-2 {$currentOrg.theme === themes.orange &&
'border-[#cc4902]'} flex h-fit items-center justify-center"
onclick={handleChangeTheme(themes.orange)}
aria-label="Orange theme"
>
<div class="m-1 h-6 w-6 rounded-full bg-[#cc4902] md:h-6 md:w-6"></div>
</button>
<button
class="rounded-full border-2 {$currentOrg.theme === themes.violet &&
'border-[#cf00ce]'} flex h-fit items-center justify-center"
onclick={handleChangeTheme(themes.violet)}
aria-label="Violet theme"
>
<div class="m-1 h-6 w-6 rounded-full bg-[#cf00ce] md:h-6 md:w-6"></div>
</button>
<div
class="h-auto w-fit border-2 {isCustomTheme
? 'border-primary-700'
: 'dark:border-neutral-700'} group relative rounded-full"
>
<!-- plus icon positioned over the color picker -->
<div
class="pointer-events-none absolute inset-0 flex items-center justify-center transition-opacity duration-200"
>
<svg
class="h-6 w-6 text-{isCustomTheme ? 'white' : 'black'} z-10 opacity-100 dark:text-white"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"></path>
</svg>
</div>
<ColorPicker position="responsive" label="" bind:hex on:input={handleCustomTheme} />
</div>
</div>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.organization.organization_profile.customize_lms.heading')}</Field.Legend>
<Field.Description>{$t('settings.organization.organization_profile.customize_lms.sub_heading')}</Field.Description>
<Field.Description>{$t('settings.organization.organization_profile.customize_lms.body')}</Field.Description>
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={() => gotoSettings('/customize-lms')}>
{$t('settings.organization.organization_profile.customize_lms.button')}
</Button>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.organization.organization_profile.custom_domain.heading')}</Field.Legend>
<Field.Description>{$t('settings.organization.organization_profile.custom_domain.sub_heading')}</Field.Description>
<Field.Description>{$t('settings.organization.organization_profile.custom_domain.body')}</Field.Description>
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={() => gotoSettings('/domains')}>
{#if $isFreePlan}
<ZapIcon size={16} class="filled" />
{/if}
{$t('settings.organization.organization_profile.custom_domain.button')}
</Button>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.organization.organization_profile.team.heading')}</Field.Legend>
<Field.Description>{$t('settings.organization.organization_profile.team.sub_heading')}</Field.Description>
<Field.Description>{$t('settings.organization.organization_profile.team.body')}</Field.Description>
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={() => gotoSettings('/teams')}>
{#if $isFreePlan}
<ZapIcon size={16} class="filled" />
{/if}
{$t('settings.organization.organization_profile.team.button')}
</Button>
</Field.Field>
</Field.Set>
</Field.Group>
<style>
:global(.dark) {
--cp-text-color: #fff;
--cp-border-color: white;
--cp-text-color: white;
--cp-input-color: #555;
--cp-button-hover-color: #777;
}
:global(.dark .alpha) {
background: #333 !important;
}
</style>
@@ -0,0 +1,168 @@
<script lang="ts">
import { page } from '$app/state';
import type { TLocale } from '@cio/db/types';
import { profile } from '$lib/utils/store/user';
import { CircleCheckBig } from '@lucide/svelte';
import { profileApi } from '$lib/features/auth/api/profile.svelte';
import { t } from '$lib/utils/functions/translations';
import LanguagePicker from '../components/language-picker.svelte';
import { Input } from '@cio/ui/base/input';
import { Button } from '@cio/ui/base/button';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
import * as Field from '@cio/ui/base/field';
let avatar = $state<string | File | undefined>();
let hasLangChanged = $state(false);
let locale = $derived<TLocale | undefined>($profile.locale || undefined);
let hasUnsavedChanges = $state(false);
let email = $derived($profile.email || '');
let isChangingEmail = $state(false);
let emailChangeInitiated = $state(false);
export async function handleUpdate() {
await profileApi.submit(
{
fullname: $profile.fullname,
username: $profile.username,
locale,
avatar
},
$profile,
hasLangChanged,
locale
);
if (profileApi.success) {
hasUnsavedChanges = false;
avatar = undefined;
}
}
async function handleEmailChange() {
if (email === $profile.email) {
return;
}
const url = new URL(window.location.href);
url.searchParams.set('trigger', 'app');
await profileApi.changeEmail({
newEmail: email,
callbackURL: url.toString()
});
if (profileApi.success) {
isChangingEmail = false;
emailChangeInitiated = true;
// Reset email field to current email after successful change request
email = $profile.email || '';
}
}
const isVerificationSent = $derived(page.url.searchParams.get('trigger') === 'app');
</script>
<UnsavedChanges bind:hasUnsavedChanges />
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Legend>{$t('settings.profile.profile_picture.heading')}</Field.Legend>
<Field.Field>
<UploadImage
bind:avatar
src={$profile.avatarUrl}
widthHeight="w-16 h-16 lg:w-24 lg:h-24"
isDisabled={profileApi.isLoading}
change={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.profile.personal_information.heading')}</Field.Legend>
{#if isVerificationSent}
<div class="flex items-center gap-2 rounded-md border border-gray-200 p-2 text-green-500">
<CircleCheckBig class="size-8" />
<p class="text-sm">{$t('settings.profile.personal_information.email_change_verification_sent')}</p>
</div>
{/if}
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.profile.personal_information.full_name')}</Field.Label>
<Input bind:value={$profile.fullname} class="" oninput={() => (hasUnsavedChanges = true)} />
{#if profileApi.errors.fullname}
<Field.Error>{$t(profileApi.errors.fullname)}</Field.Error>
{/if}
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.profile.personal_information.username')}</Field.Label>
<Input bind:value={$profile.username} oninput={() => (hasUnsavedChanges = true)} />
{#if profileApi.errors.username}
<Field.Error>{$t(profileApi.errors.username)}</Field.Error>
{/if}
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.profile.personal_information.email')}</Field.Label>
<Input
bind:value={email}
class="w-full"
type="email"
disabled={profileApi.isLoading}
oninput={() => {
if (email !== $profile.email) {
isChangingEmail = true;
emailChangeInitiated = false;
}
}}
/>
{#if profileApi.errors.newEmail}
<Field.Error>{$t(profileApi.errors.newEmail)}</Field.Error>
{/if}
{#if isChangingEmail && email !== $profile.email}
<div class="mt-2 flex gap-2">
<Button
variant="default"
class="text-sm"
loading={profileApi.isLoading}
disabled={profileApi.isLoading}
onclick={handleEmailChange}
>
{$t('settings.profile.personal_information.confirm')}
</Button>
<Button
variant="ghost"
class="text-primary-700 text-sm"
disabled={profileApi.isLoading}
onclick={() => {
email = $profile.email || '';
isChangingEmail = false;
emailChangeInitiated = false;
profileApi.errors.newEmail = '';
}}
>
{$t('settings.profile.personal_information.cancel')}
</Button>
</div>
{/if}
{#if emailChangeInitiated}
<div class="mt-2 flex items-center gap-2 rounded-md border border-gray-200 p-2 text-amber-500">
<CircleCheckBig class="size-8" />
<p class="text-sm">{$t('settings.profile.personal_information.email_change_verification_note')}</p>
</div>
{/if}
</Field.Field>
<Field.Field>
<LanguagePicker
change={() => (hasUnsavedChanges = true)}
bind:hasLangChanged
bind:value={locale}
className=""
/>
</Field.Field>
</Field.Group>
</Field.Set>
</Field.Group>
@@ -0,0 +1,158 @@
<script lang="ts">
import * as Select from '@cio/ui/base/select';
import { Spinner } from '@cio/ui/base/spinner';
import { profile } from '$lib/utils/store/user';
import { isFreePlan, currentOrg } from '$lib/utils/store/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';
import { ROLE } from '@cio/utils/constants';
import { ROLE_LABEL } from '$lib/utils/constants/roles';
import { validateEmailInString } from '$lib/utils/functions/validator';
import { Badge } from '@cio/ui/base/badge';
import { Input } from '@cio/ui/base/input';
import { Button } from '@cio/ui/base/button';
import { ComingSoon, UpgradeBanner } from '$lib/features/ui';
import * as Field from '@cio/ui/base/field';
let emailsStr = $state('');
let errorMessage = $state('');
let role = $state(ROLE.TUTOR.toString());
let isRemoving: number | null = $state(null);
async function onSendInvite() {
const { hasError, error: _error, emails } = validateEmailInString(emailsStr);
if (hasError) {
errorMessage = _error;
return;
}
errorMessage = '';
await orgApi.inviteTeamMembers($currentOrg.id, emails, parseInt(role));
if (orgApi.success) {
snackbar.success('snackbar.team_members.invite_sent');
emailsStr = '';
} else {
// Errors are handled by orgApi, but check for field-specific errors
if (orgApi.errors.emails) {
errorMessage = orgApi.errors.emails;
} else if (orgApi.errors.general) {
snackbar.error(orgApi.errors.general);
}
}
}
async function onRemove(id: number) {
isRemoving = id;
await orgApi.removeTeamMember($currentOrg.id, id);
if (orgApi.success) {
snackbar.success('snackbar.team_members.remove_success');
} else {
snackbar.error('snackbar.team_members.remove_fail');
}
isRemoving = null;
}
const isTeamMemberAdmin = (members: OrgTeamMember[], profileId: string | undefined) => {
return members.some((member) => member.profileId === profileId && member.isAdmin);
};
$effect(() => {
orgApi.getOrgTeam($currentOrg.id);
});
</script>
<UpgradeBanner>{$t('upgrade.team')}</UpgradeBanner>
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Legend>{$t('course.navItem.people.teams.add')}</Field.Legend>
<Field.Description class="mb-5">{$t('course.navItem.people.teams.add_team')}</Field.Description>
<Field.Group>
<Field.Field>
<Field.Label>{$t('course.navItem.people.teams.invite')}</Field.Label>
<Input
placeholder={$t('course.navItem.people.teams.placeholder')}
bind:value={emailsStr}
class="w-full"
disabled={$isFreePlan}
/>
{#if errorMessage}
<Field.Error>{errorMessage}</Field.Error>
{/if}
</Field.Field>
<Field.Field>
<Field.Label>{$t('course.navItem.people.teams.role')}</Field.Label>
<Select.Root type="single" bind:value={role} disabled={$isFreePlan}>
<Select.Trigger id="invite-role" 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>
</Field.Field>
<Button
variant="default"
onclick={onSendInvite}
loading={orgApi.isLoading}
disabled={orgApi.isLoading || $isFreePlan}
>
{$t('course.navItem.people.teams.send_invite')}
</Button>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('course.navItem.people.teams.members')}</Field.Legend>
<Field.Group>
{#if orgApi.isLoading && orgApi.teamMembers.length === 0}
<Spinner class="size-10! text-blue-700!" />
{:else if orgApi.teamMembers.length > 0}
{#each orgApi.teamMembers as teamMember}
<Field.Field>
<div class="flex items-center justify-between">
<div class="flex items-center">
<p class="mr-3 text-sm text-gray-500 dark:text-white">
{teamMember.email}
</p>
<Badge variant="secondary" class="mr-3 text-xs">{$t(teamMember.role)}</Badge>
{#if !teamMember.verified}
<Badge variant="outline" class="bg-yellow-200 text-xs text-yellow-700">
{$t('course.navItem.people.teams.invite_sent')}
</Badge>
{:else if teamMember.profileId === $profile.id}
<ComingSoon label={$t('course.navItem.people.teams.you')} />
{/if}
</div>
{#if teamMember.profileId !== $profile.id && isTeamMemberAdmin(orgApi.teamMembers, $profile.id)}
<Button
variant="ghost"
onclick={() => onRemove(teamMember.id)}
loading={isRemoving === teamMember.id}
disabled={isRemoving === teamMember.id}
class="text-red-500 hover:text-red-700"
>
{$t('course.navItem.people.teams.remove')}
</Button>
{/if}
</div>
</Field.Field>
{/each}
{/if}
</Field.Group>
</Field.Set>
</Field.Group>
@@ -0,0 +1,116 @@
import type { OrgLandingPageJson } from '$lib/utils/types/org';
import type { Writable } from 'svelte/store';
import { writable } from 'svelte/store';
export const landingPageSettings: Writable<OrgLandingPageJson> = writable({
header: {
title: 'Web Development Courses for',
titleHighlight: 'Everyone',
subtitle: 'Practical project-based courses that are easy to understand and straight to the point',
action: {
label: 'Start learning Today',
link: '#contact',
redirect: false
},
banner: {
video: 'https://www.youtube.com/watch?v=qqAYW7uxErI',
image: '',
type: 'video',
show: true
},
background: {
image: '',
show: true
},
show: true
},
aboutUs: {
title: 'Our Story',
content:
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.',
imageUrl: 'https://pgrest.classroomio.com/storage/v1/object/public/avatars/landingpage/riverside.jpeg',
show: true
},
courses: {
title: 'Explore our',
titleHighlight: 'Courses',
subtitle: 'Find courses you will love from best teachers all over the world🌎.',
show: true
},
faq: {
title: 'Frequently Asked Questions.',
questions: [
{
id: 1,
title: 'Lorem ipsum dolor sit, amet con',
content:
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
},
{
id: 2,
title: 'Lorem ipsum dolor sit, amet con',
content:
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
},
{
id: 3,
title: 'Lorem ipsum dolor sit, amet con',
content:
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
}
],
show: true
},
contact: {
title: 'Have you got any',
titleHighlight: 'Question?',
subtitle: 'Reach out to us with any question you might have.',
address: 'Ashwoodfield House Farm, Kidderminster Rd, Stourbridge, Kingswinford DY6 0AA',
phone: '+4407339904995',
email: '[email protected]',
show: true
},
mailinglist: {
title: 'Join our mailing list',
subtitle:
'We are constantly releasing new courses and sharing them with our email list. Subscribe to get notified once we release a new course',
buttonLabel: 'Subscribe',
show: true
},
customLinks: {
show: true,
links: [
{
id: 1,
label: 'About Us',
url: '#aboutus',
openInNewTab: false
},
{
id: 2,
label: 'Courses',
url: '#courses',
openInNewTab: false
},
{
id: 3,
label: 'FAQ',
url: '#faq',
openInNewTab: false
},
{
id: 4,
label: 'Contact Us',
url: '#contact',
openInNewTab: false
}
]
},
footer: {
facebook: '#',
instagram: '#',
twitter: '#',
linkedin: '#',
show: true
}
});
@@ -0,0 +1,176 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
export interface SetupItem {
id: string;
title: string;
desc: string;
is_completed: boolean;
button_label: string;
}
export interface SetupProgressData {
setup: SetupItem[];
courses: Array<{ id: string; [key: string]: unknown }>;
lessons: Array<{ id: string; [key: string]: unknown }>;
orgSiteName: string;
}
type TCreateSetupData = {
orgHasAvatarUrl?: boolean;
isCourseCreated?: boolean;
isLessonCreated?: boolean;
isExerciseCreated?: boolean;
isCoursePublished?: boolean;
};
// Base setup data structure - created once
const createSetupData = ({
orgHasAvatarUrl = false,
isCourseCreated = false,
isLessonCreated = false,
isExerciseCreated = false,
isCoursePublished = false
}: TCreateSetupData = {}): SetupItem[] => [
{
id: 'profile',
title: 'setup.personal_profile.title',
desc: 'setup.personal_profile.desc',
is_completed: false,
button_label: 'setup.personal_profile.button_label'
},
{
id: 'organization',
title: 'setup.organization_profile.title',
desc: 'setup.organization_profile.desc',
is_completed: orgHasAvatarUrl,
button_label: 'setup.organization_profile.button_label'
},
{
id: 'course',
title: 'setup.course.title',
desc: 'setup.course.desc',
is_completed: isCourseCreated,
button_label: 'setup.course.button_label'
},
{
id: 'lesson',
title: 'setup.lesson.title',
desc: 'setup.lesson.desc',
is_completed: isLessonCreated,
button_label: 'setup.lesson.button_label'
},
{
id: 'exercise',
title: 'setup.exercise.title',
desc: 'setup.exercise.desc',
is_completed: isExerciseCreated,
button_label: 'setup.exercise.button_label'
},
{
id: 'publish',
title: 'setup.publish_course.title',
desc: 'setup.publish_course.desc',
is_completed: isCoursePublished,
button_label: 'setup.publish_course.button_label'
}
];
/**
* API class for setup progress operations
*/
class SetupProgressApi extends BaseApiWithErrors {
progress = $state<SetupProgressData>({
orgSiteName: '',
setup: [],
courses: [],
lessons: []
});
constructor() {
super();
this.isLoading = true;
}
/**
* Computed setup list with profile completion check
* This computation is shared across components to avoid duplication
* Note: Profile completion check should be done in components using $profile
* This returns the base setup list, and components can derive the final list
*/
get setupList(): SetupItem[] {
return this.progress.setup || [];
}
/**
* Fetches setup progress data for an organization
* @param siteName Organization site name
*/
async fetchSetupProgress(siteName: string) {
await this.execute<typeof classroomio.organization.setup.$get>({
requestFn: () =>
classroomio.organization.setup.$get({
query: { siteName }
}),
logContext: 'fetching setup progress',
onSuccess: (response) => {
if (!response.data) {
// Set default empty state if no data
const setup = createSetupData();
this.progress = {
orgSiteName: siteName,
setup,
courses: [],
lessons: []
};
return;
}
const {
isCourseCreated,
isCoursePublished,
orgHasAvatarUrl,
courseData,
lessonData,
isLessonCreated,
isExerciseCreated
} = response.data;
const setup = createSetupData({
orgHasAvatarUrl,
isCourseCreated,
isLessonCreated,
isExerciseCreated,
isCoursePublished
});
this.progress = {
orgSiteName: siteName,
setup,
courses: courseData || [],
lessons: lessonData || []
};
},
onError: (error) => {
console.error('Error fetching setup progress:', error);
// Set default empty state on error
const setup = createSetupData();
this.progress = {
orgSiteName: siteName,
setup,
courses: [],
lessons: []
};
}
});
}
}
export const setupProgressApi = new SetupProgressApi();
/**
* Calculates progress percentage from setup items
*/
export function calculateProgress(setupItems: SetupItem[]): number {
if (!setupItems || setupItems.length === 0) return 0;
const completed = setupItems.filter((item) => item.is_completed).length;
return Math.round((completed / setupItems.length) * 100);
}
@@ -0,0 +1,53 @@
<script lang="ts">
import { calculateProgress, setupProgressApi } from '../api/setup-progress.svelte';
import type { SetupItem } from '../api/setup-progress.svelte';
import { Skeleton } from '@cio/ui/base/skeleton';
let { setupItems, size = 'default' } = $props<{
setupItems?: SetupItem[];
size?: 'small' | 'default';
}>();
const effectiveSetupItems = $derived(setupItems ?? setupProgressApi.progress.setup ?? []);
const progressPercentage = $derived(calculateProgress(effectiveSetupItems));
const isLoading = $derived(setupProgressApi.isLoading);
const sizeClass = $derived(size === 'small' ? 'size-10' : 'size-14');
</script>
<div class="relative shrink-0">
{#if isLoading}
<!-- Loading skeleton - round circle -->
<Skeleton class="{sizeClass} rounded-full" />
{:else}
<svg class="{sizeClass} -rotate-90" viewBox="0 0 100 100">
<!-- Background circle -->
<circle
cx="50"
cy="50"
r="45"
fill="none"
stroke="currentColor"
stroke-width="8"
class="text-gray-200 dark:text-gray-700"
/>
<!-- Progress circle -->
<circle
cx="50"
cy="50"
r="45"
fill="none"
stroke="currentColor"
stroke-width="8"
stroke-linecap="round"
class="text-green-600"
stroke-dasharray={2 * Math.PI * 45}
stroke-dashoffset={2 * Math.PI * 45 * (1 - progressPercentage / 100)}
style="transition: stroke-dashoffset 0.5s ease-in-out;"
/>
</svg>
<!-- Center text -->
<div class="absolute inset-0 flex flex-col items-center justify-center">
<p class="{size === 'small' ? 'text-[10px]' : 'text-sm'} font-bold">{progressPercentage}%</p>
</div>
{/if}
</div>
@@ -0,0 +1,124 @@
<script lang="ts">
import { page } from '$app/state';
import Avatar from '$lib/components/Avatar/index.svelte';
import { t } from '$lib/utils/functions/translations';
import { currentOrg } from '$lib/utils/store/org';
import GoogleIconColored from '$lib/components/Icons/GoogleIconColored.svelte';
import { authClient } from '$lib/utils/services/auth/client';
import * as Card from '@cio/ui/base/card';
import { Button } from '@cio/ui/base/button';
import { Separator } from '@cio/ui/base/separator';
import { preventDefault } from '$lib/utils/functions/svelte';
import { ROUTE } from '$lib/utils/constants/routes';
interface Props {
isLogin?: boolean;
showOnlyContent?: boolean;
isLoading?: boolean;
showLogo?: boolean;
hideGoogleAuth?: boolean;
redirectPathname?: string;
handleSubmit?: () => void;
children?: import('svelte').Snippet;
}
let {
isLogin = true,
showOnlyContent = false,
isLoading = false,
showLogo = false,
hideGoogleAuth = false,
redirectPathname = '',
handleSubmit = () => {},
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}`;
const errorCallbackURL = `${window.location.origin + ROUTE.AUTH_FAILED}`;
console.log({ redirectTo });
try {
console.log('signInWithGoogle');
const data = await authClient.signIn.social({
provider: 'google',
callbackURL: redirectTo,
errorCallbackURL: errorCallbackURL
});
console.log('data', data);
} catch (error) {
console.log('catch error', error);
}
}
</script>
<div class="flex min-h-screen w-full items-center justify-center p-4">
<Card.Root class="ui:w-full max-w-[400px]">
{#if !showOnlyContent || showLogo}
<Card.Header class="ui:flex ui:flex-col ui:items-center ui:gap-4">
<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="/">
<Card.Title class="ui:text-2xl ui:font-bold">
{isLogin ? $t('login.welcome') : $t('login.create_account')}
</Card.Title>
</a>
{#if isLogin}
<Card.Description class="ui:text-center">Sign in to continue</Card.Description>
{/if}
</Card.Header>
{/if}
<Card.Content>
<form onsubmit={preventDefault(handleSubmit)}>
{@render children?.()}
</form>
{#if !showOnlyContent && !hideGoogleAuth}
<div class="mt-6 flex flex-col gap-6">
<div class="relative flex items-center justify-center">
<Separator />
<span class="ui:bg-card ui:text-muted-foreground absolute px-2 text-sm"> Or continue With </span>
</div>
<Button variant="outline" onclick={signInWithGoogle} disabled={isLoading} class="w-full">
<GoogleIconColored />
<span>
{isLogin ? $t('login.login_with_google') : $t('login.signup_with_google')}
</span>
</Button>
</div>
{/if}
</Card.Content>
{#if !showOnlyContent}
<Card.Footer class="flex-col gap-2 border-t pt-6">
<p class="text-muted-foreground text-center text-sm">
{#if isLogin}
{$t('login.not_registered_yet')}
<a class="text-primary hover:underline" href="/signup{page.url.search}">{$t('login.signup')}</a>
{:else}
{$t('login.already_have_account')}
<a class="text-primary hover:underline" href="/login{page.url.search}">{$t('login.login')}</a>
{/if}
</p>
</Card.Footer>
{/if}
</Card.Root>
</div>
@@ -5,6 +5,6 @@
let { label = $t('course.navItem.lessons.exercises.new_exercise_modal.coming_soon') } = $props();
</script>
<Badge variant="outline" class="ui:text-white bg-blue-500 dark:bg-blue-600 dark:text-black">
<Badge variant="outline" class="ui:text-white border-none! bg-blue-600 dark:bg-blue-700 dark:text-black">
{label}
</Badge>
+6 -1
View File
@@ -1,3 +1,8 @@
export { default as AppHeader } from './navigation/app-header.svelte';
export { default as AuthUI } from './auth-ui.svelte';
export { default as ComingSoon } from './coming-soon.svelte';
export { default as PageLoadProgress } from './page-load-progress.svelte';
export { default as AppHeader } from './navigation/app-header.svelte';
export { default as PoweredBy } from './upgrade-powered-by.svelte';
export { default as RoleBasedSecurity } from './role-based-security.svelte';
export { default as UpgradeBanner } from './upgrade-banner.svelte';
export { default as UpgradeModal } from './upgrade-modal.svelte';
@@ -30,7 +30,7 @@
</script>
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.List class="flex-nowrap!">
<Breadcrumb.Item>
<OrgSwitcher variant="breadcrumb" />
</Breadcrumb.Item>
@@ -6,10 +6,21 @@
import * as Popover from '@cio/ui/base/popover';
import Search from '../search.svelte';
import AppBreadcrumbs from './app-breadcrumbs.svelte';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import * as Empty from '@cio/ui/base/empty';
import { currentOrg } from '$lib/utils/store/org';
import { setupProgressApi } from '$lib/features/setup/api/setup-progress.svelte';
import AppSetup from './app-setup.svelte';
$effect(() => {
if ($currentOrg.siteName) {
setupProgressApi.fetchSetupProgress($currentOrg.siteName);
}
});
</script>
<header
class="group-has-data-[collapsible=icon]/sidebar-wrapper:h-8 border-border flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear"
class="group-has-data-[collapsible=icon]/sidebar-wrapper:h-8 border-border ui:bg-background bg-background sticky top-0 z-50 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear"
>
<div class="flex w-full items-center gap-2 px-4">
<Sidebar.Trigger />
@@ -22,6 +33,8 @@
<span class="grow"></span>
<AppSetup />
<Search />
<Popover.Root>
@@ -31,11 +44,21 @@
</Button>
</Popover.Trigger>
<Popover.Content>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-2">
<p class="text-sm">Your notifications will show here</p>
</div>
</div>
<Empty.Root class="ui:from-muted/50 ui:to-background ui:h-full ui:bg-gradient-to-b ui:from-30%">
<Empty.Header>
<Empty.Media variant="icon">
<BellIcon />
</Empty.Media>
<Empty.Title>No Notifications</Empty.Title>
<Empty.Description>You're all caught up. New notifications will appear here.</Empty.Description>
</Empty.Header>
<Empty.Content>
<Button variant="outline" size="sm">
<RefreshCcwIcon />
Refresh
</Button>
</Empty.Content>
</Empty.Root>
</Popover.Content>
</Popover.Root>
</div>
@@ -0,0 +1,64 @@
<script lang="ts">
import * as HoverCard from '@cio/ui/base/hover-card';
import { Button } from '@cio/ui/base/button';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import CircleIcon from '@lucide/svelte/icons/circle';
import SetupProgress from '$lib/features/setup/components/setup-progress.svelte';
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import BadgeCheckIcon from '@lucide/svelte/icons/badge-check';
import { calculateProgress, setupProgressApi } from '$lib/features/setup/api/setup-progress.svelte';
function handleRefresh() {
if ($currentOrg.siteName) {
setupProgressApi.fetchSetupProgress($currentOrg.siteName);
}
}
const setupList = $derived(
setupProgressApi.setupList.map((item) => {
if (item.id === 'profile') {
return {
...item,
is_completed: !$profile.avatarUrl?.includes('avatars/avatar.png')
};
}
return item;
})
);
const progressPercentage = $derived(calculateProgress(setupList));
</script>
{#if progressPercentage < 100}
<HoverCard.Root openDelay={200}>
<HoverCard.Trigger class="hidden md:block">
<SetupProgress size="small" setupItems={setupList} />
</HoverCard.Trigger>
<HoverCard.Content class="hidden w-80 md:block">
{#if $currentOrg.id}
<div class="space-y-2">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold">{$t('setup.your_checklist')}</h4>
<Button variant="secondary" size="icon" onclick={handleRefresh} disabled={setupProgressApi.isLoading}>
<RefreshCcwIcon class="size-3 {setupProgressApi.isLoading ? 'animate-spin' : ''}" />
</Button>
</div>
{#each setupList as item}
<div class="flex items-center gap-2 text-sm">
{#if item.is_completed}
<BadgeCheckIcon class="custom text-green-600! size-4 shrink-0 opacity-50" />
<span class="text-muted-foreground line-through opacity-50">{$t(item.title)}</span>
{:else}
<CircleIcon class="size-4 shrink-0 text-gray-400" />
<span>{$t(item.title)}</span>
{/if}
</div>
{/each}
</div>
{/if}
</HoverCard.Content>
</HoverCard.Root>
{/if}
@@ -64,7 +64,7 @@ export function generateBreadcrumbs(
// Handle items with query params (like settings tabs)
if (matchedNavItem?.items && searchParams) {
const subItem = matchedNavItem.items.find((sub) => isActive(pathWithQuery, sub.url, true));
const subItem = matchedNavItem.items.find((sub) => isActive(pathWithQuery, sub.url, undefined, true));
if (subItem) {
breadcrumbs.push({
label: subItem.title,

Some files were not shown because too many files have changed in this diff Show More