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

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

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

* chore: cache session request

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

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

* feat: complete change email and email verification

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

* fix ui errors

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

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

* chore: add tailwind prefix to @cio/ui

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

* chore: clean up org sidebar

* chore: persist mode state

* feat: implement order breadcrumbs

* chore: upgrade to better auth 1.4

* chore: address common auth flow bugs and setup app for others to setup locally.
This commit is contained in:
Rotimi Best
2025-11-24 01:00:50 +01:00
committed by GitHub
parent 24741644d6
commit a3924100dd
567 changed files with 22364 additions and 13437 deletions
@@ -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';
@@ -0,0 +1,102 @@
<script>
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 { 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; // 2 minutes
const WAIT_TIME = WAIT_SEC * 1000;
let loading = $state(false);
let isSent = $state(false);
let autoTriggered = $state(false);
let interval;
let countDown = $state(WAIT_SEC);
const open = $derived(Boolean(!$profile.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 {
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;
} catch (error) {
snackbar.error('verify_email_modal.snackbar.error');
} finally {
loading = false;
}
interval = setInterval(() => {
countDown = countDown - 1;
}, 1000);
setTimeout(() => {
isSent = false;
clearInterval(interval);
}, WAIT_TIME);
};
$effect(() => {
if (!open || isSent || loading || autoTriggered) return;
// Only send verification code on load once
autoTriggered = true;
sendVerificationCode();
});
</script>
<Modal {open} isCloseable={false} width="w-4/5" maxWidth="w-[500px]" containerClass="p-4">
<div class="flex flex-col items-center space-y-6 text-center">
<img src="/verify-email.svg" alt="email verification" />
<p class="text-xl">{$t('verify_email_modal.heading')}</p>
<p class="w-[70%] text-sm text-gray-700 dark:text-gray-200">
{$t('verify_email_modal.sent_verification')}
{$profile.email}
{$t('verify_email_modal.to_confirm')}
</p>
<div class="flex flex-col items-center">
<PrimaryButton isDisabled={loading || isSent} className="font-normal" onClick={sendVerificationCode}>
{#if loading}
{$t('verify_email_modal.loading')}
{:else}
{$t('verify_email_modal.resend')}
{/if}
</PrimaryButton>
{#if isSent}
<p class="text-xs text-gray-700">
{$t('verify_email_modal.resend_in')}
{countDown}
{$t('verify_email_modal.seconds')}
</p>
{/if}
</div>
</div>
</Modal>
@@ -0,0 +1,39 @@
<script>
import { Modal } from '$lib/components/Modal';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { currentOrgPath } from '$lib/utils/store/org';
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');
let open = $derived(welcomePopup === 'true' && !!$profile.isEmailVerified);
let isLoading = $state(false);
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} 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>
{$t('welcome_modal.small_team')}
<span class="text-primary-700">{$t('welcome_modal.thank_you')};</span>
{$t('welcome_modal.deeply_appreciate')}
</p>
<img src="/images/welcome-img.svg" alt="A welcome banner" class="my-6 w-full" />
</Modal>
@@ -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'];
@@ -0,0 +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>
<Badge variant="outline" class="ui:text-white bg-blue-500 dark:bg-blue-600 dark:text-black">
{label}
</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,236 @@
<!--
@component
A Progress Bar that hooks to SvelteKit navigation.
The progress bar will show during navigation if loading takes more than `displayThresholdMs`
and will hide when navigation is complete.
https://github.com/prgm-dev/sveltekit-progress-bar
-->
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
interface Props {
// https://github.com/saibotsivad/svelte-progress-bar
id?: string | undefined;
viewTransitionName?: string;
busy?: boolean;
color?: string;
textColorClass?: `text-${string}` | '';
zIndex?: number;
minimum?: number;
maximum?: number;
settleTime?: number;
intervalTime?: number;
stepSizes?: any;
displayThresholdMs?: number;
noNavigationProgress?: boolean;
}
let {
id = undefined,
viewTransitionName = 'sveltekit-progress-bar',
busy = $bindable(false),
color = 'currentColor',
textColorClass = '',
zIndex = 1,
minimum = 0.08,
maximum = 0.994,
settleTime = 700,
intervalTime = 700,
stepSizes = [0, 0.005, 0.01, 0.02],
displayThresholdMs = 150,
noNavigationProgress = false
}: Props = $props();
// Towards the end of the progress bar animation, we want to shorten the increment
// step size, to give it the appearance of slowing down. This indicates to the user
// that progress is still happening, but not as fast as they might like.
const getIncrement = (n: number) => {
if (n >= 0 && n < 0.2) return 0.1;
else if (n >= 0.2 && n < 0.5) return 0.04;
else if (n >= 0.5 && n < 0.8) return 0.02;
else if (n >= 0.8 && n < 0.99) return 0.005;
return 0;
};
// Internal private state.
let running = $state(false);
let updater: ReturnType<typeof setInterval> | null = null;
let completed = $state(false);
let width = $state(0);
// Update the busy prop when the internal `running` variable changes.
// run(() => {
// busy = running;
// });
export const reset = (min = minimum) => {
width = min;
running = true;
};
/**
* Continue the animation of the progress bar from whatever position it is in, using
* a randomized step size to increment.
*/
export const animate = () => {
if (updater) {
// prevent multiple intervals by clearing before making
clearInterval(updater);
}
running = true;
updater = setInterval(() => {
const randomStep = stepSizes[Math.floor(Math.random() * stepSizes.length)] ?? 0;
const step = getIncrement(width) + randomStep;
if (width < maximum) {
width = width + step;
}
if (width > maximum) {
width = maximum;
stop();
}
}, intervalTime);
};
/** Restart the bar at the minimum, and begin the auto-increment progress. */
export const start = (min?: number) => {
reset(min);
animate();
};
/** Stop the progress bar from incrementing, but leave it visible. */
export const stop = () => {
if (updater) {
clearInterval(updater);
}
};
/**
* Moves the progress bar to the fully completed position, wait an appropriate
* amount of time so the user can feel the completion, then hide and reset.
*/
export const complete = (settle = settleTime) => {
if (updater) clearInterval(updater);
if (!running) return;
width = 1;
running = false;
setTimeout(() => {
// complete the bar first
completed = true;
setTimeout(() => {
// after some time (long enough to finish the hide animation) reset it back to 0
completed = false;
width = 0;
}, settle);
}, settle);
};
/** Stop the auto-increment functionality and manually set the width of the progress bar. */
export const setWidthRatio = (widthRatio: typeof width) => {
stop();
width = widthRatio;
completed = false;
running = true;
};
// Primarily used for tests, but can also be used for external monitoring.
export const getState = () => {
return {
width,
running,
completed,
color,
minimum,
maximum,
settleTime,
intervalTime,
stepSizes
};
};
let barStyle = $derived(
(color ? `background-color: ${color};` : '') +
(width && width * 100 ? `width: ${width * 100}%;` : '') +
`z-index: ${zIndex};` +
`view-transition-name: ${viewTransitionName}-bar;`
);
// the box shadow of the leader bar uses `color` to set its shadow color
let leaderColorStyle = $derived(
(color ? `background-color: ${color}; color: ${color};` : '') +
`z-index: ${zIndex + 1};` +
`view-transition-name: ${viewTransitionName}-leader;`
);
let progressBarStartTimeout: ReturnType<typeof setTimeout> | null = null;
beforeNavigate((nav) => {
if (progressBarStartTimeout) {
clearTimeout(progressBarStartTimeout);
progressBarStartTimeout = null;
}
if (noNavigationProgress) return;
if (nav.to?.route.id) {
// Internal navigation.
if (displayThresholdMs > 0) {
// Schedule a display of the progress bar in `displayThresholdMs` milliseconds.
// This is to avoid flickering/flashing when the navigation is fast.
progressBarStartTimeout = setTimeout(() => !noNavigationProgress && start(), displayThresholdMs);
} else start();
nav.complete.catch().finally(() => {
if (progressBarStartTimeout) {
clearTimeout(progressBarStartTimeout);
progressBarStartTimeout = null;
}
complete();
});
}
});
</script>
{#if running || width > 0}
<output
{id}
role="progressbar"
aria-valuenow={width}
aria-valuemin={0}
aria-valuemax={1}
class="svelte-progress-bar {textColorClass}"
class:running
class:svelte-progress-bar-hiding={completed}
style={barStyle}
>
{#if running}
<div class="svelte-progress-bar-leader" style={leaderColorStyle}></div>
{/if}
</output>
{/if}
<style>
.svelte-progress-bar {
position: fixed;
top: 0;
left: 0;
height: 3px;
transition: width 0.21s ease-in-out;
}
.svelte-progress-bar-hiding {
transition: top 0.8s ease;
top: -8px;
}
.svelte-progress-bar-leader {
position: absolute;
top: 0;
right: 0;
height: 5px;
width: 100px;
transform: rotate(2.5deg) translate(0px, -4px);
box-shadow: 0 0 8px;
}
</style>
@@ -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>