feat: migrate auth from supabase to better auth (#596)

* feat: add README for @cio/db package and remove existingAccounts.json

* load session, load profile and organization, add create profile hook

* chore: cache session request

The trick was passing all the cookies to the backend. Previously I was only passing the better-auth.session_token and not better-auth.session_data. Read about the difference here https://www.better-auth.com/docs/concepts/cookies#custom-cookies

* feat: login, logout, onboarding, verify email, reset password.

* feat: complete change email and email verification

* feat: standardise how we make rpc calls and create org endpoints

* fix ui errors

* fix: handle potential undefined customerId and standardize avatar URL property

* refactor: standardize custom domain handling and update telegram chat ID references

* chore: add tailwind prefix to @cio/ui

this fixed the media query issues because  @cio/ui classes were overriding this dashboard tailwind build

* chore: clean up org sidebar

* chore: persist mode state

* feat: implement order breadcrumbs

* chore: upgrade to better auth 1.4

* chore: address common auth flow bugs and setup app for others to setup locally.
This commit is contained in:
Rotimi Best
2025-11-24 01:00:50 +01:00
committed by GitHub
parent 24741644d6
commit a3924100dd
567 changed files with 22364 additions and 13437 deletions
@@ -1,6 +1,8 @@
<script lang="ts">
import type { Component } from 'svelte';
interface Activity {
icon: any;
icon: Component;
title: string;
percentage: number | string;
description: string;
@@ -7,24 +7,22 @@
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { t } from '$lib/utils/functions/translations';
import { currentOrg } from '$lib/utils/store/org';
import type { SupabaseClient } from '@supabase/supabase-js';
import GoogleIconColored from '../Icons/GoogleIconColored.svelte';
import { authClient } from '$lib/utils/services/auth/client';
interface Props {
supabase: SupabaseClient;
handleSubmit?: any;
isLogin?: boolean;
showOnlyContent?: boolean;
isLoading?: boolean;
showLogo?: boolean;
formRef: any;
formRef?: any;
hideGoogleAuth?: boolean;
redirectPathname?: string;
children?: import('svelte').Snippet;
}
let {
supabase,
handleSubmit = () => {},
isLogin = true,
showOnlyContent = false,
@@ -46,22 +44,19 @@
// const redirectTo = `https://app.classroomio.com?forwardTo=${
// window.location.origin + params.get('redirect')
// }`;
const pathname = redirectPathname || params.get('redirect');
const pathname = redirectPathname || params.get('redirect') || '';
const redirectTo = `${window.location.origin + pathname}`;
console.log({ redirectTo });
try {
console.log('signInWithGoogle');
const { error, data } = await supabase.auth.signInWithOAuth({
const data = await authClient.signIn.social({
provider: 'google',
options: {
redirectTo
}
callbackURL: redirectTo
});
console.log('data', data);
console.log('error', error);
} catch (error) {
console.log('catch error', error);
}
@@ -74,7 +69,7 @@
{#if !showOnlyContent || showLogo}
<div class="flex w-full flex-col items-center justify-center pt-2">
<Avatar
src={$currentOrg.avatar_url ? $currentOrg.avatar_url : '/logo-192.png'}
src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : '/logo-192.png'}
name={$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}
shape="rounded-md"
width="w-10"
@@ -1,10 +1,12 @@
<script lang="ts">
import XIcon from '@lucide/svelte/icons/x';
import { Circle } from 'svelte-loading-spinners';
import IconButton, { type Props as IconButtonProps } from '$lib/components/IconButton/index.svelte';
interface Props {
onClick?: IconButtonProps['onClick'];
contained?: boolean;
isClosing?: boolean;
color?: string;
size?: IconButtonProps['size'];
toolTipProps?: IconButtonProps['toolTipProps'];
@@ -13,11 +15,16 @@
let {
onClick = () => {},
contained = false,
isClosing = false,
color = 'text-primary-700 bg-primary-200 dark:bg-neutral-500',
size = 'small'
}: Props = $props();
</script>
<IconButton {onClick} {color} {contained} {size}>
<XIcon size={size === 'large' ? 24 : 16} class="dark:text-black" />
<IconButton {onClick} {color} {contained} {size} disabled={isClosing}>
{#if isClosing}
<Circle size="20" color="#1d4ee2" unit="px" duration="1s" />
{:else}
<XIcon size={size === 'large' ? 24 : 16} class="dark:text-black" />
{/if}
</IconButton>
@@ -1,14 +0,0 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
let { label = $t('course.navItem.lessons.exercises.new_exercise_modal.coming_soon') } = $props();
</script>
<p class="org-avatar bg-primary-200 text-primary-700 rounded-md px-2 py-1 text-center text-xs font-medium">
{label}
</p>
<style>
.org-avatar {
width: fit-content;
}
</style>
@@ -1,12 +1,11 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { Button } from '@cio/ui/base/button';
import EyeIcon from '@lucide/svelte/icons/eye';
import { onDestroy, onMount, untrack } from 'svelte';
import * as Breadcrumb from '@cio/ui/base/breadcrumb';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
// import * as Tabs from '@cio/ui/base/tabs';
import CirclePlusIcon from '@lucide/svelte/icons/circle-plus';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
@@ -84,7 +83,7 @@
});
onMount(() => {
const tabParam = $page.url.searchParams.get('tab');
const tabParam = page.url.searchParams.get('tab');
if (tabParam) {
selectedTab = tabParam;
}
@@ -92,7 +91,7 @@
$effect(() => {
untrack(() => {
goto($page.url.pathname + '?tab=' + selectedTab);
goto(page.url.pathname + '?tab=' + selectedTab);
});
});
// $effect(() => {
@@ -5,7 +5,7 @@
import Modal from '$lib/components/Modal/index.svelte';
import Confetti from '$lib/components/Confetti/index.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import ComingSoon from '$lib/components/ComingSoon/index.svelte';
import { ComingSoon } from '$lib/features/ui';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
@@ -1,8 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount, untrack } from 'svelte';
import { Moon } from 'svelte-loading-spinners';
import { Circle } from 'svelte-loading-spinners';
import { lesson } from '../store/lessons';
import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
@@ -161,7 +160,7 @@
{#if isFetching}
<Backdrop>
<Moon size="60" color="#1d4ed8" unit="px" duration="1s" />
<Circle size="60" color="#1d4ed8" unit="px" duration="1s" />
</Backdrop>
{/if}
@@ -12,14 +12,14 @@
import { supabase } from '$lib/utils/functions/supabase';
import { t } from '$lib/utils/functions/translations';
import { LOCALE } from '$lib/utils/types';
import { snackbar } from '$lib/components/Snackbar/store';
import type { TLocale } from '@cio/db/types';
interface LessonHistory {
new_content: string;
old_content: string;
timestamp: Date;
locale: LOCALE;
locale: TLocale;
lesson_id: string;
}
@@ -34,7 +34,7 @@
new_content: '',
old_content: '',
timestamp: new Date(),
locale: LOCALE.EN,
locale: 'en',
lesson_id: ''
};
let selectedVersionIndex = $state(0);
@@ -19,7 +19,7 @@
import { course } from '$lib/components/Course/store';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$lib/components/Snackbar/store';
import type { LessonPage, LOCALE } from '$lib/utils/types';
import type { LessonPage } from '$lib/utils/types';
import { isHtmlValueEmpty } from '$lib/utils/functions/toHtml';
import {
deleteLessonVideo,
@@ -50,6 +50,7 @@
import ComponentDocument from './components/ComponentDocument.svelte';
import AddVideoToLesson from '$lib/components/Course/components/Lesson/Materials/Video/AddVideoToLesson.svelte';
import AddDocumentToLesson from '$lib/components/Course/components/Lesson/Materials/Document/AddDocumentToLesson.svelte';
import type { TLocale } from '@cio/db/types';
interface Props {
mode?: any;
@@ -72,7 +73,6 @@
let openPopover = $state<boolean>(false);
let aiButtonClass =
'flex items-center px-5 py-2 border border-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-md w-full mb-2';
let player = $state<HTMLVideoElement>();
let localeExists: Record<string, boolean> = {};
// let prevContent = '';
let timeoutId: NodeJS.Timeout;
@@ -194,7 +194,7 @@
return lessonRes;
}
function isMaterialsEmpty(materials: LessonPage['materials'], translation: Record<LOCALE, string>) {
function isMaterialsEmpty(materials: LessonPage['materials'], translation: Record<TLocale, string>) {
const { slide_url, videos, note, documents } = materials;
return (
@@ -273,7 +273,7 @@
function autoSave(
updatedMaterials: LessonPage['materials'],
_translation: Record<LOCALE, string>,
_translation: Record<TLocale, string>,
_isLoading?: boolean,
_lessonId?: string
) {
@@ -343,6 +343,7 @@
// updateNoteByCompletion($completion);
// });
let player = $state<HTMLVideoElement | null>(null);
$effect(() => {
initPlyr(player, $lesson.materials.videos);
});
@@ -531,8 +532,9 @@
<Component {lessonId} />
{/each}
{#if $currentOrg.customization.apps.comments}
{#if $currentOrg.customization?.apps?.comments}
<hr class="my-5" />
{$currentOrg.customization}
<Comments {lessonId} />
{/if}
</div>
@@ -1,5 +1,4 @@
import type { Course, Lesson, LessonComment, LessonCompletion, LessonPage, LessonSection } from '$lib/utils/types';
import type { Updater, Writable } from 'svelte/store';
import {
createLesson,
createLessonSection,
@@ -9,8 +8,9 @@ import {
updateLessonSection
} from '$lib/utils/services/courses';
import { LOCALE } from '$lib/utils/types';
import type { RealtimeChannel } from '@supabase/supabase-js';
import type { TLocale } from '@cio/db/types';
import type { Writable } from 'svelte/store';
import { lessonValidation } from '$lib/utils/functions/validator';
import { snackbar } from '$lib/components/Snackbar/store';
import { writable } from 'svelte/store';
@@ -40,7 +40,7 @@ export const lessonCommentsChannel: Writable<RealtimeChannel> = writable();
export const lesson = writable<LessonPage>({
id: null,
locale: LOCALE.EN,
locale: 'en',
title: '',
totalExercises: 0,
totalComments: 0,
@@ -57,7 +57,7 @@ export const lesson = writable<LessonPage>({
});
export const lessonByTranslation = writable<{
[key: string]: Record<LOCALE, string>;
[key: string]: Record<TLocale, string>;
}>({});
export const lessonComments = writable<LessonComment[]>([]);
@@ -72,8 +72,8 @@ export function setLesson({
totalComments
}: {
id: string;
lessonData: Lesson & { lesson_language: { locale: LOCALE; content: string }[] };
locale: LOCALE;
lessonData: Lesson & { lesson_language: { locale: TLocale; content: string }[] };
locale: TLocale;
totalExercises: number;
totalComments: number;
}) {
@@ -111,7 +111,7 @@ export function setLesson({
acc[cur.locale] = cur.content;
return acc;
},
{} as Record<LOCALE, string>
{} as Record<TLocale, string>
)
};
});
@@ -21,9 +21,7 @@
import { NAV_IDS } from './constants';
import { profile } from '$lib/utils/store/user';
import { sideBar } from '$lib/components/Org/store';
import { course } from '$lib/components/Course/store';
import { isMobile } from '$lib/utils/store/useMobile';
import { t } from '$lib/utils/functions/translations';
import { handleAddLessonWidget } from '../Lesson/store';
import { getIsLessonComplete } from '../Lesson/functions';
@@ -50,7 +48,7 @@
url: getNavItemRoute($course.id),
isActive: (path || page.url.pathname) === getNavItemRoute($course.id),
show() {
return isStudent ? $currentOrg.customization.course.newsfeed : true;
return isStudent ? $currentOrg.customization?.['course']?.['newsfeed'] : true;
},
icon: getNavIcon(NAV_IDS.NEWS_FEED)
},
@@ -121,7 +119,7 @@
isActive: (path || page.url.pathname) === getNavItemRoute($course.id, 'marks'),
show() {
if ($course.type == COURSE_TYPE.LIVE_CLASS) {
return isStudent ? $currentOrg.customization.course.grading : true;
return isStudent ? ($currentOrg.customization?.['course']?.['grading'] ?? false) : true;
}
return false;
},
@@ -186,8 +184,6 @@
}
}
const toggleSidebarOnMobile = () => $isMobile && ($sideBar.hidden = !$sideBar.hidden);
function getNavIcon(id: string) {
if (!id) return null;
@@ -217,10 +213,12 @@
return null;
}
function toggleSidebarOnMobile() {}
</script>
<Sidebar.Provider class="flex w-fit items-start gap-2">
<Sidebar.Root collapsible="icon" class="inset-y-12 h-[calc(100vh-48px)] {$sideBar.hidden ? 'hidden' : ''}">
<Sidebar.Root collapsible="icon">
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.GroupLabel>Course Navigation</Sidebar.GroupLabel>
@@ -232,7 +230,7 @@
{#if item.isLesson && item.items}
<Collapsible.Trigger>
{#snippet child({ props })}
<a href={item.url} onclick={toggleSidebarOnMobile}>
<a href={item.url}>
<Sidebar.MenuButton
{...props}
tooltipContent={item.title}
@@ -376,7 +374,7 @@
<Sidebar.MenuButton
tooltipContent={item.title}
class="flex w-full cursor-pointer items-center gap-4 px-1.5 py-2 {item.isActive
? 'bg-accent text-accent-foreground'
? 'ui:bg-accent ui:text-accent-foreground'
: ''}"
>
{@const Icon = item.icon}
@@ -12,7 +12,7 @@
import * as Popover from '@cio/ui/base/popover';
import { qrInviteNodeStore } from './store';
import { ROLE } from '$lib/utils/constants/roles';
import { ROLE } from '@cio/utils/constants';
import { getOrgTeam } from '$lib/utils/services/org';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
@@ -11,13 +11,11 @@
import { IconButton } from '$lib/components/IconButton';
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import Row from '$lib/components/Org/Settings/Layout/Row.svelte';
import { Row, Grid, Column } from '$lib/components/Org/Settings/Layout';
import SectionTitle from '$lib/components/Org/SectionTitle.svelte';
import Grid from '$lib/components/Org/Settings/Layout/Grid.svelte';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import Column from '$lib/components/Org/Settings/Layout/Column.svelte';
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
import { settings } from './store';
@@ -3,7 +3,7 @@ import type { Course, GroupPerson, Lesson, LessonSection } from '$lib/utils/type
import { get, writable, type Writable } from 'svelte/store';
import { lessonSections, lessons } from './components/Lesson/store/lessons';
import { ROLE } from '$lib/utils/constants/roles';
import { ROLE } from '@cio/utils/constants';
import { fetchCourse } from '$lib/utils/services/courses';
export const defaultCourse: Course = {
@@ -1,7 +1,7 @@
<script lang="ts">
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { Moon } from 'svelte-loading-spinners';
import { Circle } from 'svelte-loading-spinners';
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>
<Moon size="60" color="#1d4ed8" unit="px" duration="1s" />
<Circle size="60" color="#1d4ed8" unit="px" duration="1s" />
</Backdrop>
{/if}
@@ -2,17 +2,13 @@
import get from 'lodash/get';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
import {
isCourseFree,
getStudentInviteLink,
calcCourseDiscount
} from '$lib/utils/functions/course';
import { isCourseFree, getStudentInviteLink, calcCourseDiscount } from '$lib/utils/functions/course';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { goto } from '$app/navigation';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
import PaymentModal from './PaymentModal.svelte';
import type { Course } from '$lib/utils/types';
import { ROLE } from '$lib/utils/constants/roles';
import { ROLE } from '@cio/utils/constants';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { t } from '$lib/utils/functions/translations';
@@ -2,27 +2,22 @@
import { Skeleton } from '@cio/ui/base/skeleton';
</script>
<div class="root relative mb-4 mr-4 w-72 rounded-md border bg-gray-100 transition ease-in-out dark:bg-black">
<Skeleton class="h-40 w-full" />
<div class="p-3 pb-0">
<div class="mb-4 mr-4 h-[370px] w-72 rounded-md border bg-gray-100 p-2 transition ease-in-out dark:bg-black">
<Skeleton class="h-[45%] w-full" />
<div class="flex h-[50%] flex-col justify-between pb-2 pt-4">
<!-- Title -->
<Skeleton class="h-4 w-2/4" />
<!-- Description -->
<Skeleton class="h-4 w-2/4" />
<Skeleton class="h-4 w-2/4" />
<Skeleton class="mt-4 h-4 w-full" />
<Skeleton class="mb-4 h-4 w-full" />
<div class="mb-6 mt-4 flex flex-col gap-2">
<Skeleton class=" h-4 w-full" />
<Skeleton class=" h-4 w-full" />
<Skeleton class=" h-4 w-full" />
</div>
<div class="mb-3 flex justify-between">
<div class="flex justify-between">
<Skeleton class="h-4 w-1/4" />
<Skeleton class="h-4 w-1/4" />
</div>
<Skeleton class="h-4 w-1/4" />
</div>
</div>
<style>
.root {
min-height: 370px;
}
</style>
@@ -3,7 +3,7 @@
import { goto } from '$app/navigation';
import { page } from '$app/state';
import ComingSoon from '$lib/components/ComingSoon/index.svelte';
import { ComingSoon } from '$lib/features/ui';
import { validateForm } from '$lib/components/Courses/functions';
import { courses, createCourseModal } from '$lib/components/Courses/store';
import TextArea from '$lib/components/Form/TextArea.svelte';
@@ -11,7 +11,7 @@
import Modal from '$lib/components/Modal/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { ROLE } from '$lib/utils/constants/roles';
import { ROLE } from '@cio/utils/constants';
import { supabase } from '$lib/utils/functions/supabase';
import { t } from '$lib/utils/functions/translations';
import { addDefaultNewsFeed, addGroupMember } from '$lib/utils/services/courses';
@@ -151,7 +151,7 @@
{$t('courses.new_course_modal.type_selector_title')}
</h2>
<div class="my-8 flex flex-col items-center justify-evenly gap-4 md:flex-row!">
<div class="my-8 flex flex-col items-center justify-evenly gap-4 md:flex-row">
{#each options as option}
<button
class="w-11/12 rounded-md border-2 p-5 md:h-60 md:w-[261px] dark:bg-neutral-700 {option.type === type
@@ -10,10 +10,9 @@
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
import { sideBar } from '$lib/components/Org/store';
import { t } from '$lib/utils/functions/translations';
import NavUser from '../Sidebar/components/nav-user.svelte';
import { SidebarFooterMenu } from '$lib/features/ui/sidebar/footer';
interface SideLinks {
name: string;
@@ -25,7 +24,7 @@
const user = $derived.by(() => ({
name: $profile.fullname,
email: $profile.email,
avatar: $profile.avatar_url
avatar: $profile.avatarUrl
}));
function isActive(pagePath: string, itemPath: string) {
@@ -74,14 +73,10 @@
}
].filter((link) => (link.show ? link.show() : true))
);
const toggleSidebar = () => {
$sideBar.hidden = !$sideBar.hidden;
};
</script>
<Sidebar.Provider class="w-fit">
<Sidebar.Root collapsible="icon" class="inset-y-12 h-[calc(100vh-48px)] {$sideBar.hidden ? 'hidden' : ''}">
<Sidebar.Root collapsible="icon">
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.GroupLabel>LMS Navigation</Sidebar.GroupLabel>
@@ -91,9 +86,8 @@
<Sidebar.MenuButton tooltipContent={item.name}>
<a
href={item.link}
onclick={toggleSidebar}
class="flex w-full items-center gap-4 {isActive(page.url.pathname, item.link)
? 'bg-accent text-accent-foreground rounded-md px-3 py-2'
? 'ui:bg-accent ui:text-accent-foreground rounded-md px-3 py-2'
: ''}"
>
<item.icon size={16} />
@@ -108,7 +102,7 @@
{#if user}
<Sidebar.Footer>
<NavUser {user} />
<SidebarFooterMenu />
</Sidebar.Footer>
{/if}
@@ -1,9 +1,7 @@
<script>
import TextField from '$lib/components/Form/TextField.svelte';
import Row from '$lib/components/Org/Settings/Layout/Row.svelte';
import Grid from '$lib/components/Org/Settings/Layout/Grid.svelte';
import { Row, Grid, Column } from '$lib/components/Org/Settings/Layout';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import Column from '$lib/components/Org/Settings/Layout/Column.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import LanguagePicker from '$lib/components/Org/Settings/LanguagePicker.svelte';
@@ -12,6 +12,7 @@
containerClass?: string;
size?: string;
isCloseable?: boolean;
isClosing?: boolean;
children?: import('svelte').Snippet;
}
@@ -26,6 +27,7 @@
containerClass = '',
size = '',
isCloseable = true,
isClosing = false,
children
}: Props = $props();
@@ -40,7 +42,7 @@
{#if open}
<div
class="dialog fixed inset-0 z-50 flex items-center justify-center overflow-y-auto overflow-x-hidden bg-gray-100 bg-opacity-50"
class="dialog fixed inset-0 z-50 flex items-center justify-center overflow-x-hidden bg-gray-100 bg-opacity-50"
onclick={onClose}
role="presentation"
>
@@ -48,24 +50,24 @@
<div
class="{maxWidth || 'container'} {size === 'sm'
? 'small'
: ''} mx-auto bg-white dark:bg-neutral-800 {width} rounded-md pt-2 shadow-lg"
: ''} mx-auto bg-white dark:bg-neutral-800 {width} max-h-4/5 overflow-y-auto rounded-md shadow-lg"
onclick={(e) => e.stopPropagation()}
role="presentation"
>
<div
class="{headerClass} flex items-center justify-between border border-l-0 border-r-0 border-t-0 border-gray-100 p-4 px-5 dark:border-neutral-600"
class="{headerClass} sticky top-0 z-10 flex items-center justify-between border border-l-0 border-r-0 border-t-0 border-gray-100 bg-white p-4 px-5 dark:border-neutral-600 dark:bg-neutral-800"
>
<p class="text-md m-0 font-medium dark:text-white {labelClass}">
{modalHeading}
</p>
{#if isCloseable}
<div class="rounded-full">
<CloseButton onClick={onClose} contained={true} />
<CloseButton onClick={onClose} contained={true} {isClosing} />
</div>
{/if}
</div>
<div class="body h-4/5 overflow-y-auto p-6 {containerClass}">
<div class="body p-6 {containerClass} ">
{@render children?.()}
</div>
</div>
@@ -0,0 +1 @@
export { default as Modal } from './index.svelte';
@@ -1,7 +1,7 @@
<script lang="ts">
import { stopPropagation } from '$lib/utils/functions/svelte';
import { page } from '$app/stores';
import { page } from '$app/state';
import { user } from '$lib/utils/store/user';
import { type TCustomLinks } from './types';
import CustomLinks from './CustomLinks.svelte';
@@ -55,7 +55,7 @@
<CustomLinks {customLinks} isMobile={true} onMobileClick={closeMobileMenu} />
<!-- Login/Signup buttons in mobile menu -->
{#if !$user.isLoggedIn && !$page.url.pathname?.includes('/404')}
{#if !$user.isLoggedIn && !page.url.pathname?.includes('/404')}
<li class="pt-4">
<AuthButtons {disableSignup} {redirect} isMobile={true} onMobileClick={closeMobileMenu} />
</li>
@@ -1,15 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import BellIcon from '@lucide/svelte/icons/bell';
import MenuIcon from '@lucide/svelte/icons/menu';
import XIcon from '@lucide/svelte/icons/x';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import { ModeSwitcher } from '@cio/ui/base/dark-mode';
import { IconButton } from '$lib/components/IconButton';
import { globalStore } from '$lib/utils/store/app';
import { currentOrgPath } from '$lib/utils/store/org';
import { sideBar } from '../Org/store';
import { t } from '$lib/utils/functions/translations';
interface Props {
@@ -20,42 +16,15 @@
let { title = $bindable(''), navClass = '', isCoursePage = false }: Props = $props();
const toggleSidebar = () => {
$sideBar.hidden = !$sideBar.hidden;
};
let coursesPath = $derived(
$globalStore.isOrgSite ? '/lms/mylearning' : isCoursePage ? `${$currentOrgPath}/courses` : $currentOrgPath
);
</script>
<nav class="{navClass} bg-primary-700 flex h-12 w-full p-1 transition delay-150 duration-300 ease-in-out md:px-6">
<nav class="{navClass} bg-primary-700 flex h-12 w-full p-1 transition delay-150 duration-300 ease-in-out md:px-4">
<ul class="flex w-full items-center gap-2">
<div class="flex items-center text-white">
<li class="md:hidden">
<IconButton
onClick={() => {
toggleSidebar();
}}
>
{#if $sideBar.hidden}
<MenuIcon size={16} class="custom" />
{:else}
<XIcon size={16} class="custom" />
{/if}
</IconButton>
</li>
{#if isCoursePage}
<li class="hidden md:block">
<IconButton
onClick={() => {
toggleSidebar();
}}
>
<MenuIcon size={16} class="custom" />
</IconButton>
</li>
<li class="hidden md:block">
<IconButton onClick={() => goto(coursesPath)}>
<ArrowLeftIcon size={16} class="custom" />
@@ -77,16 +46,6 @@
<li>
<BellIcon class="custom text-white" size={16} />
</li>
<li>
<!-- <IconButton size="small" onClick={toggleDarkMode}>
{#if $globalStore.isDark}
<SunIcon size={16} class="custom" />
{:else}
<MoonIcon class="custom text-white" size={16} />
{/if}
</IconButton> -->
<ModeSwitcher />
</li>
<li></li>
</ul>
@@ -1,5 +1,5 @@
<script lang="ts">
import { page } from '$app/stores';
import { page } from '$app/state';
import { user } from '$lib/utils/store/user';
import { isCoursePage } from '$lib/utils/functions/app';
import { t } from '$lib/utils/functions/translations';
@@ -32,7 +32,7 @@
let navClass = '';
let mobileMenuOpen = $state(false);
let redirect = $derived(isCoursePage($page.url.pathname) ? `?redirect=${$page.url.pathname}` : '');
let redirect = $derived(isCoursePage(page.url.pathname) ? `?redirect=${page.url.pathname}` : '');
let showLinks = $derived(customLinks && customLinks.show && customLinks.links && customLinks.links.length > 0);
function toggleMobileMenu() {
@@ -69,12 +69,12 @@
{#if isOrgSite}
<li><a class="block" href="/lms"> {$t('navigation.goto_lms')} </a></li>
{/if}
{:else if isOrgSite && !$page.url.pathname?.includes('/404')}
{:else if isOrgSite && !page.url.pathname?.includes('/404')}
<!-- Hide login/signup buttons on mobile when custom links exist -->
<div class="hidden lg:block">
<AuthButtons {disableSignup} {redirect} />
</div>
{:else if !isOrgSite && !$page.url.pathname?.includes('/404')}
{:else if !isOrgSite && !page.url.pathname?.includes('/404')}
<AuthButtons {disableSignup} {redirect} />
{/if}
@@ -1,19 +1,10 @@
<script lang="ts">
import { page } from '$app/state';
import { browser } from '$app/environment';
import BellIcon from '@lucide/svelte/icons/bell';
import MoonIcon from '@lucide/svelte/icons/moon';
import SunIcon from '@lucide/svelte/icons/sun';
import Avatar from '$lib/components/Avatar/index.svelte';
import TextChip from '$lib/components/Chip/Text.svelte';
import XIcon from '@lucide/svelte/icons/x';
import MenuIcon from '@lucide/svelte/icons/menu';
import { IconButton } from '$lib/components/IconButton';
import { globalStore } from '$lib/utils/store/app';
import { sideBar } from '$lib/components/Org/store';
import { currentOrg } from '$lib/utils/store/org';
import { toggleBodyByMode } from '$lib/utils/functions/app';
import { t } from '$lib/utils/functions/translations';
interface Props {
@@ -22,19 +13,6 @@
let { navClass = '' }: Props = $props();
const toggleSidebar = () => {
$sideBar.hidden = !$sideBar.hidden;
};
function toggleDarkMode() {
$globalStore.isDark = !$globalStore.isDark;
toggleBodyByMode($globalStore.isDark);
if (browser) {
localStorage.setItem('mode', $globalStore.isDark ? 'dark' : '');
}
}
function shortenName(name: string) {
return name?.substring(0, 2)?.toUpperCase() || '';
}
@@ -42,15 +20,6 @@
<nav class="{navClass} bg-primary-700 flex h-[48px] w-full p-2 md:px-6">
<ul class="flex w-full items-center">
<li class="md:hidden">
<IconButton onClick={toggleSidebar}>
{#if $sideBar.hidden}
<MenuIcon size={16} />
{:else}
<XIcon size={16} />
{/if}
</IconButton>
</li>
<div class="">
<a href={page.url.pathname} title={$t('navigation.goto_home')} id="logo" class="flex items-center text-lg">
{#if $currentOrg.avatar_url}
@@ -76,15 +45,6 @@
<li>
<BellIcon class="text-white" size={16} />
</li>
<li>
<IconButton size="small" onClick={toggleDarkMode}>
{#if $globalStore.isDark}
<SunIcon class="text-white" size={16} />
{:else}
<MoonIcon class="text-white" size={16} />
{/if}
</IconButton>
</li>
<li></li>
</ul>
</nav>
@@ -165,7 +165,7 @@
</div>
</div>
{:else}
<Box className="w-screen">
<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>
@@ -1,14 +0,0 @@
<script lang="ts">
import { globalStore } from '$lib/utils/store/app';
import NewsSection from './components/NewsSection.svelte';
import LogoutButton from './components/LogoutButton.svelte';
</script>
<div class="cursor-pointer space-y-2">
{#if !$globalStore.isOrgSite}
<NewsSection />
{/if}
<LogoutButton />
</div>
@@ -1,40 +0,0 @@
<script lang="ts">
import posthog from 'posthog-js';
import { goto } from '$app/navigation';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import LogOutIcon from '@lucide/svelte/icons/log-out';
import { t } from '$lib/utils/functions/translations';
import { supabase } from '$lib/utils/functions/supabase';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { currentOrg, orgs, defaultCurrentOrgState } from '$lib/utils/store/org';
import { user, profile, defaultProfileState, defaultUserState } from '$lib/utils/store/user';
async function logout() {
const { error } = await supabase.auth.signOut();
if (error) {
console.error('Error logging out: ', error);
}
currentOrg.set(defaultCurrentOrgState);
orgs.set([]);
user.set(defaultUserState);
profile.set(defaultProfileState);
capturePosthogEvent('user_logged_out');
posthog.reset();
goto('/login');
}
</script>
<DropdownMenu.Separator />
<DropdownMenu.Item>
<button onclick={logout} class="w-full cursor-pointer">
<span class="flex items-center gap-2">
<LogOutIcon />
<p class="text-sm">{$t('settings.profile.logout')}</p>
</span>
</button>
</DropdownMenu.Item>
@@ -1,38 +0,0 @@
<script lang="ts">
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import RocketIcon from '@lucide/svelte/icons/rocket';
import BellPlusIcon from '@lucide/svelte/icons/bell-plus';
import BadgeHelpIcon from '@lucide/svelte/icons/badge-help';
import { t } from '$lib/utils/functions/translations';
import { currentOrgPath } from '$lib/utils/store/org';
</script>
<div class="space-y-4">
<DropdownMenu.Item class="m-0">
<a href="https://classroomio.com/roadmap" target="_blank" class="flex w-full items-center gap-2">
<BellPlusIcon size={16} />
<p class="text-sm">{$t('profileMenu.whats_new')}</p>
</a>
</DropdownMenu.Item>
<DropdownMenu.Item class="m-0">
<a href="https://classroomio.com/blog/launch-week" target="_blank" class="flex w-full items-center gap-2">
<RocketIcon size={16} />
<p class="text-sm">{$t('profileMenu.launch_week')}</p>
</a>
</DropdownMenu.Item>
<DropdownMenu.Item class="m-0">
<a href={$currentOrgPath} class="flex h-full w-full items-center justify-start space-x-2 text-start">
<BadgeHelpIcon size={20} />
<p class="text-sm dark:text-white">{$t('org_navigation.help')}</p>
</a>
</DropdownMenu.Item>
</div>
<style>
a {
text-decoration: none;
}
</style>
@@ -24,11 +24,9 @@
import CardLoader from '$lib/components/Courses/components/Card/Loader.svelte';
import PageLoader from './PageLoader.svelte';
import Row from '../Settings/Layout/Row.svelte';
import Grid from '../Settings/Layout/Grid.svelte';
import { Row, Grid, Column } from '../Settings/Layout';
import * as Accordion from '@cio/ui/base/accordion';
import { t } from '$lib/utils/functions/translations';
import Column from '../Settings/Layout/Column.svelte';
import type { CurrentOrg } from '$lib/utils/types/org';
import { getSupabase } from '$lib/utils/functions/supabase';
import { getCourseBySiteName } from '$lib/utils/services/org';
@@ -1,43 +0,0 @@
<script lang="ts">
import * as Popover from '@cio/ui/base/popover';
import * as Command from '@cio/ui/base/command';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import { profile } from '$lib/utils/store/user';
import Menu from '../FooterMenu/Menu.svelte';
import Avatar from '$lib/components/Avatar/index.svelte';
let open = $state(false);
</script>
<div class="flex w-full items-center">
<Popover.Root bind:open>
<Popover.Trigger class="w-full">
<button
class="flex w-full items-center justify-between rounded-md p-1.5 hover:bg-gray-200 dark:hover:bg-neutral-800"
onclick={(e) => {
e.stopPropagation();
open = !open;
}}
>
<div class="flex w-full items-center justify-start space-x-2 text-start">
<Avatar src={$profile.avatar_url} name={$profile.username} width="w-[1.2rem]" height="h-[1.2rem]" />
<p class="max-w-full truncate text-sm font-medium dark:text-white">
{$profile.fullname}
</p>
</div>
<ChevronsUpDownIcon size={16} />
</button>
</Popover.Trigger>
<Popover.Content class="w-[200px] p-0" side="right" align="start">
<Command.Root>
<Command.List>
<Menu />
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
</div>
@@ -4,9 +4,7 @@
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { currentOrgPlan, isFreePlan } from '$lib/utils/store/org';
import Row from './Layout/Row.svelte';
import Grid from './Layout/Grid.svelte';
import Column from './Layout/Column.svelte';
import { Row, Grid, Column } from './Layout';
import SectionTitle from '../SectionTitle.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
@@ -20,7 +18,7 @@
try {
if ($currentOrgPlan?.provider === 'polar') {
const url = new URL('/api/polar/portal', window.location.origin);
url.searchParams.set('customerId', $currentOrgPlan?.customerId);
url.searchParams.set('customerId', $currentOrgPlan?.customerId || '');
window.open(url.toString(), '_blank');
}
@@ -10,9 +10,7 @@
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { handleOpenWidget } from '$lib/components/CourseLandingPage/store';
import Row from './Layout/Row.svelte';
import Grid from './Layout/Grid.svelte';
import Column from './Layout/Column.svelte';
import { Row, Grid, Column } from './Layout';
import SectionTitle from '../SectionTitle.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
@@ -18,15 +18,13 @@
import { updateOrgSiteNameValidation } from '$lib/utils/functions/validator';
import { sanitizeDomain, sendDomainRequest } from '$lib/utils/functions/domain';
import Row from './Layout/Row.svelte';
import Grid from './Layout/Grid.svelte';
import Column from './Layout/Column.svelte';
import { Row, Grid, Column } from './Layout';
import SectionTitle from '../SectionTitle.svelte';
import TextChip from '$lib/components/Chip/Text.svelte';
import { IconButton } from '$lib/components/IconButton';
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import ComingSoon from '$lib/components/ComingSoon/index.svelte';
import { ComingSoon } from '$lib/features/ui';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
@@ -158,7 +156,7 @@
snackbar.success();
$currentOrg.customDomain = undefined;
$currentOrg.customDomain = '';
$currentOrg.isCustomDomainVerified = false;
isCustomDomainLoading = false;
}
@@ -5,9 +5,7 @@
import { snackbar } from '$lib/components/Snackbar/store';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import Row from './Layout/Row.svelte';
import Grid from './Layout/Grid.svelte';
import Column from './Layout/Column.svelte';
import { Row, Grid, Column } from './Layout';
import SectionTitle from '../SectionTitle.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
@@ -30,7 +28,7 @@
console.log(error);
snackbar.error('snackbar.course_settings.error.failed_integration');
} else {
$profile.telegram_chat_id = chatId;
$profile.telegramChatId = chatId;
chatId = null;
snackbar.success('snackbar.course_settings.success.successful_integration');
}
@@ -43,7 +41,7 @@
snackbar.error('snackbar.course_settings.error.failed_deletion');
console.log(error);
} else {
$profile.telegram_chat_id = null;
$profile.telegramChatId = null;
snackbar.success('snackbar.course_settings.success.successful_deletion');
}
}
@@ -57,7 +55,7 @@
>
<Column sm={8} md={8} lg={8} class="mt-2 flex flex-col items-center lg:mt-0 lg:items-start">
{#if $profile.telegram_chat_id !== null}
{#if $profile.telegramChatId !== null}
<div class="flex w-full flex-col items-center">
<div class="flex items-center">
<CircleCheckIcon size={16} filled />
@@ -13,9 +13,7 @@
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { handleOpenWidget } from '$lib/components/CourseLandingPage/store';
import Row from './Layout/Row.svelte';
import Grid from './Layout/Grid.svelte';
import Column from './Layout/Column.svelte';
import { Row, Grid, Column } from './Layout';
import SectionTitle from '../SectionTitle.svelte';
import { IconButton } from '$lib/components/IconButton';
import TextArea from '$lib/components/Form/TextArea.svelte';
@@ -23,6 +21,7 @@
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
import type { OrgLandingPageJson } from '$lib/utils/types/org';
let isSaving = $state(false);
let creatingNewQuestion = $state(false);
@@ -154,7 +153,7 @@
// Set default from store
currentOrg.subscribe((cOrg) => {
if (cOrg.landingpage && Object.keys(cOrg.landingpage).length) {
const landingpage = { ...cOrg.landingpage };
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) {
@@ -2,21 +2,21 @@
import { Label } from '@cio/ui/base/label';
import * as Select from '@cio/ui/base/select';
import { LOCALE } from '$lib/utils/types';
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?: LOCALE;
value?: TLocale;
hasLangChanged?: boolean;
change?: () => void;
}
let { className = '', value = $bindable(LOCALE.EN), hasLangChanged = $bindable(false), change }: Props = $props();
let { className = '', value = $bindable('en'), hasLangChanged = $bindable(false), change }: Props = $props();
function handleSelect(selectedValue: string) {
value = selectedValue as LOCALE;
value = selectedValue as TLocale;
hasLangChanged = true;
change?.();
}
@@ -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';
@@ -13,9 +13,7 @@
import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
import { injectCustomTheme, setCustomTheme, setTheme } from '$lib/utils/functions/theme';
import Row from './Layout/Row.svelte';
import Grid from './Layout/Grid.svelte';
import Column from './Layout/Column.svelte';
import { Row, Grid, Column } from './Layout';
import SectionTitle from '../SectionTitle.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import UploadImage from '$lib/components/UploadImage/index.svelte';
@@ -119,8 +117,8 @@
if (data) {
const { data: response } = supabase.storage.from('avatars').getPublicUrl(filename);
updates.avatar_url = response.publicUrl;
$currentOrg.avatar_url = response.publicUrl;
updates.avatarUrl = response.publicUrl;
$currentOrg.avatarUrl = response.publicUrl;
}
avatar = undefined;
}
@@ -174,7 +172,7 @@
/>
<UploadImage
bind:avatar
src={$currentOrg.avatar_url}
src={$currentOrg.avatarUrl}
shape="rounded-md"
widthHeight="w-24 h-24"
change={() => (hasUnsavedChanges = true)}
@@ -1,94 +1,61 @@
<script lang="ts">
import { LOCALE } from '$lib/utils/types';
import type { TLocale } from '@cio/db/types';
import { profile } from '$lib/utils/store/user';
import { supabase } from '$lib/utils/functions/supabase';
import type { ProfileStore } from '$lib/utils/store/user';
import { snackbar } from '$lib/components/Snackbar/store';
import { CircleCheckBig } from '@lucide/svelte';
import { profileApi } from '$lib/features/auth/api/profile.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { updateProfileValidation } from '$lib/utils/functions/validator';
import { handleLocaleChange, t } from '$lib/utils/functions/translations';
import { t } from '$lib/utils/functions/translations';
import { Row, Grid, Column } from './Layout';
import Grid from './Layout/Grid.svelte';
import Row from './Layout/Row.svelte';
import Column from './Layout/Column.svelte';
import SectionTitle from '../SectionTitle.svelte';
import LanguagePicker from './LanguagePicker.svelte';
import generateUUID from '$lib/utils/functions/generateUUID';
import TextField from '$lib/components/Form/TextField.svelte';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
let avatar = $state<string | undefined>();
let loading = $state(false);
let avatar = $state<string | File | undefined>();
let hasLangChanged = $state(false);
let locale = $derived<LOCALE | undefined>($profile.locale);
let locale = $derived<TLocale | undefined>($profile.locale || undefined);
let hasUnsavedChanges = $state(false);
let errors = $state<Record<string, string>>({});
let email = $derived($profile.email || '');
let isChangingEmail = $state(false);
let emailChangeInitiated = $state(false);
async function handleUpdate() {
errors = updateProfileValidation($profile);
if (Object.values(errors).some((error) => error)) {
loading = false;
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;
}
try {
console.log({ hasLangChanged });
loading = true;
await profileApi.changeEmail({
newEmail: email,
callbackURL: window.location.href
});
const updates: Partial<ProfileStore> = {
fullname: $profile.fullname,
username: $profile.username,
email: $profile.email,
locale
};
console.log('updates', updates);
if (avatar) {
const filename = `user/${generateUUID()}.webp`;
const { data } = await supabase.storage.from('avatars').upload(filename, avatar, {
cacheControl: '3600',
upsert: false
});
if (data) {
const { data: response } = await supabase.storage.from('avatars').getPublicUrl(filename);
updates.avatar_url = response.publicUrl;
$profile.avatar_url = response.publicUrl;
}
avatar = '';
}
let { error } = await supabase.from('profile').update(updates).match({ id: $profile.id });
profile.update((_profile) => ({
..._profile,
...updates
}));
snackbar.success('snackbar.course_settings.success.update_successful');
if (hasLangChanged && locale) {
handleLocaleChange(locale);
}
hasUnsavedChanges = false;
if (error) throw error;
} catch (error) {
let message = error instanceof Error ? error.message : `${error}`;
if (message.includes('profile_username_key')) {
message = $t('snackbar.lms.error.username_exists');
}
snackbar.error(`${$t('snackbar.lms.error.update')}: ${message}`);
loading = false;
} finally {
loading = false;
if (profileApi.success) {
isChangingEmail = false;
emailChangeInitiated = true;
// Reset email field to current email after successful change request
email = $profile.email || '';
}
}
</script>
@@ -103,8 +70,9 @@
<Column sm={8} md={8} lg={8} class="mt-2 lg:mt-0">
<UploadImage
bind:avatar
src={$profile.avatar_url}
src={$profile.avatarUrl}
widthHeight="w-16 h-16 lg:w-24 lg:h-24"
isDisabled={profileApi.isLoading}
change={() => (hasUnsavedChanges = true)}
/>
</Column>
@@ -118,23 +86,64 @@
label={$t('settings.profile.personal_information.full_name')}
bind:value={$profile.fullname}
className="w-full lg:w-60 mb-4"
errorMessage={$t(errors.fullname)}
errorMessage={profileApi.errors.fullname ? $t(profileApi.errors.fullname) : undefined}
onChange={() => (hasUnsavedChanges = true)}
/>
<TextField
label={$t('settings.profile.personal_information.username')}
bind:value={$profile.username}
className="w-full lg:w-60 mb-4"
errorMessage={$t(errors.username)}
onChange={() => (hasUnsavedChanges = true)}
/>
<TextField
label={$t('settings.profile.personal_information.email')}
bind:value={$profile.email}
className="w-full lg:w-60 mb-4"
errorMessage={$t(errors.email)}
errorMessage={profileApi.errors.username ? $t(profileApi.errors.username) : undefined}
onChange={() => (hasUnsavedChanges = true)}
/>
<div class="mb-4 w-full lg:w-60">
<TextField
label={$t('settings.profile.personal_information.email')}
bind:value={email}
className="w-full"
type="email"
isDisabled={profileApi.isLoading}
errorMessage={profileApi.errors.newEmail ? $t(profileApi.errors.newEmail) : undefined}
onChange={() => {
if (email !== $profile.email) {
isChangingEmail = true;
emailChangeInitiated = false;
}
}}
/>
{#if isChangingEmail && email !== $profile.email}
<div class="mt-2 flex gap-2">
<PrimaryButton
label={$t('settings.profile.personal_information.confirm')}
variant={VARIANTS.CONTAINED_DARK}
className="text-sm"
isLoading={profileApi.isLoading}
isDisabled={profileApi.isLoading}
onClick={handleEmailChange}
/>
<PrimaryButton
label={$t('settings.profile.personal_information.cancel')}
variant={VARIANTS.NONE}
className="text-sm text-primary-700"
isDisabled={profileApi.isLoading}
onClick={() => {
email = $profile.email || '';
isChangingEmail = false;
emailChangeInitiated = false;
profileApi.errors.newEmail = '';
}}
/>
</div>
{/if}
{#if emailChangeInitiated}
<div
class="ml-2 mt-2 flex w-4/5 items-center gap-2 rounded-md border border-gray-200 p-2 text-sm text-amber-500"
>
<CircleCheckBig />
<p>{$t('settings.profile.personal_information.email_change_verification_note')}</p>
</div>
{/if}
</div>
<LanguagePicker
change={() => (hasUnsavedChanges = true)}
bind:hasLangChanged
@@ -149,8 +158,7 @@
label={$t('settings.profile.update_profile')}
variant={VARIANTS.CONTAINED_DARK}
className="mr-5 w-fit"
isLoading={loading}
isDisabled={loading}
isLoading={profileApi.isLoading}
onClick={handleUpdate}
/>
</div>
@@ -2,7 +2,7 @@
import { untrack } from 'svelte';
import { Label } from '@cio/ui/base/label';
import * as Select from '@cio/ui/base/select';
import { Moon } from 'svelte-loading-spinners';
import { Circle } from 'svelte-loading-spinners';
import { profile } from '$lib/utils/store/user';
import { isFreePlan } from '$lib/utils/store/org';
@@ -12,18 +12,17 @@
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, ROLE } from '$lib/utils/constants/roles';
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 from './Layout/Row.svelte';
import Grid from './Layout/Grid.svelte';
import Column from './Layout/Column.svelte';
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/components/ComingSoon/index.svelte';
import { ComingSoon } from '$lib/features/ui';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
let emailsStr = $state('');
@@ -190,7 +189,7 @@
>
<Column sm={8} md={8} lg={8}>
{#if isFetching}
<Moon />
<Circle />
{:else}
{#each $orgTeam as teamMember}
<div class="mb-5 flex items-center justify-between">
@@ -1,87 +0,0 @@
<script>
import Modal from '$lib/components/Modal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { snackbar } from '$lib/components/Snackbar/store';
import { t } from '$lib/utils/functions/translations';
import { NOTIFICATION_NAME, triggerSendEmail } from '$lib/utils/services/notification/notification';
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
const WAIT_SEC = 120;
const WAIT_TIME = WAIT_SEC * 1000;
let loading = $state(false);
let isSent = $state(false);
let autoTriggered = $state(false);
let interval;
let countDown = $state(WAIT_SEC);
const open = $derived(Boolean(!$profile.is_email_verified && !!$profile.id && !!$currentOrg.id));
const sendVerificationCode = async () => {
loading = true;
try {
triggerSendEmail(NOTIFICATION_NAME.VERIFY_EMAIL, {
to: $profile.email,
profileId: $profile.id,
fullname: $profile.fullname,
orgSiteName: $currentOrg.siteName
});
isSent = true;
} catch (error) {
snackbar.error('verify_email_modal.snackbar.error');
} finally {
loading = false;
}
interval = setInterval(() => {
countDown = countDown - 1;
}, 1000);
setTimeout(() => {
isSent = false;
clearInterval(interval);
}, WAIT_TIME);
};
$effect(() => {
if (!open || isSent || loading || autoTriggered) return;
// Only send verification code on load once
autoTriggered = true;
sendVerificationCode();
});
</script>
<Modal {open} isCloseable={false} width="w-4/5" maxWidth="w-[500px]" containerClass="p-4">
<div class="flex flex-col items-center space-y-6 text-center">
<img src="/verify-email.svg" alt="email verification" />
<p class="text-xl">{$t('verify_email_modal.heading')}</p>
<p class="w-[70%] text-sm text-gray-700 dark:text-gray-200">
{$t('verify_email_modal.sent_verification')}
{$profile.email}
{$t('verify_email_modal.to_confirm')}
</p>
<div class="flex flex-col items-center">
<PrimaryButton isDisabled={loading || isSent} className="font-normal" onClick={sendVerificationCode}>
{#if loading}
{$t('verify_email_modal.loading')}
{:else}
{$t('verify_email_modal.resend')}
{/if}
</PrimaryButton>
{#if isSent}
<p class="text-xs text-gray-700">
{$t('verify_email_modal.resend_in')}
{countDown}
{$t('verify_email_modal.seconds')}
</p>
{/if}
</div>
</div>
</Modal>
@@ -3,7 +3,3 @@ import { writable } from 'svelte/store';
export const newOrgModal = writable({
open: false
});
export const sideBar = writable({
hidden: true
});
@@ -1,236 +0,0 @@
<!--
@component
A Progress Bar that hooks to SvelteKit navigation.
The progress bar will show during navigation if loading takes more than `displayThresholdMs`
and will hide when navigation is complete.
https://github.com/prgm-dev/sveltekit-progress-bar
-->
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
interface Props {
// https://github.com/saibotsivad/svelte-progress-bar
id?: string | undefined;
viewTransitionName?: string;
busy?: boolean;
color?: string;
textColorClass?: `text-${string}` | '';
zIndex?: number;
minimum?: number;
maximum?: number;
settleTime?: number;
intervalTime?: number;
stepSizes?: any;
displayThresholdMs?: number;
noNavigationProgress?: boolean;
}
let {
id = undefined,
viewTransitionName = 'sveltekit-progress-bar',
busy = $bindable(false),
color = 'currentColor',
textColorClass = '',
zIndex = 1,
minimum = 0.08,
maximum = 0.994,
settleTime = 700,
intervalTime = 700,
stepSizes = [0, 0.005, 0.01, 0.02],
displayThresholdMs = 150,
noNavigationProgress = false
}: Props = $props();
// Towards the end of the progress bar animation, we want to shorten the increment
// step size, to give it the appearance of slowing down. This indicates to the user
// that progress is still happening, but not as fast as they might like.
const getIncrement = (n: number) => {
if (n >= 0 && n < 0.2) return 0.1;
else if (n >= 0.2 && n < 0.5) return 0.04;
else if (n >= 0.5 && n < 0.8) return 0.02;
else if (n >= 0.8 && n < 0.99) return 0.005;
return 0;
};
// Internal private state.
let running = $state(false);
let updater: ReturnType<typeof setInterval> | null = null;
let completed = $state(false);
let width = $state(0);
// Update the busy prop when the internal `running` variable changes.
// run(() => {
// busy = running;
// });
export const reset = (min = minimum) => {
width = min;
running = true;
};
/**
* Continue the animation of the progress bar from whatever position it is in, using
* a randomized step size to increment.
*/
export const animate = () => {
if (updater) {
// prevent multiple intervals by clearing before making
clearInterval(updater);
}
running = true;
updater = setInterval(() => {
const randomStep = stepSizes[Math.floor(Math.random() * stepSizes.length)] ?? 0;
const step = getIncrement(width) + randomStep;
if (width < maximum) {
width = width + step;
}
if (width > maximum) {
width = maximum;
stop();
}
}, intervalTime);
};
/** Restart the bar at the minimum, and begin the auto-increment progress. */
export const start = (min?: number) => {
reset(min);
animate();
};
/** Stop the progress bar from incrementing, but leave it visible. */
export const stop = () => {
if (updater) {
clearInterval(updater);
}
};
/**
* Moves the progress bar to the fully completed position, wait an appropriate
* amount of time so the user can feel the completion, then hide and reset.
*/
export const complete = (settle = settleTime) => {
if (updater) clearInterval(updater);
if (!running) return;
width = 1;
running = false;
setTimeout(() => {
// complete the bar first
completed = true;
setTimeout(() => {
// after some time (long enough to finish the hide animation) reset it back to 0
completed = false;
width = 0;
}, settle);
}, settle);
};
/** Stop the auto-increment functionality and manually set the width of the progress bar. */
export const setWidthRatio = (widthRatio: typeof width) => {
stop();
width = widthRatio;
completed = false;
running = true;
};
// Primarily used for tests, but can also be used for external monitoring.
export const getState = () => {
return {
width,
running,
completed,
color,
minimum,
maximum,
settleTime,
intervalTime,
stepSizes
};
};
let barStyle = $derived(
(color ? `background-color: ${color};` : '') +
(width && width * 100 ? `width: ${width * 100}%;` : '') +
`z-index: ${zIndex};` +
`view-transition-name: ${viewTransitionName}-bar;`
);
// the box shadow of the leader bar uses `color` to set its shadow color
let leaderColorStyle = $derived(
(color ? `background-color: ${color}; color: ${color};` : '') +
`z-index: ${zIndex + 1};` +
`view-transition-name: ${viewTransitionName}-leader;`
);
let progressBarStartTimeout: ReturnType<typeof setTimeout> | null = null;
beforeNavigate((nav) => {
if (progressBarStartTimeout) {
clearTimeout(progressBarStartTimeout);
progressBarStartTimeout = null;
}
if (noNavigationProgress) return;
if (nav.to?.route.id) {
// Internal navigation.
if (displayThresholdMs > 0) {
// Schedule a display of the progress bar in `displayThresholdMs` milliseconds.
// This is to avoid flickering/flashing when the navigation is fast.
progressBarStartTimeout = setTimeout(() => !noNavigationProgress && start(), displayThresholdMs);
} else start();
nav.complete.catch().finally(() => {
if (progressBarStartTimeout) {
clearTimeout(progressBarStartTimeout);
progressBarStartTimeout = null;
}
complete();
});
}
});
</script>
{#if running || width > 0}
<output
{id}
role="progressbar"
aria-valuenow={width}
aria-valuemin={0}
aria-valuemax={1}
class="svelte-progress-bar {textColorClass}"
class:running
class:svelte-progress-bar-hiding={completed}
style={barStyle}
>
{#if running}
<div class="svelte-progress-bar-leader" style={leaderColorStyle}></div>
{/if}
</output>
{/if}
<style>
.svelte-progress-bar {
position: fixed;
top: 0;
left: 0;
height: 3px;
transition: width 0.21s ease-in-out;
}
.svelte-progress-bar-hiding {
transition: top 0.8s ease;
top: -8px;
}
.svelte-progress-bar-leader {
position: absolute;
top: 0;
right: 0;
height: 5px;
width: 100px;
transform: rotate(2.5deg) translate(0px, -4px);
box-shadow: 0 0 8px;
}
</style>
@@ -1,60 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import * as Sidebar from '@cio/ui/base/sidebar';
import type { AppSidebar } from './types';
import { isFreePlan } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import NavMain from './nav-main.svelte';
import NavUser from './nav-user.svelte';
import TeamSwitcher from './team-switcher.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
let {
sidebarData,
canAddOrg = true,
ref = $bindable(null),
collapsible = 'icon',
...restProps
}: AppSidebar = $props();
const openModal = () => {
goto(window.location.pathname + '?upgrade=true');
};
</script>
<Sidebar.Root {collapsible} {...restProps} class="inset-y-12 h-[calc(100vh-48px)]">
{#if sidebarData.teams}
<Sidebar.Header>
<TeamSwitcher {canAddOrg} teams={sidebarData.teams} />
</Sidebar.Header>
{/if}
<Sidebar.Content>
<NavMain items={sidebarData.navMain} />
</Sidebar.Content>
{#if $isFreePlan}
<Sidebar.Content>
<div
class="border-primary-700 mx-4 mt-2 flex flex-col items-center justify-center gap-4 rounded-md border px-2 py-6 text-center transition-all ease-in-out hover:scale-95"
>
<img src="/upgrade.png" alt="upgrade" class="h-16 w-16" />
<span class="flex flex-col gap-1">
<p class="text-base font-semibold">{$t('org_navigation.early_adopter')}</p>
<p class="text-xs">{$t('org_navigation.unlock')}</p>
</span>
<PrimaryButton label={$t('org_navigation.upgrade')} onClick={openModal} className="font-normal" />
</div>
</Sidebar.Content>
{/if}
{#if sidebarData.user}
<Sidebar.Footer>
<NavUser user={sidebarData.user} />
</Sidebar.Footer>
{/if}
<Sidebar.Rail />
</Sidebar.Root>
@@ -1,52 +0,0 @@
<script lang="ts">
import { currentOrgPath } from '$lib/utils/store/org';
import * as Collapsible from '@cio/ui/base/collapsible';
import * as Sidebar from '@cio/ui/base/sidebar';
let {
items
}: {
items: {
title: string;
url: string;
// this should be `Component` after @lucide/svelte updates types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon?: any;
isActive?: boolean;
items?: {
title: string;
url: string;
}[];
}[];
} = $props();
</script>
<Sidebar.Group>
<Sidebar.GroupLabel>Platform</Sidebar.GroupLabel>
<Sidebar.Menu>
{#each items as item (item.title)}
<Collapsible.Root open={item.isActive} class="group/collapsible">
{#snippet child({ props })}
<Sidebar.MenuItem {...props}>
<Sidebar.MenuButton
{...props}
tooltipContent={item.title}
class={item.isActive ? 'bg-accent text-accent-foreground' : ''}
>
{#if item.icon}
<item.icon />
{/if}
<a href="{$currentOrgPath}{item.url}" class="w-full">{item.title}</a>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/snippet}
</Collapsible.Root>
{/each}
</Sidebar.Menu>
</Sidebar.Group>
<style>
a {
text-decoration: none;
}
</style>
@@ -1,82 +0,0 @@
<script lang="ts">
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import { useSidebar } from '@cio/ui/base/sidebar';
import * as Sidebar from '@cio/ui/base/sidebar';
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
import FolderIcon from '@lucide/svelte/icons/folder';
import ForwardIcon from '@lucide/svelte/icons/forward';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
let {
projects
}: {
projects: {
name: string;
url: string;
// This should be `Component` after @lucide/svelte updates types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon: any;
}[];
} = $props();
const sidebar = useSidebar();
</script>
<Sidebar.Group class="group-data-[collapsible=icon]:hidden">
<Sidebar.GroupLabel>Projects</Sidebar.GroupLabel>
<Sidebar.Menu>
{#each projects as item (item.name)}
<Sidebar.MenuItem>
<Sidebar.MenuButton>
{#snippet child({ props })}
<a href={item.url} {...props}>
<item.icon />
<span>{item.name}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Sidebar.MenuAction showOnHover {...props}>
<EllipsisIcon />
<span class="sr-only">More</span>
</Sidebar.MenuAction>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-48 rounded-lg"
side={sidebar.isMobile ? 'bottom' : 'right'}
align={sidebar.isMobile ? 'end' : 'start'}
>
<DropdownMenu.Item>
<FolderIcon />
<span>View Project</span>
</DropdownMenu.Item>
<DropdownMenu.Item>
<ForwardIcon />
<span>Share Project</span>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item>
<Trash2Icon />
<span>Delete Project</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
{/each}
<Sidebar.MenuItem>
<Sidebar.MenuButton class="text-sidebar-foreground/70">
<EllipsisIcon class="text-sidebar-foreground/70" />
<span>More</span>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Group>
<style>
a {
text-decoration: none;
}
</style>
@@ -1,99 +0,0 @@
<script lang="ts">
import * as Avatar from '@cio/ui/base/avatar';
import * as Sidebar from '@cio/ui/base/sidebar';
import { useSidebar } from '@cio/ui/base/sidebar';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import { globalStore } from '$lib/utils/store/app';
import { currentOrg, currentOrgDomain, currentOrgPath } from '$lib/utils/store/org';
import TextChip from '$lib/components/Chip/Text.svelte';
import Menu from '$lib/components/Org/FooterMenu/Menu.svelte';
let { user }: { user: { name: string; email: string; avatar: string } } = $props();
const sidebar = useSidebar();
</script>
<Sidebar.Menu>
<Sidebar.MenuItem>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
{...props}
>
<Avatar.Root class="size-8 rounded-lg">
<Avatar.Image src={user.avatar} alt={user.name} />
<Avatar.Fallback class="rounded-lg">CN</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{user.name}</span>
<span class="truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDownIcon class="ml-auto size-4" />
</Sidebar.MenuButton>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
side={sidebar.isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenu.Label class="p-0 font-normal">
<a
href={`${!$globalStore.isOrgSite ? $currentOrgPath : '/lms'}/settings`}
class="flex items-center gap-2 px-1 py-1.5 text-left text-sm"
>
<Avatar.Root class="size-8 rounded-lg">
<Avatar.Image src={user.avatar} alt={user.name} />
<Avatar.Fallback class="rounded-lg">CN</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{user.name}</span>
<span class="truncate text-xs">{user.email}</span>
</div>
</a>
</DropdownMenu.Label>
{#if !$globalStore.isOrgSite}
<DropdownMenu.Separator />
<DropdownMenu.Label class="p-0 font-normal">
<a
href={`${$currentOrgPath}/settings?tab=org`}
class="flex items-center gap-2 px-1 py-1.5 text-left text-sm"
>
<Avatar.Root class="size-8 rounded-lg">
{#if $currentOrg.avatar_url && $currentOrg.name}
<Avatar.Image src={$currentOrg.avatar_url} alt={$currentOrg.name} />
{:else if $currentOrg.shortName}
<TextChip size="sm" value={$currentOrg.shortName} className="bg-primary-200 dark:text-black" />
{/if}
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{$currentOrg.name}</span>
<span class="truncate text-xs">{$currentOrgDomain}</span>
</div>
</a>
</DropdownMenu.Label>
<DropdownMenu.Separator />
{/if}
<DropdownMenu.Group>
<Menu />
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
</Sidebar.Menu>
<style>
a {
text-decoration: none;
}
</style>
@@ -1,132 +0,0 @@
<script lang="ts">
import * as Avatar from '@cio/ui/base/avatar';
import * as Sidebar from '@cio/ui/base/sidebar';
import PlusIcon from '@lucide/svelte/icons/plus';
import { Skeleton } from '@cio/ui/base/skeleton';
import { useSidebar } from '@cio/ui/base/sidebar';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import { setTheme } from '$lib/utils/functions/theme';
import type { CurrentOrg } from '$lib/utils/types/org';
import { currentOrg, currentOrgPath, orgs } from '$lib/utils/store/org';
import TextChip from '$lib/components/Chip/Text.svelte';
import ComingSoon from '$lib/components/ComingSoon/index.svelte';
let {
teams,
canAddOrg = true
}: {
teams: CurrentOrg[];
canAddOrg?: boolean;
} = $props();
const sidebar = useSidebar();
let activeTeam = $state($currentOrg || null);
console.log('Active Team:', $currentOrg);
function getOrg(org: CurrentOrg) {
return $orgs.find((team) => team.name === org.name);
}
function onClick(org: any) {
activeTeam = org;
const organization = getOrg(org);
if (organization) {
localStorage.setItem('classroomio_org_sitename', organization.siteName);
currentOrg.set(organization);
setTheme(organization.theme);
window.location.href = $currentOrgPath;
}
}
</script>
<Sidebar.Menu>
<Sidebar.MenuItem>
{#if activeTeam}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton
{...props}
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
{#if $currentOrg.name}
<Avatar.Root class="flex size-8 items-center justify-center rounded-lg">
{#if $currentOrg.avatar_url && $currentOrg.name}
<Avatar.Image src={$currentOrg.avatar_url} alt={$currentOrg.name} />
{:else if $currentOrg.shortName}
<TextChip size="sm" value={$currentOrg.shortName} className="bg-primary-200 dark:text-black" />
{/if}
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">
{activeTeam.name}
</span>
<span class="truncate text-xs">{activeTeam.organization_plan[0]?.plan_name}</span>
</div>
<ChevronsUpDownIcon class="ml-auto" />
{:else}
<div class="h-[30px] w-[219px]">
<Skeleton class="h-full w-full" />
</div>
{/if}
</Sidebar.MenuButton>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-(--bits-dropdown-menu-anchor-width) min-w-56 rounded-lg"
align="start"
side={sidebar.isMobile ? 'bottom' : 'right'}
sideOffset={4}
>
<DropdownMenu.Label class="text-muted-foreground text-xs">Teams</DropdownMenu.Label>
{#each teams as team (team.name)}
{#if canAddOrg}
<DropdownMenu.Item onSelect={() => onClick(team)} class="gap-2 p-2">
<Avatar.Root class="flex size-8 items-center justify-center rounded-lg">
{#if $currentOrg.avatar_url && $currentOrg.name}
<Avatar.Image src={$currentOrg.avatar_url} alt={$currentOrg.name} />
{:else if $currentOrg.shortName}
<TextChip size="sm" value={$currentOrg.shortName} className="bg-primary-200 dark:text-black" />
{/if}
</Avatar.Root>
{team.name}
<!-- <DropdownMenu.Shortcut>⌘{index + 1}</DropdownMenu.Shortcut> -->
</DropdownMenu.Item>
{/if}
{/each}
<DropdownMenu.Separator />
<DropdownMenu.Item class="cursor-not-allowed gap-2 p-2 opacity-50">
<div class="flex size-6 items-center justify-center rounded-md border bg-transparent">
<PlusIcon class="size-4" />
</div>
<div class="text-muted-foreground font-medium">Add team</div>
<ComingSoon />
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else}
<Sidebar.MenuButton size="lg">
<div
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg"
>
<PlusIcon class="size-4" />
</div>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">No teams</span>
<span class="truncate text-xs">Loading...</span>
</div>
</Sidebar.MenuButton>
{/if}
</Sidebar.MenuItem>
</Sidebar.Menu>
@@ -1,8 +0,0 @@
import type { ComponentProps } from 'svelte';
import * as Sidebar from '@cio/ui/base/sidebar';
import type { SidebarData } from '$lib/utils/types/sidebar';
export interface AppSidebar extends ComponentProps<typeof Sidebar.Root> {
sidebarData: SidebarData;
canAddOrg?: boolean;
}
@@ -1,14 +0,0 @@
<script lang="ts">
import AppSidebar from './components/app-sidebar.svelte';
import * as Sidebar from '@cio/ui/base/sidebar';
let { data } = $props();
</script>
<Sidebar.Provider class="flex w-fit items-start gap-4">
<AppSidebar sidebarData={data} />
<Sidebar.Inset class="hidden md:block">
<Sidebar.Trigger class="-ml-1" />
</Sidebar.Inset>
</Sidebar.Provider>
@@ -31,7 +31,7 @@
let isConfirming = $derived((query.get('confirmation') || '') === 'true');
async function handleClick(plan, planName: string) {
if (plan.CTA.IS_DISABLED || !$profile.id) {
if (plan.CTA.IS_DISABLED || !$profile.id || !$profile.email) {
return;
}
@@ -51,7 +51,7 @@
checkoutURL.searchParams.set(
'metadata',
JSON.stringify({
triggeredBy: $currentOrg.memberId,
triggeredBy: $currentOrg.roleId,
orgId: $currentOrg.id,
orgSlug: $currentOrg.siteName
})
@@ -130,8 +130,8 @@
<Progress />
</div>
{:else}
<div class="flex flex-col items-center justify-center">
<div class="relative mb-2 flex items-center rounded-[30px] border-[2px] p-[2px] lg:scale-100">
<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"
@@ -145,14 +145,12 @@
onclick={toggleIsYearlyPlan}
>
{$t('pricing.modal.annually')}
<div
class="absolute -top-4 right-[-40%] scale-[90%] rounded-full bg-[#006600] px-1.5 py-1 text-[0.7rem] text-white"
>
<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 lg:grid-cols-3">
<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' &&
@@ -210,9 +208,9 @@
<li class="flex items-center">
<div>
<CheckIcon
size={16}
fill={planName === 'EARLY_ADOPTER' ? '#fff' : '#1D4EE2'}
class="mr-2 lg:mr-3"
class="custom mr-2 size-4 lg:mr-3 {planName === 'EARLY_ADOPTER'
? 'text-white'
: 'text-[#1D4EE2]'}"
/>
</div>
<p class="text-sm">
@@ -6,7 +6,7 @@
interface Props {
avatar: string | File | undefined;
src: string | undefined;
src: string | undefined | null;
widthHeight?: string;
shape?: string;
errorMessage?: string | null;
@@ -1,32 +0,0 @@
<script>
import Modal from '../Modal/index.svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { currentOrgPath } from '$lib/utils/store/org';
import { triggerSendEmail, NOTIFICATION_NAME } from '$lib/utils/services/notification/notification';
import { profile } from '$lib/utils/store/user';
let query = new URLSearchParams(page.url.search);
let welcomePopup = query.get('welcomePopup');
import { t } from '$lib/utils/functions/translations';
const closeModal = () => {
triggerSendEmail(NOTIFICATION_NAME.WELCOME_TO_APP, {
to: $profile.email,
name: $profile.fullname
});
goto($currentOrgPath + '/courses?create=true');
};
</script>
<Modal onClose={closeModal} open={welcomePopup === 'true'} width="w-9/12" maxWidth="w-[800px]" modalHeading="Welcome">
<p class="text-sm text-black md:text-base lg:text-lg dark:text-white">
{$t('welcome_modal.we_at')}
<a href="https://app.classroomio.com/" class="text-primary-700 no-underline hover:no-underline">ClassroomIO</a>
{$t('welcome_modal.small_team')}
<span class="text-primary-700">{$t('welcome_modal.thank_you')};</span>
{$t('welcome_modal.deeply_appreciate')}
</p>
<img src="/images/welcome-img.svg" alt="A welcome banner" class="my-6 w-full" />
</Modal>
@@ -1,5 +0,0 @@
import { writable } from 'svelte/store';
export const welcomeModalStore = writable({
open: false
});