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:
@@ -10,21 +10,6 @@ html {
|
||||
--scrollbarBG: #fff;
|
||||
--thumbBG: #ccc;
|
||||
scroll-behavior: smooth;
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
Segoe UI,
|
||||
Roboto,
|
||||
Helvetica Neue,
|
||||
Arial,
|
||||
Noto Sans,
|
||||
sans-serif,
|
||||
'Apple Color Emoji',
|
||||
'Segoe UI Emoji',
|
||||
Segoe UI Symbol,
|
||||
'Noto Color Emoji' !important;
|
||||
}
|
||||
|
||||
#calendar .grid {
|
||||
@@ -146,7 +131,6 @@ svg.lucide-icon:not(.custom) {
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
|
||||
10%,
|
||||
90% {
|
||||
transform: translate3d(-15px, 0, 0);
|
||||
@@ -828,7 +812,6 @@ svg.lucide-icon:not(.custom) {
|
||||
}
|
||||
|
||||
@keyframes shakeAnnouncement {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
@@ -936,7 +919,6 @@ svg.lucide-icon:not(.custom) {
|
||||
}
|
||||
|
||||
@keyframes gradeGlow {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
@@ -950,7 +932,6 @@ svg.lucide-icon:not(.custom) {
|
||||
}
|
||||
|
||||
@keyframes peopleWave {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: rotate(0deg) scale(1);
|
||||
@@ -997,7 +978,6 @@ svg.lucide-icon:not(.custom) {
|
||||
}
|
||||
|
||||
@keyframes screenGlow {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
@@ -1171,4 +1151,4 @@ svg.lucide-icon:not(.custom) {
|
||||
100% {
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
import type { TUser, TSession, TProfile, TOrganization, TOrganizationmember, TOrganizationPlan } from '@cio/db/types';
|
||||
|
||||
type AccountOrganization = TOrganization & {
|
||||
member: TOrganizationmember | null;
|
||||
plan:
|
||||
| (Pick<TOrganizationPlan, 'planName' | 'isActive' | 'provider' | 'subscriptionId'> & { customerId: string })
|
||||
| null;
|
||||
};
|
||||
|
||||
// src/app.d.ts
|
||||
declare global {
|
||||
namespace App {
|
||||
interface Locals {
|
||||
user: TUser | null;
|
||||
session: TSession | null;
|
||||
profile: TProfile | null;
|
||||
organizations: AccountOrganization[];
|
||||
fromSessions?: boolean;
|
||||
// getAccount: () =>
|
||||
}
|
||||
// interface PageData {}
|
||||
// interface Error {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -1,49 +1,52 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { validateUser } from '$lib/utils/services/middlewares';
|
||||
import { getSessionData } from '$lib/utils/services/auth/session';
|
||||
import { type Handle, redirect } from '@sveltejs/kit';
|
||||
import { isPublicApiRoute, isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute';
|
||||
import { ROUTE } from '$lib/utils/constants/routes';
|
||||
|
||||
const PUBLIC_API_ROUTES = [
|
||||
'/api/completion',
|
||||
'student_prove_payment',
|
||||
'teacher_student_buycourse',
|
||||
'/api/polar',
|
||||
'/api/lmz',
|
||||
'/api/verify'
|
||||
];
|
||||
export const handle: Handle = async (args) => {
|
||||
const { event } = args;
|
||||
const sessionData = await getSessionData(event.cookies);
|
||||
|
||||
function isPublicRoute(pathname: string) {
|
||||
return PUBLIC_API_ROUTES.some((route) => pathname.includes(route));
|
||||
}
|
||||
if (sessionData) {
|
||||
event.locals = sessionData;
|
||||
}
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const response = await resolve(event);
|
||||
// Handle API routes
|
||||
if (event.url.pathname.includes('/api')) {
|
||||
return handleAPIRoutes(args);
|
||||
}
|
||||
|
||||
// Handle other routes (ui pages, etc.)
|
||||
return handlePagesRoutes(args);
|
||||
};
|
||||
|
||||
const handlePagesRoutes: Handle = async ({ event, resolve }) => {
|
||||
const { pathname } = event.url;
|
||||
|
||||
// Only validate API routes
|
||||
if (!pathname.includes('/api')) {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Skip public routes
|
||||
if (isPublicRoute(pathname)) {
|
||||
return response;
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
const accessToken = event.request.headers.get('Authorization')!;
|
||||
if (!event.locals.user) {
|
||||
const shouldAddRedirectParam = !pathname.includes(ROUTE.LOGOUT);
|
||||
const redirectPath = shouldAddRedirectParam ? `${ROUTE.LOGIN}?redirect=${pathname}` : ROUTE.LOGIN;
|
||||
|
||||
try {
|
||||
const user = await validateUser(accessToken);
|
||||
|
||||
response.headers.set('user_id', `${user.id}`);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'Unauthenticated user') {
|
||||
return new Response(JSON.stringify({ code: 'unauthenticated', message: 'Unauthenticated user' }), {
|
||||
status: 401
|
||||
});
|
||||
}
|
||||
}
|
||||
return redirect(303, redirectPath);
|
||||
}
|
||||
|
||||
return response;
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
const handleAPIRoutes: Handle = async ({ event, resolve }) => {
|
||||
const { pathname } = event.url;
|
||||
|
||||
if (isPublicApiRoute(pathname)) {
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
if (!event.locals.user) {
|
||||
redirect(303, '/login');
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
@@ -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,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(() => {
|
||||
|
||||
+1
-1
@@ -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}
|
||||
|
||||
|
||||
+3
-3
@@ -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
-6
@@ -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">
|
||||
|
||||
@@ -3,7 +3,3 @@ import { writable } from 'svelte/store';
|
||||
export const newOrgModal = writable({
|
||||
open: false
|
||||
});
|
||||
|
||||
export const sideBar = writable({
|
||||
hidden: true
|
||||
});
|
||||
|
||||
@@ -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,5 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const welcomeModalStore = writable({
|
||||
open: false
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { BaseApi, classroomio } from '$lib/utils/services/api';
|
||||
import { currentOrg, isOrgStudent, orgs } from '$lib/utils/store/org';
|
||||
import { defaultProfileState, defaultUserState, profile, user } from '$lib/utils/store/user';
|
||||
|
||||
import type { AccountResponse } from './types';
|
||||
import type { TUser } from '@cio/db/types';
|
||||
import { get } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { handleLocaleChange } from '$lib/utils/functions/translations';
|
||||
import { identifyPosthogUser } from '$lib/utils/services/posthog';
|
||||
import { logout } from '$lib/utils/functions/logout';
|
||||
import { page } from '$app/state';
|
||||
import { resolve } from '$app/paths';
|
||||
import { setSentryUser } from '$lib/utils/services/sentry';
|
||||
import shouldRedirectOnAuth from '$lib/utils/functions/routes/shouldRedirectOnAuth';
|
||||
|
||||
type AppSetupParams = {
|
||||
isOrgSite: boolean;
|
||||
orgSiteName: string;
|
||||
};
|
||||
|
||||
/*
|
||||
Manages everything related to loading the logged in user and setting up the organization.
|
||||
*/
|
||||
class AppInitApi extends BaseApi {
|
||||
data = $state<AccountResponse>(null);
|
||||
session = $state<App.Locals | null>(null);
|
||||
|
||||
get loading() {
|
||||
return this.isLoading;
|
||||
}
|
||||
|
||||
async setupApp(locals: App.Locals, params: AppSetupParams): Promise<boolean | undefined> {
|
||||
if (!locals.user) {
|
||||
console.log('No user found in locals');
|
||||
return;
|
||||
}
|
||||
|
||||
this.session = locals;
|
||||
|
||||
await this.execute<typeof classroomio.account.$get>({
|
||||
requestFn: () => classroomio.account.$get(),
|
||||
logContext: 'fetching account',
|
||||
onSuccess: (accountData) => {
|
||||
this.data = accountData;
|
||||
this.setupStores();
|
||||
this.setUserAnalytics();
|
||||
this.routeUserToNextPage(params);
|
||||
},
|
||||
onError: () => {
|
||||
logout();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
1. Update user store
|
||||
2. Update profile store
|
||||
3. Update organizations store
|
||||
*/
|
||||
setupStores() {
|
||||
if (!this.data?.success || !this.session) {
|
||||
return;
|
||||
}
|
||||
|
||||
user.update((_user) => ({
|
||||
..._user,
|
||||
fetchingUser: false,
|
||||
isLoggedIn: true,
|
||||
currentSession: (this.session?.user as unknown as TUser) || undefined
|
||||
}));
|
||||
|
||||
profile.set(this.data.profile);
|
||||
handleLocaleChange(this.data.profile.locale ?? 'en');
|
||||
|
||||
this.setOrgStore();
|
||||
}
|
||||
|
||||
setOrgStore() {
|
||||
if (!this.data?.success || !this.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.data.organizations.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
orgs.set(this.data.organizations);
|
||||
|
||||
const lastOrgSiteName = localStorage.getItem('classroomio_org_sitename');
|
||||
const lastOrg = this.data.organizations.find((org) => org.siteName === lastOrgSiteName);
|
||||
|
||||
currentOrg.set(lastOrg || this.data.organizations[0]);
|
||||
}
|
||||
|
||||
routeUserToNextPage({ isOrgSite }: AppSetupParams) {
|
||||
if (!this.data?.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userHasOrganizations = this.data.organizations.length > 0;
|
||||
const shouldRedirectToOnboarding = !isOrgSite && !userHasOrganizations;
|
||||
if (shouldRedirectToOnboarding) {
|
||||
return goto(resolve(`/onboarding`, {}));
|
||||
}
|
||||
|
||||
const redirect = page.url.searchParams.get('redirect');
|
||||
if (redirect) {
|
||||
// goto redirect won't accept dynamic url so we need to use window.location.href
|
||||
window.location.href = redirect;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldRedirectOnAuth(page.url.pathname)) return;
|
||||
|
||||
const isStudent = get(isOrgStudent);
|
||||
|
||||
return isOrgSite || isStudent ? this.goToLMS() : this.goToOrg();
|
||||
}
|
||||
|
||||
goToLMS() {
|
||||
goto(resolve('/lms', {}));
|
||||
}
|
||||
|
||||
goToOrg() {
|
||||
const selectedOrg = get(currentOrg);
|
||||
|
||||
goto(resolve(`/org/${selectedOrg.siteName}`, {}));
|
||||
}
|
||||
|
||||
setUserAnalytics() {
|
||||
const profileStore = get(profile);
|
||||
|
||||
if (!profileStore?.id) return;
|
||||
|
||||
setSentryUser({
|
||||
id: profileStore.id,
|
||||
username: profileStore.username,
|
||||
email: profileStore.email,
|
||||
fullname: profileStore.fullname
|
||||
});
|
||||
|
||||
identifyPosthogUser(profileStore.id, {
|
||||
email: profileStore.email,
|
||||
name: profileStore.fullname
|
||||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
super.reset();
|
||||
this.data = null;
|
||||
|
||||
user.set(defaultUserState);
|
||||
profile.set(defaultProfileState);
|
||||
}
|
||||
|
||||
get isInitializedAndReady() {
|
||||
return !this.isLoading && !this.error && this.data !== null;
|
||||
}
|
||||
}
|
||||
|
||||
export const appInitApi = new AppInitApi();
|
||||
@@ -0,0 +1,120 @@
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import type { CurrentOrg } from '$lib/utils/types/org';
|
||||
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;
|
||||
subdomain: string;
|
||||
orgSiteName: string;
|
||||
}
|
||||
|
||||
export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise<OrgSiteInfo> {
|
||||
const response: OrgSiteInfo = {
|
||||
orgSiteName: '',
|
||||
subdomain: '',
|
||||
isOrgSite: false,
|
||||
org: null
|
||||
};
|
||||
|
||||
// Selfhosted usecase
|
||||
if (PUBLIC_IS_SELFHOSTED === 'true') {
|
||||
const subdomain = getSubdomain(url);
|
||||
|
||||
// Student dashboard
|
||||
if (subdomain) {
|
||||
const org = (await getCurrentOrg(subdomain, true)) || null;
|
||||
|
||||
// Organization by subdomain not found
|
||||
if (!org) {
|
||||
return response;
|
||||
}
|
||||
|
||||
response.org = org;
|
||||
response.isOrgSite = true;
|
||||
response.orgSiteName = subdomain;
|
||||
response.subdomain = subdomain;
|
||||
}
|
||||
|
||||
// Never go beyond this for selfhosted instances
|
||||
return response;
|
||||
}
|
||||
|
||||
const isLocalHost = url.host.includes('localhost');
|
||||
const tempSiteName = url.searchParams.get('org');
|
||||
|
||||
if (isLocalHost && tempSiteName) {
|
||||
cookies.set('_orgSiteName', tempSiteName, {
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
|
||||
const _orgSiteName = cookies.get('_orgSiteName');
|
||||
const debugMode = _orgSiteName && _orgSiteName !== 'false';
|
||||
|
||||
const subdomain = getSubdomain(url) || '';
|
||||
|
||||
// Custom domain
|
||||
if (isURLCustomDomain(url)) {
|
||||
response.org = (await getCurrentOrg(url.host, true, true)) || null;
|
||||
|
||||
if (!response.org) {
|
||||
return response;
|
||||
}
|
||||
|
||||
response.isOrgSite = true;
|
||||
response.orgSiteName = response.org?.siteName || '';
|
||||
response.subdomain = subdomain;
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
// Subdomain except blocked ones.
|
||||
if (!blockedSubdomain.includes(subdomain)) {
|
||||
const APP_SUBDOMAINS = env.PRIVATE_APP_SUBDOMAINS?.split(',') || [];
|
||||
|
||||
if (APP_SUBDOMAINS.includes(subdomain)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
response.isOrgSite = debugMode || !!subdomain;
|
||||
response.orgSiteName = debugMode ? _orgSiteName : subdomain;
|
||||
response.org = (await getCurrentOrg(response.orgSiteName, true)) || null;
|
||||
|
||||
const shouldDeleteCookie = !response.org && _orgSiteName;
|
||||
if (shouldDeleteCookie) {
|
||||
cookies.delete('_orgSiteName', { path: '/' });
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function isURLCustomDomain(url: URL) {
|
||||
if (url.host.includes('localhost')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const notCustomDomainHosts = [env.PRIVATE_APP_HOST || '', 'classroomio.com', 'vercel.app'].filter(Boolean);
|
||||
|
||||
return !notCustomDomainHosts.some((host) => url.host.endsWith(host));
|
||||
}
|
||||
|
||||
export function getSubdomain(url: URL) {
|
||||
const host = url.hostname.replace('www.', '');
|
||||
const parts = host.split('.');
|
||||
const appHost = env.PRIVATE_APP_HOST;
|
||||
|
||||
const appHostParts = appHost.split('.');
|
||||
const isAppHost = parts.slice(-appHostParts.length).join('.') === appHost;
|
||||
|
||||
if (isAppHost) {
|
||||
// Subdomain exists only if extra part(s) before main domain
|
||||
return parts.length > appHostParts.length ? parts[0] : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
export type AccountResponse = InferResponseType<typeof classroomio.account.$get> | null;
|
||||
|
||||
export type AccountSuccess = Extract<InferResponseType<typeof classroomio.account.$get>, { success: true }>;
|
||||
|
||||
export type AccountOrg = AccountSuccess['organizations'][number];
|
||||
@@ -0,0 +1,37 @@
|
||||
import { BaseApiWithErrors } from '$lib/utils/services/api';
|
||||
import type { TForgotPasswordForm } from '../utils/types';
|
||||
import { authClient } from '$lib/utils/services/auth/client';
|
||||
import { forgotPasswordValidation } from '../utils/validation';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
|
||||
export class ForgotApi extends BaseApiWithErrors {
|
||||
async submit(fields: TForgotPasswordForm) {
|
||||
const errors = forgotPasswordValidation(fields);
|
||||
if (errors) {
|
||||
this.errors = errors;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.isLoading = true;
|
||||
this.errors = {};
|
||||
this.success = false;
|
||||
|
||||
const { error } = await authClient.requestPasswordReset({
|
||||
email: fields.email,
|
||||
redirectTo: window.location.origin + '/reset'
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
this.success = true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
snackbar.error('snackbar.something');
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const forgotApi = new ForgotApi();
|
||||
@@ -0,0 +1,207 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type { TLocale, TProfile } from '@cio/db/types';
|
||||
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';
|
||||
|
||||
export interface TProfileUpdateForm extends Omit<TProfileUpdateFormSchema, 'avatarUrl'> {
|
||||
avatar?: string | File | undefined;
|
||||
}
|
||||
|
||||
export interface TChangeEmailForm {
|
||||
newEmail: string;
|
||||
callbackURL?: string;
|
||||
}
|
||||
|
||||
export class ProfileApi extends BaseApiWithErrors {
|
||||
private validateForm(fields: TProfileUpdateForm): boolean {
|
||||
const validationData = {
|
||||
fullname: fields.fullname,
|
||||
username: fields.username,
|
||||
locale: fields.locale
|
||||
};
|
||||
|
||||
const result = ZProfileUpdateForm.safeParse(validationData);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error);
|
||||
return false;
|
||||
}
|
||||
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,
|
||||
avatarUrl?: string
|
||||
): Promise<void> {
|
||||
const userUpdates: { name?: string; image?: string } = {};
|
||||
|
||||
if (fields.fullname !== currentProfile.fullname) {
|
||||
userUpdates.name = fields.fullname;
|
||||
}
|
||||
if (avatarUrl) {
|
||||
userUpdates.image = avatarUrl;
|
||||
}
|
||||
|
||||
if (Object.keys(userUpdates).length > 0) {
|
||||
const { error: userError } = await authClient.updateUser(userUpdates);
|
||||
if (userError) throw userError;
|
||||
}
|
||||
}
|
||||
|
||||
private buildProfileUpdates(
|
||||
fields: TProfileUpdateForm,
|
||||
currentProfile: TProfile,
|
||||
locale: TLocale | undefined,
|
||||
avatarUrl?: string
|
||||
): Partial<TProfile> {
|
||||
const profileUpdates: Partial<TProfile> = {};
|
||||
|
||||
if (fields.fullname !== currentProfile.fullname) {
|
||||
profileUpdates.fullname = fields.fullname;
|
||||
}
|
||||
|
||||
if (fields.username !== currentProfile.username) {
|
||||
profileUpdates.username = fields.username;
|
||||
}
|
||||
|
||||
if (locale && locale !== currentProfile.locale) {
|
||||
profileUpdates.locale = locale;
|
||||
}
|
||||
|
||||
if (avatarUrl) {
|
||||
profileUpdates.avatarUrl = avatarUrl;
|
||||
}
|
||||
|
||||
return profileUpdates;
|
||||
}
|
||||
|
||||
private async updateProfile(profileUpdates: Partial<TProfile>): Promise<void> {
|
||||
if (Object.keys(profileUpdates).length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<typeof classroomio.account.user.$put>({
|
||||
requestFn: () => classroomio.account.user.$put({ json: profileUpdates }),
|
||||
logContext: 'updating profile',
|
||||
onSuccess: (response) => {
|
||||
profileStore.update((_profile) => ({
|
||||
..._profile,
|
||||
...response.profile
|
||||
}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handleError(error: unknown): void {
|
||||
console.error(error);
|
||||
const message = error instanceof Error ? error.message : `${error}`;
|
||||
|
||||
if (message.includes('profile_username_key')) {
|
||||
this.errors.username = 'snackbar.lms.error.username_exists';
|
||||
snackbar.error('snackbar.lms.error.username_exists');
|
||||
} else {
|
||||
this.errors.general = message;
|
||||
snackbar.error(`snackbar.lms.error.update: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async submit(fields: TProfileUpdateForm, currentProfile: TProfile, hasLangChanged: boolean, locale?: TLocale) {
|
||||
if (!this.validateForm(fields)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.isLoading = true;
|
||||
this.errors = {};
|
||||
this.success = false;
|
||||
|
||||
// Handle avatar upload if provided
|
||||
let avatarUrl: string | undefined;
|
||||
if (fields.avatar instanceof File) {
|
||||
avatarUrl = await this.uploadAvatar(fields.avatar);
|
||||
}
|
||||
|
||||
// Update user info via Better Auth (name and image)
|
||||
await this.updateUserInfo(fields, currentProfile, avatarUrl);
|
||||
|
||||
// Update profile-specific fields via API route
|
||||
const profileUpdates = this.buildProfileUpdates(fields, currentProfile, locale, avatarUrl);
|
||||
await this.updateProfile(profileUpdates);
|
||||
|
||||
// Handle locale change if needed
|
||||
if (hasLangChanged && locale) {
|
||||
handleLocaleChange(locale);
|
||||
}
|
||||
|
||||
snackbar.success('snackbar.course_settings.success.update_successful');
|
||||
this.success = true;
|
||||
} catch (error) {
|
||||
this.handleError(error);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async changeEmail(fields: TChangeEmailForm) {
|
||||
// Validate email format
|
||||
const result = ZChangeEmail.safeParse({ newEmail: fields.newEmail });
|
||||
console.log('result', result);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.isLoading = true;
|
||||
this.errors = {};
|
||||
this.success = false;
|
||||
|
||||
const { error } = await authClient.changeEmail({
|
||||
newEmail: fields.newEmail,
|
||||
callbackURL: window.location.origin
|
||||
});
|
||||
|
||||
if (error) throw new Error(error.message);
|
||||
|
||||
snackbar.success();
|
||||
this.success = true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
const message = error instanceof Error ? error.message : `${error}`;
|
||||
|
||||
this.errors.email = message;
|
||||
snackbar.error(`${t.get('snackbar.lms.error.update')} ${message}`);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const profileApi = new ProfileApi();
|
||||
@@ -0,0 +1,47 @@
|
||||
import { BaseApiWithErrors } from '$lib/utils/services/api';
|
||||
import type { TResetPasswordForm } from '../utils/types';
|
||||
import { authClient } from '$lib/utils/services/auth/client';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resetPasswordValidation } from '../utils/validation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
|
||||
export class ResetApi extends BaseApiWithErrors {
|
||||
async submit(fields: TResetPasswordForm) {
|
||||
const errors = resetPasswordValidation(fields);
|
||||
if (errors) {
|
||||
this.errors = errors;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.isLoading = true;
|
||||
this.errors = {};
|
||||
this.success = false;
|
||||
|
||||
const { error } = await authClient.resetPassword({
|
||||
newPassword: fields.password, // required
|
||||
token: fields.token // required
|
||||
});
|
||||
|
||||
if (error) throw new Error(error.message);
|
||||
|
||||
this.success = true;
|
||||
snackbar.success();
|
||||
|
||||
goto(resolve('/login', {}));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
snackbar.error(message);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
setError(_errors: Record<string, string>) {
|
||||
this.errors = _errors;
|
||||
}
|
||||
}
|
||||
|
||||
export const resetApi = new ResetApi();
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as z from 'zod';
|
||||
|
||||
export const ZForgotPasswordForm = z.object({
|
||||
email: z.email()
|
||||
});
|
||||
export type TForgotPasswordForm = z.infer<typeof ZForgotPasswordForm>;
|
||||
|
||||
export const ZResetPasswordForm = z.object({
|
||||
password: z.string().min(8),
|
||||
confirmPassword: z.string().min(8),
|
||||
token: z.string()
|
||||
});
|
||||
export type TResetPasswordForm = z.infer<typeof ZResetPasswordForm>;
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { TForgotPasswordForm, TResetPasswordForm } from './types';
|
||||
|
||||
import { ForgotPasswordSchema } from '@cio/utils/validation/auth/forgot';
|
||||
import { ResetPasswordSchema } from '@cio/utils/validation/auth/reset';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
|
||||
export const forgotPasswordValidation = (field: TForgotPasswordForm) => {
|
||||
const result = ForgotPasswordSchema.safeParse(field);
|
||||
if (!result.success) {
|
||||
return mapZodErrorsToTranslations(result.error);
|
||||
}
|
||||
};
|
||||
|
||||
export const resetPasswordValidation = (field: TResetPasswordForm) => {
|
||||
const result = ResetPasswordSchema.safeParse(field);
|
||||
if (!result.success) {
|
||||
return mapZodErrorsToTranslations(result.error);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { OnboardingField, OnboardingStep } from '../utils/types';
|
||||
import { currentOrg, orgs } from '$lib/utils/store/org';
|
||||
|
||||
import { ONBOARDING_STEPS } from '../utils/constants';
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import { goto } from '$app/navigation';
|
||||
import { handleLocaleChange } from '$lib/utils/functions/translations';
|
||||
import { onboardingValidation } from '../utils/validations';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { resolve } from '$app/paths';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
|
||||
export class OnboardingApi extends BaseApiWithErrors {
|
||||
step: OnboardingStep = $state(ONBOARDING_STEPS.ORG_SETUP);
|
||||
|
||||
async submit(data: OnboardingField) {
|
||||
if (this.step === ONBOARDING_STEPS.ORG_SETUP) {
|
||||
return this.submitOrgSetup(data);
|
||||
}
|
||||
|
||||
if (this.step === ONBOARDING_STEPS.USER_METADATA) {
|
||||
return this.submitUserMetada(data);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async submitOrgSetup(data: OnboardingField) {
|
||||
const errors = onboardingValidation(data, ONBOARDING_STEPS.ORG_SETUP);
|
||||
if (errors) {
|
||||
this.errors = errors;
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.execute<typeof classroomio.onboarding.step1.$post>({
|
||||
requestFn: () => classroomio.onboarding.step1.$post({ json: data }),
|
||||
logContext: 'submitting organization setup',
|
||||
onSuccess: (result) => {
|
||||
const { organizations } = result.data;
|
||||
|
||||
orgs.set(organizations);
|
||||
currentOrg.set(organizations[0]);
|
||||
|
||||
this.errors = {};
|
||||
this.step = ONBOARDING_STEPS.USER_METADATA;
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if ('message' in result) {
|
||||
snackbar.error(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// map api validation back to frontend fields
|
||||
if ('error' in result) {
|
||||
this.handleValidationError(result);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async submitUserMetada(data: OnboardingField) {
|
||||
const errors = onboardingValidation(data, ONBOARDING_STEPS.USER_METADATA);
|
||||
if (errors) {
|
||||
this.errors = errors;
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.execute<typeof classroomio.onboarding.step2.$post>({
|
||||
requestFn: () => classroomio.onboarding.step2.$post({ json: data }),
|
||||
logContext: 'submitting organization setup',
|
||||
onSuccess: (result) => {
|
||||
profile.set(result.data);
|
||||
handleLocaleChange(result.data.locale ?? 'en');
|
||||
|
||||
const welcomePopup = `${result.data.isEmailVerified}`;
|
||||
|
||||
return goto(resolve(`/org/${data.siteName}?welcomePopup=${welcomePopup}`, {}));
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error(result);
|
||||
return;
|
||||
}
|
||||
|
||||
if ('message' in result) {
|
||||
snackbar.error(result.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// map api validation back to frontend fields
|
||||
if ('error' in result) {
|
||||
this.handleValidationError(result);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override reset() {
|
||||
super.reset();
|
||||
}
|
||||
}
|
||||
|
||||
export const onboardingApi = new OnboardingApi();
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as WelcomeModal } from './welcome-modal.svelte';
|
||||
export { default as VerifyEmailModal } from './verify-email-modal.svelte';
|
||||
+24
-9
@@ -1,13 +1,15 @@
|
||||
<script>
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import { Modal } from '$lib/components/Modal';
|
||||
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';
|
||||
import { authClient } from '$lib/utils/services/auth/client';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { page } from '$app/state';
|
||||
|
||||
const WAIT_SEC = 120;
|
||||
const WAIT_SEC = 120; // 2 minutes
|
||||
const WAIT_TIME = WAIT_SEC * 1000;
|
||||
|
||||
let loading = $state(false);
|
||||
@@ -17,17 +19,30 @@
|
||||
let interval;
|
||||
let countDown = $state(WAIT_SEC);
|
||||
|
||||
const open = $derived(Boolean(!$profile.is_email_verified && !!$profile.id && !!$currentOrg.id));
|
||||
const open = $derived(Boolean(!$profile.isEmailVerified && !!$profile.id && !!$currentOrg.id));
|
||||
|
||||
const sendVerificationCode = async () => {
|
||||
if (!$profile.email) {
|
||||
snackbar.error('verify_email_modal.snackbar.error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (loading || isSent) return;
|
||||
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
triggerSendEmail(NOTIFICATION_NAME.VERIFY_EMAIL, {
|
||||
to: $profile.email,
|
||||
profileId: $profile.id,
|
||||
fullname: $profile.fullname,
|
||||
orgSiteName: $currentOrg.siteName
|
||||
const callbackURL = new URL(page.url.href);
|
||||
callbackURL.searchParams.set('trigger', 'app');
|
||||
|
||||
// Avoid showing welcome popup in lms
|
||||
if (!$globalStore.isOrgSite) {
|
||||
callbackURL.searchParams.set('welcomePopup', 'true');
|
||||
}
|
||||
|
||||
await authClient.sendVerificationEmail({
|
||||
email: $profile.email,
|
||||
callbackURL: callbackURL.toString()
|
||||
});
|
||||
|
||||
isSent = true;
|
||||
+17
-10
@@ -1,26 +1,33 @@
|
||||
<script>
|
||||
import Modal from '../Modal/index.svelte';
|
||||
import { Modal } from '$lib/components/Modal';
|
||||
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';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
|
||||
let query = new URLSearchParams(page.url.search);
|
||||
let welcomePopup = query.get('welcomePopup');
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
let open = $derived(welcomePopup === 'true' && !!$profile.isEmailVerified);
|
||||
|
||||
const closeModal = () => {
|
||||
triggerSendEmail(NOTIFICATION_NAME.WELCOME_TO_APP, {
|
||||
to: $profile.email,
|
||||
name: $profile.fullname
|
||||
});
|
||||
let isLoading = $state(false);
|
||||
|
||||
goto($currentOrgPath + '/courses?create=true');
|
||||
const closeModal = async () => {
|
||||
if (isLoading) return;
|
||||
|
||||
try {
|
||||
isLoading = true;
|
||||
await classroomio.onboarding.complete.$post({});
|
||||
|
||||
goto($currentOrgPath + '/courses?create=true');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal onClose={closeModal} open={welcomePopup === 'true'} width="w-9/12" maxWidth="w-[800px]" modalHeading="Welcome">
|
||||
<Modal onClose={closeModal} isClosing={isLoading} {open} 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>
|
||||
@@ -0,0 +1,59 @@
|
||||
export const ONBOARDING_STEPS = {
|
||||
ORG_SETUP: 1,
|
||||
USER_METADATA: 2
|
||||
} as const;
|
||||
|
||||
export const DROPDOWN_ITEMS = [
|
||||
{ id: 'de', text: 'German' },
|
||||
{ id: 'en', text: 'English' },
|
||||
{ id: 'es', text: 'Spanish' },
|
||||
{ id: 'fr', text: 'French' },
|
||||
{ id: 'hi', text: 'Hindi' },
|
||||
{ id: 'pl', text: 'Polish' },
|
||||
{ id: 'pt', text: 'Portuguese' },
|
||||
{ id: 'ru', text: 'Russian' },
|
||||
{ id: 'vi', text: 'Vietnamese' },
|
||||
{ id: 'da', text: 'Danish' }
|
||||
];
|
||||
|
||||
export const GOALS = [
|
||||
{
|
||||
label: 'onboarding.sell',
|
||||
value: 'sell-online'
|
||||
},
|
||||
{
|
||||
label: 'onboarding.teach',
|
||||
value: 'teach-online'
|
||||
},
|
||||
{
|
||||
label: 'onboarding.employees',
|
||||
value: 'employees'
|
||||
},
|
||||
{
|
||||
label: 'onboarding.customers',
|
||||
value: 'customers'
|
||||
},
|
||||
{
|
||||
label: 'onboarding.expanding',
|
||||
value: 'expanding-platform'
|
||||
}
|
||||
];
|
||||
|
||||
export const SOURCES = [
|
||||
{
|
||||
label: 'onboarding.articles',
|
||||
value: 'articles'
|
||||
},
|
||||
{
|
||||
label: 'onboarding.search',
|
||||
value: 'search-engine'
|
||||
},
|
||||
{
|
||||
label: 'onboarding.social',
|
||||
value: 'social-media'
|
||||
},
|
||||
{
|
||||
label: 'onboarding.friends',
|
||||
value: 'friends-family'
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ONBOARDING_STEPS } from './constants';
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
|
||||
export type OnboardingStep = (typeof ONBOARDING_STEPS)[keyof typeof ONBOARDING_STEPS];
|
||||
|
||||
export interface OnboardingField {
|
||||
fullname?: string;
|
||||
orgName?: string;
|
||||
siteName?: string;
|
||||
goal?: string;
|
||||
source?: string;
|
||||
locale?: TLocale;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface Goal {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { OnboardingField, OnboardingStep } from './types';
|
||||
import { ZOnboardingStep1, ZOnboardingStep2 } from '@cio/utils/validation/onboarding';
|
||||
|
||||
import { ONBOARDING_STEPS } from './constants';
|
||||
import { blockedSubdomain } from '@cio/utils/constants';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
|
||||
export const onboardingValidation = (field: OnboardingField, step: OnboardingStep) => {
|
||||
if (step === ONBOARDING_STEPS.ORG_SETUP) {
|
||||
if (blockedSubdomain.includes(field.siteName || '')) {
|
||||
return { siteName: 'Sitename already exists.' };
|
||||
}
|
||||
|
||||
const result = ZOnboardingStep1.safeParse(field);
|
||||
if (!result.success) {
|
||||
return mapZodErrorsToTranslations(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
if (step === ONBOARDING_STEPS.USER_METADATA) {
|
||||
const result = ZOnboardingStep2.safeParse(field);
|
||||
if (!result.success) {
|
||||
return mapZodErrorsToTranslations(result.error);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './org.svelte';
|
||||
export * from './org-plan.svelte';
|
||||
@@ -0,0 +1,46 @@
|
||||
import { BaseApi, classroomio } from '$lib/utils/services/api';
|
||||
|
||||
import type { TNewOrganizationPlan } from '@cio/db/types';
|
||||
|
||||
/**
|
||||
* API class for organization plan operations
|
||||
*/
|
||||
class OrgPlanApi extends BaseApi {
|
||||
/**
|
||||
* Creates a new organization plan
|
||||
* @param params Organization plan creation parameters
|
||||
* @returns Created organization plan
|
||||
*/
|
||||
async createOrgPlan(params: TNewOrganizationPlan) {
|
||||
return this.execute({
|
||||
requestFn: () => classroomio.organization.plan.$post({ json: params }),
|
||||
logContext: 'creating organization plan'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an organization plan
|
||||
* @param params Update parameters (subscriptionId, payload)
|
||||
* @returns Updated organization plan
|
||||
*/
|
||||
async updateOrgPlan(params: { subscriptionId: string; payload: Record<string, unknown> }) {
|
||||
return this.execute({
|
||||
requestFn: () => classroomio.organization.plan.$put({ json: params }),
|
||||
logContext: 'updating organization plan'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels an organization plan
|
||||
* @param params Cancel parameters (subscriptionId, payload)
|
||||
* @returns Updated organization plan
|
||||
*/
|
||||
async cancelOrgPlan(params: { subscriptionId: string; payload: Record<string, unknown> }) {
|
||||
return this.execute({
|
||||
requestFn: () => classroomio.organization.plan.cancel.$post({ json: params }),
|
||||
logContext: 'canceling organization plan'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const orgPlanApi = new OrgPlanApi();
|
||||
@@ -0,0 +1,108 @@
|
||||
import { BaseApi, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
CoursesByOrganizationSiteName,
|
||||
GetAudienceResponse,
|
||||
GetCoursesBySiteNameResponse,
|
||||
OrganizationAudience,
|
||||
OrganizationTeamMembers
|
||||
} from '../utils/types';
|
||||
|
||||
import type { GetTeamResponse } from '../utils/types';
|
||||
import type { TGetOrganizations } from '@cio/utils/validation/organization';
|
||||
import { orgs } from '$lib/utils/store/org';
|
||||
|
||||
/**
|
||||
* API class for organization operations
|
||||
*/
|
||||
class OrgApi extends BaseApi {
|
||||
teamMembers = $state<OrganizationTeamMembers>([]);
|
||||
audience = $state<OrganizationAudience>([]);
|
||||
orgSiteCourses = $state<CoursesByOrganizationSiteName>([]);
|
||||
|
||||
/**
|
||||
* Gets organization team members (non-students)
|
||||
* @param orgId Organization ID
|
||||
* @returns Team members array
|
||||
*/
|
||||
async getOrgTeam(orgId: string) {
|
||||
return this.execute<GetTeamResponse>({
|
||||
requestFn: () =>
|
||||
classroomio.organization[':orgId'].team.$get({
|
||||
param: { orgId }
|
||||
}),
|
||||
logContext: 'fetching organization team',
|
||||
onSuccess: (response) => {
|
||||
this.teamMembers = response.data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets organization audience (students)
|
||||
* @param orgId Organization ID
|
||||
* @returns Audience array
|
||||
*/
|
||||
async getOrgAudience(orgId: string) {
|
||||
return this.execute<GetAudienceResponse>({
|
||||
requestFn: () =>
|
||||
classroomio.organization[':orgId'].audience.$get({
|
||||
param: { orgId }
|
||||
}),
|
||||
logContext: 'fetching organization audience',
|
||||
onSuccess: (response) => {
|
||||
this.audience = response.data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets courses by organization siteName
|
||||
* @param siteName Organization site name
|
||||
* @returns Courses array
|
||||
*/
|
||||
async getCourseBySiteName(siteName: string) {
|
||||
return this.execute<GetCoursesBySiteNameResponse>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.courses.$get({
|
||||
query: { siteName }
|
||||
}),
|
||||
logContext: 'fetching courses',
|
||||
onSuccess: (response) => {
|
||||
this.orgSiteCourses = response.data;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets current organization by siteName or custom domain
|
||||
* @param siteName Organization site name or custom domain
|
||||
* @param isCustomDomain Whether the siteName is a custom domain
|
||||
* @returns Organization data or null if not found
|
||||
*/
|
||||
async getOrgBySiteName(siteName: string, isCustomDomain = false) {
|
||||
const query: TGetOrganizations = { siteName };
|
||||
if (isCustomDomain) {
|
||||
query.customDomain = siteName;
|
||||
query.isCustomDomainVerified = true;
|
||||
}
|
||||
|
||||
const result = await this.execute<typeof classroomio.organization.$get>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.$get({
|
||||
query
|
||||
}),
|
||||
logContext: 'fetching organization',
|
||||
onSuccess: (response) => {
|
||||
if (!response.data || !Array.isArray(response.data) || response.data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
orgs.set(response.data);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export const orgApi = new OrgApi();
|
||||
@@ -0,0 +1,25 @@
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
// Team member types
|
||||
export type GetTeamResponse = (typeof classroomio.organization)[':orgId']['team']['$get'];
|
||||
export type OrganizationTeamResponse = InferResponseType<GetTeamResponse> | null;
|
||||
export type OrganizationTeamSuccess = Extract<InferResponseType<GetTeamResponse>, { success: true }>;
|
||||
|
||||
export type OrganizationTeamMembers = OrganizationTeamSuccess['data'];
|
||||
|
||||
// Audience types
|
||||
export type GetAudienceResponse = (typeof classroomio.organization)[':orgId']['audience']['$get'];
|
||||
export type OrganizationAudienceResponse = InferResponseType<GetAudienceResponse> | null;
|
||||
export type OrganizationAudienceSuccess = Extract<InferResponseType<GetAudienceResponse>, { success: true }>;
|
||||
|
||||
export type OrganizationAudience = OrganizationAudienceSuccess['data'];
|
||||
|
||||
// Org Public Courses types
|
||||
export type GetCoursesBySiteNameResponse = typeof classroomio.organization.courses.$get;
|
||||
export type CoursesByOrganizationSiteNameResponse = InferResponseType<GetCoursesBySiteNameResponse> | null;
|
||||
export type CoursesByOrganizationSiteNameSuccess = Extract<
|
||||
InferResponseType<GetCoursesBySiteNameResponse>,
|
||||
{ success: true }
|
||||
>;
|
||||
|
||||
export type CoursesByOrganizationSiteName = CoursesByOrganizationSiteNameSuccess['data'];
|
||||
+4
-8
@@ -1,14 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
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">
|
||||
<Badge variant="outline" class="ui:text-white bg-blue-500 dark:bg-blue-600 dark:text-black">
|
||||
{label}
|
||||
</p>
|
||||
|
||||
<style>
|
||||
.org-avatar {
|
||||
width: fit-content;
|
||||
}
|
||||
</style>
|
||||
</Badge>
|
||||
@@ -0,0 +1,3 @@
|
||||
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';
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { MediaQuery } from 'svelte/reactivity';
|
||||
import * as Breadcrumb from '@cio/ui/base/breadcrumb';
|
||||
import * as Drawer from '@cio/ui/base/drawer';
|
||||
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
|
||||
import { buttonVariants } from '@cio/ui/base/button';
|
||||
import { page } from '$app/state';
|
||||
import { currentOrgPath, currentOrg, isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { getOrgNavigationItems } from './org-navigation';
|
||||
import { generateBreadcrumbs } from './breadcrumb';
|
||||
import OrgSwitcher from '../sidebar/org-sidebar/org-switcher.svelte';
|
||||
|
||||
const navItems = $derived(getOrgNavigationItems($currentOrgPath, $currentOrg, $isOrgAdmin, $t, page.url.pathname));
|
||||
|
||||
const breadcrumbs = $derived(
|
||||
generateBreadcrumbs(
|
||||
page.url.pathname,
|
||||
page.url.search,
|
||||
navItems,
|
||||
$currentOrgPath,
|
||||
page.data as { breadcrumb?: string } | undefined
|
||||
)
|
||||
);
|
||||
|
||||
let open = $state(false);
|
||||
|
||||
const isDesktop = new MediaQuery('(min-width: 768px)');
|
||||
const ITEMS_TO_DISPLAY = $derived(isDesktop.current ? 2 : 1); // Show last 2 items (since we have org switcher as first)
|
||||
</script>
|
||||
|
||||
<Breadcrumb.Root>
|
||||
<Breadcrumb.List>
|
||||
<Breadcrumb.Item>
|
||||
<OrgSwitcher variant="breadcrumb" />
|
||||
</Breadcrumb.Item>
|
||||
|
||||
{#if breadcrumbs.length > 0}
|
||||
<Breadcrumb.Separator />
|
||||
|
||||
{#if breadcrumbs.length > ITEMS_TO_DISPLAY}
|
||||
<!-- Show ellipsis for hidden items -->
|
||||
<Breadcrumb.Item>
|
||||
{#if isDesktop.current}
|
||||
<DropdownMenu.Root bind:open>
|
||||
<DropdownMenu.Trigger class="flex items-center gap-1" aria-label="Toggle menu">
|
||||
<Breadcrumb.Ellipsis class="size-4" />
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="start">
|
||||
{#each breadcrumbs.slice(0, -ITEMS_TO_DISPLAY) as breadcrumb, i (i)}
|
||||
<DropdownMenu.Item>
|
||||
<a href={breadcrumb.href ? breadcrumb.href : '##'}>
|
||||
{breadcrumb.label}
|
||||
</a>
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{:else}
|
||||
<Drawer.Root bind:open>
|
||||
<Drawer.Trigger aria-label="Toggle Menu">
|
||||
<Breadcrumb.Ellipsis class="size-4" />
|
||||
</Drawer.Trigger>
|
||||
<Drawer.Content>
|
||||
<Drawer.Header class="text-start">
|
||||
<Drawer.Title>Navigate to</Drawer.Title>
|
||||
<Drawer.Description>Select a page to navigate to.</Drawer.Description>
|
||||
</Drawer.Header>
|
||||
<div class="grid gap-1 px-4">
|
||||
{#each breadcrumbs.slice(0, -ITEMS_TO_DISPLAY) as breadcrumb, i (i)}
|
||||
<a href={breadcrumb.href ? breadcrumb.href : '##'} class="py-1 text-sm">
|
||||
{breadcrumb.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<Drawer.Footer class="pt-4">
|
||||
<Drawer.Close class={buttonVariants({ variant: 'outline' })}>Close</Drawer.Close>
|
||||
</Drawer.Footer>
|
||||
</Drawer.Content>
|
||||
</Drawer.Root>
|
||||
{/if}
|
||||
</Breadcrumb.Item>
|
||||
<Breadcrumb.Separator />
|
||||
{/if}
|
||||
|
||||
<!-- Show last ITEMS_TO_DISPLAY items (or all if <= ITEMS_TO_DISPLAY) -->
|
||||
{#each breadcrumbs.slice(-ITEMS_TO_DISPLAY) as breadcrumb, index (breadcrumb.label)}
|
||||
<Breadcrumb.Item>
|
||||
{@const isLast = index === breadcrumbs.slice(-ITEMS_TO_DISPLAY).length - 1}
|
||||
{#if !isLast}
|
||||
<Breadcrumb.Link href={breadcrumb.href} class="max-w-20 truncate md:max-w-none">
|
||||
{breadcrumb.label}
|
||||
</Breadcrumb.Link>
|
||||
<Breadcrumb.Separator />
|
||||
{:else}
|
||||
<Breadcrumb.Page class="max-w-20 truncate md:max-w-none">
|
||||
{breadcrumb.label}
|
||||
</Breadcrumb.Page>
|
||||
{/if}
|
||||
</Breadcrumb.Item>
|
||||
{/each}
|
||||
{/if}
|
||||
</Breadcrumb.List>
|
||||
</Breadcrumb.Root>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import BellIcon from '@lucide/svelte/icons/bell';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Popover from '@cio/ui/base/popover';
|
||||
import Search from '../search.svelte';
|
||||
import AppBreadcrumbs from './app-breadcrumbs.svelte';
|
||||
</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"
|
||||
>
|
||||
<div class="flex w-full items-center gap-2 px-4">
|
||||
<Sidebar.Trigger />
|
||||
|
||||
<div class="h-4 w-2">
|
||||
<Separator orientation="vertical" />
|
||||
</div>
|
||||
|
||||
<AppBreadcrumbs />
|
||||
|
||||
<span class="grow"></span>
|
||||
|
||||
<Search />
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<Button variant="outline" size="icon">
|
||||
<BellIcon class="custom rounded-full" />
|
||||
</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>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { NavItem } from './org-navigation';
|
||||
import { isActive } from '$lib/utils/functions/app';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate breadcrumbs from the current pathname using navigation structure
|
||||
*/
|
||||
export function generateBreadcrumbs(
|
||||
pathname: string,
|
||||
searchParams: string,
|
||||
navItems: NavItem[],
|
||||
currentOrgPath: string,
|
||||
pageData?: { breadcrumb?: string } // Optional page data for dynamic titles
|
||||
): BreadcrumbItem[] {
|
||||
const breadcrumbs: BreadcrumbItem[] = [];
|
||||
const pathWithQuery = pathname + searchParams;
|
||||
|
||||
// Remove the org path prefix to get the relative path
|
||||
const relativePath = pathname.replace(currentOrgPath, '').replace(/^\//, '') || '';
|
||||
const pathSegments = relativePath.split('/').filter(Boolean);
|
||||
|
||||
// Handle root/dashboard
|
||||
if (!relativePath || relativePath === '') {
|
||||
return breadcrumbs;
|
||||
}
|
||||
|
||||
// Find the matching top-level nav item
|
||||
const firstSegment = pathSegments[0];
|
||||
let matchedNavItem: NavItem | undefined;
|
||||
|
||||
for (const item of navItems) {
|
||||
// Use path for matching (works for both hash URLs and regular URLs)
|
||||
const itemPath = item.path.replace(/^\//, '');
|
||||
if (itemPath === firstSegment || (itemPath === '' && firstSegment === undefined)) {
|
||||
matchedNavItem = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedNavItem) {
|
||||
// Try to match by checking if pathname matches the item path
|
||||
for (const item of navItems) {
|
||||
const fullItemPath = `${currentOrgPath}${item.path}`;
|
||||
if (isActive(pathname, fullItemPath)) {
|
||||
matchedNavItem = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedNavItem) {
|
||||
// Handle hash URLs (like settings) - use the actual path instead of '#'
|
||||
const href = matchedNavItem.useHashUrl ? `${currentOrgPath}${matchedNavItem.path}` : matchedNavItem.url;
|
||||
|
||||
breadcrumbs.push({
|
||||
label: matchedNavItem.title,
|
||||
href: href
|
||||
});
|
||||
}
|
||||
|
||||
// Handle items with query params (like settings tabs)
|
||||
if (matchedNavItem?.items && searchParams) {
|
||||
const subItem = matchedNavItem.items.find((sub) => isActive(pathWithQuery, sub.url, true));
|
||||
if (subItem) {
|
||||
breadcrumbs.push({
|
||||
label: subItem.title,
|
||||
href: subItem.url
|
||||
});
|
||||
return breadcrumbs;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle nested routes
|
||||
if (pathSegments.length > 1 && matchedNavItem) {
|
||||
const secondSegment = pathSegments[1];
|
||||
|
||||
// Check for static nested routes (like community/ask, settings/customize-lms)
|
||||
if (matchedNavItem.nestedRoutes) {
|
||||
const nestedRoute = matchedNavItem.nestedRoutes.find((route) => route.path === secondSegment);
|
||||
if (nestedRoute) {
|
||||
breadcrumbs.push({
|
||||
label: nestedRoute.titleKey, // Could be translated if needed
|
||||
href: `${currentOrgPath}${matchedNavItem.path}/${nestedRoute.path}`
|
||||
});
|
||||
return breadcrumbs;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for dynamic segments (like community/[slug], quiz/[slug])
|
||||
if (matchedNavItem.supportsDynamicSegment && secondSegment) {
|
||||
// Check if it's not a static nested route
|
||||
const isStaticRoute = matchedNavItem.nestedRoutes?.some((route) => route.path === secondSegment);
|
||||
if (!isStaticRoute && pageData?.breadcrumb) {
|
||||
breadcrumbs.push({
|
||||
label: pageData.breadcrumb,
|
||||
href: `${currentOrgPath}${matchedNavItem.path}/${secondSegment}`
|
||||
});
|
||||
}
|
||||
return breadcrumbs;
|
||||
}
|
||||
}
|
||||
|
||||
return breadcrumbs;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { AccountOrg } from '$lib/features/app/types';
|
||||
import CogIcon from '@lucide/svelte/icons/cog';
|
||||
import type { Component } from 'svelte';
|
||||
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard';
|
||||
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
|
||||
import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
|
||||
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import { isActive } from '$lib/utils/functions/app';
|
||||
|
||||
export interface NavItem {
|
||||
title: string; // translation key
|
||||
url: string;
|
||||
path: string; // Actual path (e.g., '/settings') for breadcrumb generation
|
||||
icon?: Component;
|
||||
isActive?: boolean;
|
||||
isExpanded?: boolean;
|
||||
items?: NavItem[]; // for nested items like settings
|
||||
// Metadata for breadcrumb generation
|
||||
useHashUrl?: boolean; // Use '#' as URL (for collapsible items like settings)
|
||||
nestedRoutes?: NestedRouteConfig[]; // Static nested routes (like community/ask, settings/customize-lms)
|
||||
supportsDynamicSegment?: boolean; // Supports dynamic segments (like [slug])
|
||||
}
|
||||
|
||||
export interface NavItemConfig {
|
||||
titleKey: string;
|
||||
path: string;
|
||||
icon?: Component;
|
||||
requiresAdmin?: boolean;
|
||||
items?: NavItemConfig[];
|
||||
useHashUrl?: boolean; // Use '#' as URL (for collapsible items like settings)
|
||||
nestedRoutes?: NestedRouteConfig[]; // Static nested routes
|
||||
supportsDynamicSegment?: boolean; // Supports dynamic segments (like [slug])
|
||||
}
|
||||
|
||||
export interface NestedRouteConfig {
|
||||
path: string; // Relative to parent (e.g., 'ask', 'customize-lms')
|
||||
titleKey: string; // Translation key or plain text
|
||||
}
|
||||
|
||||
// Base navigation configuration structure
|
||||
const baseNavConfig: NavItemConfig[] = [
|
||||
{
|
||||
titleKey: 'org_navigation.dashboard',
|
||||
path: '',
|
||||
icon: LayoutDashboardIcon
|
||||
},
|
||||
{
|
||||
titleKey: 'org_navigation.courses',
|
||||
path: '/courses',
|
||||
icon: LibraryBigIcon
|
||||
},
|
||||
{
|
||||
titleKey: 'org_navigation.community',
|
||||
path: '/community',
|
||||
icon: MessageSquareMoreIcon,
|
||||
supportsDynamicSegment: true, // Supports /community/[slug]
|
||||
nestedRoutes: [
|
||||
{
|
||||
path: 'ask',
|
||||
titleKey: 'Ask Question' // Could be translated
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
titleKey: 'org_navigation.audience',
|
||||
path: '/audience',
|
||||
icon: UsersIcon
|
||||
},
|
||||
{
|
||||
titleKey: 'org_navigation.setup',
|
||||
path: '/setup',
|
||||
icon: Settings2Icon,
|
||||
requiresAdmin: true
|
||||
},
|
||||
{
|
||||
titleKey: 'org_navigation.settings',
|
||||
path: '/settings',
|
||||
icon: CogIcon,
|
||||
useHashUrl: true, // Use '#' for collapsible parent
|
||||
items: [
|
||||
{
|
||||
titleKey: 'Profile',
|
||||
path: '/settings'
|
||||
},
|
||||
{
|
||||
titleKey: 'Organization',
|
||||
path: '/settings?tab=org'
|
||||
},
|
||||
{
|
||||
titleKey: 'Landing Page',
|
||||
path: '/settings?tab=landingpage'
|
||||
},
|
||||
{
|
||||
titleKey: 'Billing',
|
||||
path: '/settings?tab=billing'
|
||||
}
|
||||
],
|
||||
nestedRoutes: [
|
||||
{
|
||||
path: 'customize-lms',
|
||||
titleKey: 'Customize LMS'
|
||||
},
|
||||
{
|
||||
path: 'domains',
|
||||
titleKey: 'Domains'
|
||||
},
|
||||
{
|
||||
path: 'teams',
|
||||
titleKey: 'Teams'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Get navigation items based on organization context and permissions
|
||||
*/
|
||||
export function getOrgNavigationItems(
|
||||
currentOrgPath: string,
|
||||
currentOrg: AccountOrg,
|
||||
isOrgAdmin: boolean | null,
|
||||
t: (key: string) => string,
|
||||
pagePathname: string
|
||||
): NavItem[] {
|
||||
const items: NavItem[] = [];
|
||||
|
||||
for (const config of baseNavConfig) {
|
||||
// Skip admin-only items if user is not admin
|
||||
if (config.requiresAdmin && !isOrgAdmin) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = config.path === '' ? currentOrgPath : `${currentOrgPath}${config.path}`;
|
||||
const fullPath = config.path === '' ? `/org/${currentOrg.siteName}` : `/org/${currentOrg.siteName}${config.path}`;
|
||||
|
||||
const item: NavItem = {
|
||||
title: t(config.titleKey),
|
||||
url: config.useHashUrl ? '#' : url,
|
||||
path: config.path, // Store actual path for breadcrumb generation
|
||||
icon: config.icon,
|
||||
isActive: isActive(pagePathname, fullPath),
|
||||
isExpanded: config.items ? isActive(pagePathname, fullPath) : undefined,
|
||||
useHashUrl: config.useHashUrl,
|
||||
nestedRoutes: config.nestedRoutes,
|
||||
supportsDynamicSegment: config.supportsDynamicSegment
|
||||
};
|
||||
|
||||
// Handle nested items (like settings sub-items)
|
||||
if (config.items) {
|
||||
item.items = config.items.map((subConfig) => ({
|
||||
title: subConfig.titleKey,
|
||||
url: `${currentOrgPath}${subConfig.path}`,
|
||||
path: subConfig.path
|
||||
}));
|
||||
}
|
||||
|
||||
items.push(item);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import * as Kbd from '@cio/ui/base/kbd';
|
||||
import * as InputGroup from '@cio/ui/base/input-group';
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
</script>
|
||||
|
||||
<InputGroup.Root class="hidden! md:flex! max-w-56">
|
||||
<InputGroup.Input placeholder="Search..." />
|
||||
<InputGroup.Addon>
|
||||
<SearchIcon />
|
||||
</InputGroup.Addon>
|
||||
<InputGroup.Addon align="inline-end">
|
||||
<Kbd.Root>⌘</Kbd.Root>
|
||||
<Kbd.Root>K</Kbd.Root>
|
||||
</InputGroup.Addon>
|
||||
</InputGroup.Root>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SidebarFooterMenu } from './menu.svelte';
|
||||
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
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 LogOutIcon from '@lucide/svelte/icons/log-out';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
|
||||
import ThemeToggle from './theme-toggle.svelte';
|
||||
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { logout } from '$lib/utils/functions/logout';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
|
||||
const sidebar = useSidebar();
|
||||
</script>
|
||||
|
||||
<!--
|
||||
|
||||
SNIPPETS
|
||||
|
||||
-->
|
||||
{#snippet avatarblock()}
|
||||
<Avatar.Root>
|
||||
<Avatar.Image src={$profile.avatarUrl} alt={$profile.fullname} />
|
||||
<Avatar.Fallback class="rounded-lg">{$profile.fullname.slice(0, 2)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="grid flex-1 text-left text-sm font-normal leading-tight">
|
||||
<span class="truncate">{$profile.fullname}</span>
|
||||
<span class="truncate text-xs">{$profile.email}</span>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet usertrigger()}
|
||||
<DropdownMenu.Label class="p-0">
|
||||
<a
|
||||
href={`${!$globalStore.isOrgSite ? $currentOrgPath : '/lms'}/settings`}
|
||||
class="flex items-center gap-2 px-1 py-1.5 text-left text-sm"
|
||||
>
|
||||
{@render avatarblock()}
|
||||
</a>
|
||||
</DropdownMenu.Label>
|
||||
{/snippet}
|
||||
|
||||
{#snippet themetoggle()}
|
||||
<DropdownMenu.Label class="p-0 font-normal">
|
||||
<ThemeToggle />
|
||||
</DropdownMenu.Label>
|
||||
{/snippet}
|
||||
|
||||
<!--
|
||||
|
||||
COMPONENT
|
||||
|
||||
-->
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<DropdownMenu.Root>
|
||||
<!-- DROPDOWN TRIGGER -->
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton
|
||||
size="lg"
|
||||
class="ui:data-[state=open]:bg-sidebar-accent ui:data-[state=open]:text-sidebar-accent-foreground"
|
||||
{...props}
|
||||
>
|
||||
{@render avatarblock()}
|
||||
<ChevronsUpDownIcon class="ml-auto size-4" />
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
|
||||
<!-- DROPDOWN CONTENT -->
|
||||
<DropdownMenu.Content
|
||||
class="ui:w-(--bits-dropdown-menu-anchor-width) ui:min-w-56 ui:rounded-lg"
|
||||
side={sidebar.isMobile ? 'bottom' : 'right'}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
{@render usertrigger()}
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
{@render themetoggle()}
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Group>
|
||||
<div class="cursor-pointer space-y-2">
|
||||
{#if !$globalStore.isOrgSite}
|
||||
<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="https://classroomio.com/contact"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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>
|
||||
{/if}
|
||||
|
||||
<DropdownMenu.Separator />
|
||||
|
||||
<DropdownMenu.Item onclick={() => logout()}>
|
||||
<span class="flex items-center gap-2">
|
||||
<LogOutIcon />
|
||||
<p class="text-sm">{$t('settings.profile.logout')}</p>
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
</div>
|
||||
</DropdownMenu.Group>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { MonitorIcon, SunIcon, MoonIcon } from '@lucide/svelte';
|
||||
import { userPrefersMode, resetMode, setMode } from 'mode-watcher';
|
||||
|
||||
type UserPrefersMode = typeof userPrefersMode.current;
|
||||
|
||||
const themes: { mode: UserPrefersMode; icon: typeof MonitorIcon }[] = [
|
||||
{ mode: 'light', icon: SunIcon },
|
||||
{ mode: 'dark', icon: MoonIcon },
|
||||
{ mode: 'system', icon: MonitorIcon }
|
||||
];
|
||||
|
||||
const handleThemeChange = (newMode: UserPrefersMode) => {
|
||||
if (newMode === 'light') {
|
||||
setMode('light');
|
||||
} else if (newMode === 'dark') {
|
||||
setMode('dark');
|
||||
} else {
|
||||
resetMode();
|
||||
}
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
console.log('mode.current', userPrefersMode.current);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-between gap-8">
|
||||
<p>Theme</p>
|
||||
|
||||
<div class="flex items-center gap-1 rounded-md backdrop-blur-sm">
|
||||
{#each themes as theme (theme.mode)}
|
||||
{@const isActive = userPrefersMode.current === theme.mode || userPrefersMode.current === undefined}
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant={isActive ? 'outline' : 'ghost'}
|
||||
class={['size-4 rounded-md p-1 transition-all duration-200', isActive && 'shadow-primary/20 shadow-lg']}
|
||||
title={theme.mode}
|
||||
onclick={() => handleThemeChange(theme.mode)}
|
||||
>
|
||||
<theme.icon class="size-4" />
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
|
||||
const planName = $derived($currentOrg.plans?.[0]?.planName || 'FREE');
|
||||
</script>
|
||||
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton
|
||||
size="sm"
|
||||
class="ui:data-[state=open]:bg-sidebar-accent ui:data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<a
|
||||
href="https://classroomio.com?utm_source={$currentOrg.siteName}.classroomio.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
>
|
||||
<Avatar.Root class="ui:flex ui:size-6 ui:items-center ui:justify-center">
|
||||
<Avatar.Image src={'/logo-192.png'} alt="ClassroomIO logo" />
|
||||
</Avatar.Root>
|
||||
|
||||
<span class="truncate font-normal">ClassroomIO</span>
|
||||
<Badge variant="outline" class="capitalize">
|
||||
{planName.toLowerCase()}
|
||||
</Badge>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
@@ -0,0 +1 @@
|
||||
export { default as OrgSidebar } from './org-sidebar.svelte';
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { currentOrgPath, currentOrg, isOrgAdmin } from '$lib/utils/store/org';
|
||||
import * as Collapsible from '@cio/ui/base/collapsible';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { isActive } from '$lib/utils/functions/app';
|
||||
import { page } from '$app/state';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import { getOrgNavigationItems } from '$lib/features/ui/navigation/org-navigation';
|
||||
|
||||
const items = $derived(getOrgNavigationItems($currentOrgPath, $currentOrg, $isOrgAdmin, $t, page.url.pathname));
|
||||
|
||||
const pathWithQuery = $derived(page.url.pathname + page.url.search);
|
||||
</script>
|
||||
|
||||
<Sidebar.Group>
|
||||
<Sidebar.GroupLabel>{$t('org_navigation.platform')}</Sidebar.GroupLabel>
|
||||
<Sidebar.Menu>
|
||||
{#each items as item (item.title)}
|
||||
<Collapsible.Root open={item.isActive || item.isExpanded} class="group/collapsible">
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuItem {...props}>
|
||||
<Collapsible.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton {...props} tooltipContent={item.title} isActive={item.isActive}>
|
||||
{#snippet child({ props })}
|
||||
<a href={item.url} {...props}>
|
||||
{#if item.icon}
|
||||
<item.icon />
|
||||
{/if}
|
||||
<span>{item.title}</span>
|
||||
{#if item.items}
|
||||
<ChevronRightIcon
|
||||
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||
/>
|
||||
{/if}
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</Collapsible.Trigger>
|
||||
{#if item.items}
|
||||
<Collapsible.Content>
|
||||
<Sidebar.MenuSub>
|
||||
{#each item.items ?? [] as subItem (subItem.title)}
|
||||
<Sidebar.MenuSubItem>
|
||||
<Sidebar.MenuSubButton isActive={isActive(pathWithQuery, subItem.url, true)}>
|
||||
{#snippet child({ props })}
|
||||
<a href={subItem.url} {...props}>
|
||||
<span>{subItem.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuSubButton>
|
||||
</Sidebar.MenuSubItem>
|
||||
{/each}
|
||||
</Sidebar.MenuSub>
|
||||
</Collapsible.Content>
|
||||
{/if}
|
||||
</Sidebar.MenuItem>
|
||||
{/snippet}
|
||||
</Collapsible.Root>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { orgs } from '$lib/utils/store/org';
|
||||
|
||||
import AppLogo from './app-logo.svelte';
|
||||
import NavMain from './nav-main.svelte';
|
||||
import { SidebarFooterMenu } from '../footer';
|
||||
import UpgradeTrigger from './upgrade-trigger.svelte';
|
||||
import SidebarSkeleton from '../sidebar-skeleton.svelte';
|
||||
|
||||
const isOrgLoaded = $derived($orgs.length > 0 && $profile.id);
|
||||
</script>
|
||||
|
||||
{#if !isOrgLoaded}
|
||||
<SidebarSkeleton />
|
||||
{:else}
|
||||
<Sidebar.Root collapsible="icon">
|
||||
<Sidebar.Header>
|
||||
<AppLogo />
|
||||
</Sidebar.Header>
|
||||
|
||||
<Sidebar.Content>
|
||||
<NavMain />
|
||||
<UpgradeTrigger />
|
||||
</Sidebar.Content>
|
||||
|
||||
<Sidebar.Footer>
|
||||
<SidebarFooterMenu />
|
||||
</Sidebar.Footer>
|
||||
|
||||
<Sidebar.Rail />
|
||||
</Sidebar.Root>
|
||||
{/if}
|
||||
@@ -0,0 +1,156 @@
|
||||
<script lang="ts">
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import * as Breadcrumb from '@cio/ui/base/breadcrumb';
|
||||
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 ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import { getShortOrgName } from '$lib/utils/functions/org';
|
||||
import type { AccountOrg } from '$lib/features/app/types';
|
||||
|
||||
import { setTheme } from '$lib/utils/functions/theme';
|
||||
import { currentOrg, currentOrgPath, orgs } from '$lib/utils/store/org';
|
||||
|
||||
import { ComingSoon } from '$lib/features/ui';
|
||||
|
||||
const sidebar = useSidebar();
|
||||
|
||||
interface Props {
|
||||
variant?: 'sidebar' | 'breadcrumb';
|
||||
}
|
||||
|
||||
let { variant = 'sidebar' }: Props = $props();
|
||||
|
||||
function onClick(org: AccountOrg) {
|
||||
if (org.id === $currentOrg.id) return;
|
||||
|
||||
localStorage.setItem('classroomio_org_sitename', org.siteName);
|
||||
currentOrg.set(org);
|
||||
|
||||
setTheme(org.theme);
|
||||
window.location.href = $currentOrgPath;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if variant === 'breadcrumb'}
|
||||
<!-- Breadcrumb context version -->
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Breadcrumb.Link {...props} href="##" class="flex items-center gap-2">
|
||||
{#if $currentOrg.name}
|
||||
<Avatar.Root class="rounded-md! size-6! flex items-center justify-center">
|
||||
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
|
||||
<Avatar.Fallback class="rounded-md! text-xs">{getShortOrgName($currentOrg.name)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<span class="hidden truncate text-sm font-medium md:block">{$currentOrg.name}</span>
|
||||
<ChevronDownIcon class="ml-auto hidden size-4 md:block" />
|
||||
{:else}
|
||||
<Skeleton class="h-4 w-24" />
|
||||
{/if}
|
||||
</Breadcrumb.Link>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
class="ui:w-(--bits-dropdown-menu-anchor-width) ui:min-w-56 ui:rounded-lg"
|
||||
align="start"
|
||||
side={sidebar.isMobile ? 'bottom' : 'right'}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenu.Label class="ui:text-muted-foreground ui:text-xs">Organizations</DropdownMenu.Label>
|
||||
|
||||
{#each $orgs as org (org.name)}
|
||||
<DropdownMenu.Item onSelect={() => onClick(org)} class="gap-2 p-2">
|
||||
<Avatar.Root class="flex size-8 items-center justify-center rounded-lg">
|
||||
<Avatar.Image src={org.avatarUrl} alt={org.name} />
|
||||
<Avatar.Fallback class="rounded-lg">{getShortOrgName(org.name)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
{org.name}
|
||||
</DropdownMenu.Item>
|
||||
{/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="ui:text-muted-foreground ui:font-normal">Add Organization</div>
|
||||
|
||||
<ComingSoon />
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
{:else}
|
||||
<!-- Sidebar context version -->
|
||||
<Sidebar.Menu>
|
||||
<Sidebar.MenuItem>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton
|
||||
{...props}
|
||||
size="lg"
|
||||
class="ui:data-[state=open]:bg-sidebar-accent ui:data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
{#if $currentOrg.name}
|
||||
<Avatar.Root class="ui:flex ui:size-8 ui:items-center ui:justify-center ui:rounded-lg">
|
||||
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
|
||||
<Avatar.Fallback class="rounded-lg">{getShortOrgName($currentOrg.name)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-normal">
|
||||
{$currentOrg.name}
|
||||
</span>
|
||||
<span class="truncate text-xs">
|
||||
{$currentOrg.plans?.[0]?.planName || 'Free'}
|
||||
</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="ui:w-(--bits-dropdown-menu-anchor-width) ui:min-w-56 ui:rounded-lg"
|
||||
align="start"
|
||||
side={sidebar.isMobile ? 'bottom' : 'right'}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenu.Label class="ui:text-muted-foreground ui:text-xs">Organizations</DropdownMenu.Label>
|
||||
|
||||
{#each $orgs as org (org.name)}
|
||||
<DropdownMenu.Item onSelect={() => onClick(org)} class="gap-2 p-2">
|
||||
<Avatar.Root class="flex size-8 items-center justify-center rounded-lg">
|
||||
<Avatar.Image src={org.avatarUrl} alt={org.name} />
|
||||
<Avatar.Fallback class="rounded-lg">{getShortOrgName(org.name)}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
{org.name}
|
||||
</DropdownMenu.Item>
|
||||
{/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="ui:text-muted-foreground ui:font-normal">Add Organization</div>
|
||||
|
||||
<ComingSoon />
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Sidebar.MenuItem>
|
||||
</Sidebar.Menu>
|
||||
{/if}
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { Button } from '$src/base/button';
|
||||
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||
|
||||
const sidebar = useSidebar();
|
||||
</script>
|
||||
|
||||
{#if $isFreePlan}
|
||||
<Sidebar.Content>
|
||||
{#if sidebar.open || sidebar.isMobile}
|
||||
<div
|
||||
class="mx-4 mt-2 flex flex-col items-center justify-center gap-4 rounded-md border border-blue-600 px-2 py-6 text-center transition-all ease-in-out hover:border-blue-800"
|
||||
>
|
||||
<SparklesIcon class="my-3 size-6" />
|
||||
<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>
|
||||
<Button
|
||||
data-sidebar="upgrade-trigger"
|
||||
data-slot="upgrade-trigger"
|
||||
class="font-normal"
|
||||
type="button"
|
||||
onclick={openUpgradeModal}
|
||||
>
|
||||
<SparklesIcon class="custom size-4" />
|
||||
{$t('org_navigation.upgrade')}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<Button
|
||||
data-sidebar="upgrade-trigger"
|
||||
data-slot="upgrade-trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="mx-1 flex w-full items-center justify-center"
|
||||
type="button"
|
||||
onclick={openUpgradeModal}
|
||||
>
|
||||
<SparklesIcon class="custom size-4" />
|
||||
<span class="sr-only">{$t('org_navigation.upgrade')}</span>
|
||||
</Button>
|
||||
{/if}
|
||||
</Sidebar.Content>
|
||||
{/if}
|
||||
@@ -0,0 +1,49 @@
|
||||
<script>
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
</script>
|
||||
|
||||
<div class="bg-sidebar flex h-[calc(100vh-48px)] w-64 flex-col gap-2 border-r p-2">
|
||||
<!-- org switcher -->
|
||||
<div class="flex items-center gap-2 rounded-md p-2">
|
||||
<Skeleton class="h-8 w-8 rounded-lg" />
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<Skeleton class="h-4 w-24" />
|
||||
<Skeleton class="h-3 w-16" />
|
||||
</div>
|
||||
<Skeleton class="h-4 w-4" />
|
||||
</div>
|
||||
|
||||
<!-- nav main -->
|
||||
<div class="flex flex-col gap-1 py-2">
|
||||
<Skeleton class="mb-2 h-3 w-16 px-2" />
|
||||
{#each Array(5) as _, i (i)}
|
||||
<div class="flex items-center gap-2 rounded-md p-2">
|
||||
<Skeleton class="h-4 w-4" />
|
||||
<Skeleton class="h-4 w-full" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- spacer -->
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- upgrade banner -->
|
||||
<div class="mx-4 mb-2 flex flex-col items-center gap-3 rounded-md border p-4">
|
||||
<Skeleton class="h-16 w-16 rounded-md" />
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<Skeleton class="h-4 w-32" />
|
||||
<Skeleton class="h-3 w-40" />
|
||||
</div>
|
||||
<Skeleton class="h-9 w-24 rounded-md" />
|
||||
</div>
|
||||
|
||||
<!-- nav user -->
|
||||
<div class="flex items-center gap-2 rounded-md p-2">
|
||||
<Skeleton class="h-8 w-8 rounded-lg" />
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<Skeleton class="h-4 w-24" />
|
||||
<Skeleton class="h-3 w-20" />
|
||||
</div>
|
||||
<Skeleton class="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -5,16 +5,7 @@ export const SIGNUP_FIELDS = {
|
||||
confirmPassword: ''
|
||||
};
|
||||
|
||||
export const RESET_FIELDS = {
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
};
|
||||
|
||||
export const LOGIN_FIELDS = {
|
||||
email: '',
|
||||
password: ''
|
||||
};
|
||||
|
||||
export const FORGOT_PASSWORD_FIELDS = {
|
||||
email: ''
|
||||
};
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
export const ROLE = {
|
||||
ADMIN: 1,
|
||||
TUTOR: 2,
|
||||
STUDENT: 3
|
||||
};
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
|
||||
export const ROLE_LABEL = {
|
||||
[ROLE.ADMIN]: 'course.navItem.people.roles.admin',
|
||||
|
||||
@@ -15,11 +15,12 @@ export const ROUTE = {
|
||||
DISCUSSIONS: '/discussions',
|
||||
ASK: '/ask',
|
||||
FORGOT: '/forgot',
|
||||
RESET: '/reset'
|
||||
RESET: '/reset',
|
||||
LOGOUT: '/logout'
|
||||
};
|
||||
|
||||
export const PUBLIC_ROUTES = [
|
||||
`^${ROUTE.HOME}$`,
|
||||
// `^${ROUTE.HOME}$`,
|
||||
ROUTE.LOGIN,
|
||||
ROUTE.SIGN_UP,
|
||||
`^${ROUTE.INVITE_TEACHER}/.*`,
|
||||
@@ -30,6 +31,15 @@ export const PUBLIC_ROUTES = [
|
||||
'/404'
|
||||
];
|
||||
|
||||
export const PUBLIC_API_ROUTES = [
|
||||
'/api/completion',
|
||||
'student_prove_payment',
|
||||
'teacher_student_buycourse',
|
||||
'/api/polar',
|
||||
'/api/lmz',
|
||||
'/api/verify'
|
||||
];
|
||||
|
||||
export const ROUTES_TO_HIDE_NAV = [
|
||||
`^${ROUTE.LOGIN}$`,
|
||||
`^${ROUTE.SIGN_UP}$`,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { LOCALE } from '../types';
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
|
||||
export const LANGUAGE = {
|
||||
[LOCALE.DE]: 'German',
|
||||
[LOCALE.EN]: 'English',
|
||||
[LOCALE.ES]: 'Spanish',
|
||||
[LOCALE.FR]: 'French',
|
||||
[LOCALE.HI]: 'Hindi',
|
||||
[LOCALE.PL]: 'Polish',
|
||||
[LOCALE.PT]: 'Portuguese',
|
||||
[LOCALE.RU]: 'Russian',
|
||||
[LOCALE.VI]: 'Vietnamese',
|
||||
[LOCALE.DA]: 'Danish'
|
||||
export const LANGUAGE: Record<TLocale, string> = {
|
||||
da: 'Danish',
|
||||
de: 'German',
|
||||
en: 'English',
|
||||
es: 'Spanish',
|
||||
fr: 'French',
|
||||
hi: 'Hindi',
|
||||
pl: 'Polish',
|
||||
pt: 'Portuguese',
|
||||
ru: 'Russian',
|
||||
vi: 'Vietnamese'
|
||||
};
|
||||
|
||||
export const LANGUAGES = Object.keys(LANGUAGE).map((lang) => ({
|
||||
|
||||
@@ -25,5 +25,20 @@ export function isQuizPage(path) {
|
||||
}
|
||||
|
||||
export function isLMSPage(path) {
|
||||
return /lms[\/a-z 0-9 -]*/.test(path);
|
||||
return /lms[/a-z 0-9 -]*/.test(path);
|
||||
}
|
||||
|
||||
export function isActive(pagePath: string, itemPath: string, exact: boolean = false) {
|
||||
const pageLinkItems = pagePath.split('/');
|
||||
const itemLinkItems = itemPath.split('/');
|
||||
|
||||
if (exact) {
|
||||
return pagePath === itemPath;
|
||||
}
|
||||
|
||||
if (itemLinkItems.length !== pageLinkItems.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return pagePath.includes(itemPath);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,6 @@
|
||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { identifyPosthogUser, initPosthog } from '$lib/utils/services/posthog';
|
||||
import { initSentry, setSentryUser } from '$lib/utils/services/sentry';
|
||||
import { profile, user } from '$lib/utils/store/user';
|
||||
|
||||
import { ROLE } from '$lib/utils/constants/roles';
|
||||
import { ROUTE } from '$lib/utils/constants/routes';
|
||||
import { dev } from '$app/environment';
|
||||
import { get } from 'svelte/store';
|
||||
import { getOrganizations } from '$lib/utils/services/org';
|
||||
import { goto } from '$app/navigation';
|
||||
import { handleLocaleChange } from '$lib/utils/functions/translations';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import isPublicRoute from '$lib/utils/functions/routes/isPublicRoute';
|
||||
import { page } from '$app/stores';
|
||||
import { setTheme } from '$lib/utils/functions/theme';
|
||||
import shouldRedirectOnAuth from '$lib/utils/functions/routes/shouldRedirectOnAuth';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { initPosthog } from '$lib/utils/services/posthog';
|
||||
import { initSentry } from '$lib/utils/services/sentry';
|
||||
|
||||
export function setupAnalytics() {
|
||||
// Set up sentry
|
||||
@@ -29,193 +14,3 @@ export function setupAnalytics() {
|
||||
localStorage.setItem('umami.disabled', '1');
|
||||
}
|
||||
}
|
||||
|
||||
function setAnalyticsUser() {
|
||||
const profileStore = get(profile);
|
||||
|
||||
if (!profileStore.id) return;
|
||||
|
||||
setSentryUser({
|
||||
id: profileStore.id,
|
||||
username: profileStore.username,
|
||||
email: profileStore.email,
|
||||
fullname: profileStore.fullname
|
||||
});
|
||||
|
||||
identifyPosthogUser(profileStore.id, {
|
||||
email: profileStore.email,
|
||||
name: profileStore.fullname
|
||||
});
|
||||
}
|
||||
|
||||
export async function getProfile({
|
||||
path,
|
||||
queryParam,
|
||||
isOrgSite,
|
||||
orgSiteName
|
||||
}: {
|
||||
path: string;
|
||||
queryParam: string;
|
||||
isOrgSite: boolean;
|
||||
orgSiteName: string;
|
||||
}) {
|
||||
const pageStore = get(page);
|
||||
const profileStore = get(profile);
|
||||
const currentOrgStore = get(currentOrg);
|
||||
const currentOrgDomainStore = get(currentOrgDomain);
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
// Get user profile
|
||||
const {
|
||||
data: { session }
|
||||
} = await supabase.auth.getSession();
|
||||
const { user: authUser } = session || {};
|
||||
console.log('Get user', authUser);
|
||||
|
||||
if (!authUser && !isPublicRoute(pageStore.url?.pathname)) {
|
||||
return goto('/login?redirect=/' + path + queryParam);
|
||||
}
|
||||
|
||||
// Skip refetching profile, if already in store
|
||||
if (profileStore.id) {
|
||||
handleLocaleChange(profileStore.locale);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has profile
|
||||
const {
|
||||
data: profileData,
|
||||
error,
|
||||
status
|
||||
} = await supabase.from('profile').select(`*`).eq('id', authUser?.id).single();
|
||||
console.log('Get profile', profileData);
|
||||
|
||||
if (error && !profileData && status === 406 && authUser) {
|
||||
// User wasn't found, create profile
|
||||
console.log(`User wasn't found, create profile`);
|
||||
|
||||
const [regexUsernameMatch] = [...(authUser.email?.matchAll(/(.*)@/g) || [])];
|
||||
|
||||
const isGoogleAuth = !!authUser.app_metadata?.providers?.includes('google');
|
||||
|
||||
const { data: newProfileData, error } = await supabase
|
||||
.from('profile')
|
||||
.insert({
|
||||
id: authUser.id,
|
||||
username: regexUsernameMatch[1] + `${new Date().getTime()}`,
|
||||
fullname: regexUsernameMatch[1],
|
||||
email: authUser.email,
|
||||
is_email_verified: isGoogleAuth,
|
||||
verified_at: isGoogleAuth ? new Date().toDateString() : undefined
|
||||
})
|
||||
.select();
|
||||
|
||||
// Profile created, go to onboarding or lms
|
||||
if (!error && newProfileData) {
|
||||
user.update((_user) => ({
|
||||
..._user,
|
||||
fetchingUser: false,
|
||||
isLoggedIn: true,
|
||||
currentSession: authUser
|
||||
}));
|
||||
|
||||
profile.set(newProfileData[0]);
|
||||
|
||||
setAnalyticsUser();
|
||||
|
||||
// Fetch language
|
||||
handleLocaleChange(newProfileData[0].locale);
|
||||
|
||||
if (isOrgSite) {
|
||||
const { data, error } = await supabase
|
||||
.from('organizationmember')
|
||||
.insert({
|
||||
organization_id: currentOrgStore.id,
|
||||
profile_id: profileStore.id,
|
||||
role_id: 3
|
||||
})
|
||||
.select();
|
||||
if (error) {
|
||||
console.error('Error adding user to organisation', error);
|
||||
} else {
|
||||
console.log('Success adding user to organisation', data);
|
||||
const memberId = data?.[0]?.id || '';
|
||||
|
||||
currentOrg.update((_currentOrg) => ({
|
||||
..._currentOrg,
|
||||
memberId
|
||||
}));
|
||||
}
|
||||
|
||||
if (params.get('redirect')) {
|
||||
goto(params.get('redirect') || '');
|
||||
} else if (shouldRedirectOnAuth(path)) {
|
||||
goto('/lms');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// On invite page, don't go to onboarding
|
||||
if (!path.includes('invite')) {
|
||||
goto(ROUTE.ONBOARDING);
|
||||
}
|
||||
}
|
||||
|
||||
user.update((_user) => ({
|
||||
..._user,
|
||||
fetchingUser: false
|
||||
}));
|
||||
} else if (profileData) {
|
||||
// Profile exists, go to profile page
|
||||
user.update((_user) => ({
|
||||
..._user,
|
||||
fetchingUser: false,
|
||||
isLoggedIn: true,
|
||||
currentSession: authUser
|
||||
}));
|
||||
|
||||
profile.set(profileData);
|
||||
|
||||
// Set user in sentry
|
||||
setAnalyticsUser();
|
||||
|
||||
handleLocaleChange(profileData.locale);
|
||||
|
||||
const orgRes = await getOrganizations(profileData.id, isOrgSite, orgSiteName);
|
||||
|
||||
const isStudentAccount = orgRes.currentOrg.role_id == ROLE.STUDENT;
|
||||
|
||||
// student redirect
|
||||
if (isOrgSite) {
|
||||
if (params.has('redirect')) {
|
||||
goto(params.get('redirect') || '');
|
||||
} else if (shouldRedirectOnAuth(path)) {
|
||||
goto('/lms');
|
||||
}
|
||||
} else {
|
||||
if (isStudentAccount) {
|
||||
// Check if the student logged into the dashboard.
|
||||
console.log('Student logged into dashboard');
|
||||
if (dev) {
|
||||
goto('/lms');
|
||||
} else {
|
||||
window.location.replace(`${currentOrgDomainStore}/lms`);
|
||||
}
|
||||
} else if (isEmpty(orgRes.orgs) && !path.includes('invite')) {
|
||||
// Not on invite page or no org, go to onboarding
|
||||
goto(ROUTE.ONBOARDING);
|
||||
} else if (params.has('redirect')) {
|
||||
goto(params.get('redirect') || '');
|
||||
} else if (shouldRedirectOnAuth(path)) {
|
||||
// By default redirect to first organization
|
||||
goto(`/org/${orgRes.currentOrg.siteName}`);
|
||||
}
|
||||
}
|
||||
|
||||
setTheme(orgRes?.currentOrg?.theme);
|
||||
}
|
||||
|
||||
if (!profileData && !isPublicRoute(pageStore.url?.pathname)) {
|
||||
goto('/login?redirect=/' + path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import posthog from 'posthog-js';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { appInitApi } from '$lib/features/app/init.svelte';
|
||||
import { authClient } from '$lib/utils/services/auth/client';
|
||||
import { capturePosthogEvent } from '$lib/utils/services/posthog';
|
||||
import { user, profile, defaultProfileState, defaultUserState } from '$lib/utils/store/user';
|
||||
import { goto } from '$app/navigation';
|
||||
import posthog from 'posthog-js';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
export async function logout(redirect = true) {
|
||||
const { error } = await supabase.auth.signOut();
|
||||
const { error } = await authClient.signOut();
|
||||
|
||||
if (error) {
|
||||
console.error('Error logging out: ', error);
|
||||
}
|
||||
|
||||
user.set(defaultUserState);
|
||||
profile.set(defaultProfileState);
|
||||
appInitApi.reset();
|
||||
|
||||
capturePosthogEvent('user_logged_out');
|
||||
posthog.reset();
|
||||
|
||||
if (redirect) {
|
||||
goto('/login');
|
||||
goto(resolve('/login', {}));
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user