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

* feat: new components, deploy storybook

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

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

* chore: add field, card, password components

* feat: improve page component & update AuthUI path

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

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

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

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

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

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

* chore: update organization API and UI components

- Added new API methods for organization plan operations and team management.
- Introduced middleware for API key authentication and organization admin checks.
- Refactored organization-related components to utilize the new API structure.
- Updated various UI components to improve consistency and maintainability.
- Enhanced error handling and validation for organization-related actions.
- Removed deprecated services and streamlined the codebase.
This commit is contained in:
Rotimi Best
2025-12-02 09:54:38 +01:00
committed by GitHub
parent 531516a8f7
commit c7854efae3
350 changed files with 13928 additions and 3895 deletions
@@ -1,13 +1,13 @@
import type { AccountOrg } from '$lib/features/app/types';
import type { Cookies } from '@sveltejs/kit';
import type { CurrentOrg } from '$lib/utils/types/org';
import { OrgApiServer } from '$lib/features/org/api/org.server';
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
import { blockedSubdomain } from '$lib/utils/constants/app';
import { env } from '$env/dynamic/private';
import { getCurrentOrg } from '$lib/utils/services/org';
export interface OrgSiteInfo {
isOrgSite: boolean;
org: CurrentOrg | null;
org: AccountOrg | null;
subdomain: string;
orgSiteName: string;
}
@@ -26,14 +26,14 @@ export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise<OrgSit
// Student dashboard
if (subdomain) {
const org = (await getCurrentOrg(subdomain, true)) || null;
const org = await OrgApiServer.getOrgBySiteName(subdomain);
// Organization by subdomain not found
if (!org) {
return response;
}
response.org = org;
response.org = org as AccountOrg;
response.isOrgSite = true;
response.orgSiteName = subdomain;
response.subdomain = subdomain;
@@ -59,12 +59,14 @@ export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise<OrgSit
// Custom domain
if (isURLCustomDomain(url)) {
response.org = (await getCurrentOrg(url.host, true, true)) || null;
const orgs = await OrgApiServer.getOrgsByCustomDomain(url.host, true);
if (!response.org) {
if (!orgs || orgs.length === 0) {
return response;
}
const org = orgs[0];
response.org = org as AccountOrg;
response.isOrgSite = true;
response.orgSiteName = response.org?.siteName || '';
response.subdomain = subdomain;
@@ -82,7 +84,11 @@ export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise<OrgSit
response.isOrgSite = debugMode || !!subdomain;
response.orgSiteName = debugMode ? _orgSiteName : subdomain;
response.org = (await getCurrentOrg(response.orgSiteName, true)) || null;
if (response.orgSiteName) {
const org = await OrgApiServer.getOrgBySiteName(response.orgSiteName);
response.org = (org as AccountOrg) || null;
}
const shouldDeleteCookie = !response.org && _orgSiteName;
if (shouldDeleteCookie) {
@@ -0,0 +1 @@
// Components exports will be added here when needed
@@ -0,0 +1,133 @@
<script lang="ts">
import * as Table from '@cio/ui/base/table';
import { page as pageStore } from '$app/state';
import { Skeleton } from '@cio/ui/base/skeleton';
import { Search } from '@cio/ui/custom/search';
import * as Pagination from '@cio/ui/base/pagination';
import UsersIcon from '@lucide/svelte/icons/users';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { t } from '$lib/utils/functions/translations';
import { Empty } from '@cio/ui/custom/empty';
import { UpgradeBanner } from '$lib/features/ui';
import { currentOrg, currentOrgMaxAudience } from '$lib/utils/store/org';
import Avatar from '$lib/components/Avatar/index.svelte';
import * as Page from '@cio/ui/base/page';
$effect(() => {
orgApi.getOrgAudience($currentOrg.id);
});
const headers = [
{ key: 'name', value: $t('audience.name') },
{ key: 'email', value: $t('audience.email') },
{ key: 'date_joined', value: $t('audience.date_joined') }
];
let pageSize = $state(5);
let currentPage = $state(1);
let searchValue = $state('');
const filteredRows = $derived(
searchValue
? orgApi.audience.filter((row) =>
Object.values(row).some((val) => String(val).toLowerCase().includes(searchValue.toLowerCase()))
)
: orgApi.audience
);
const totalPages = $derived(Math.ceil(filteredRows.length / pageSize));
const startIndex = $derived((currentPage - 1) * pageSize);
const endIndex = $derived(startIndex + pageSize);
const paginatedRows = $derived(filteredRows.slice(startIndex, endIndex));
$effect(() => {
if (filteredRows.length > 0 && currentPage > totalPages) {
currentPage = 1;
}
});
</script>
{#if orgApi.audience.length >= $currentOrgMaxAudience}
<UpgradeBanner>{$t('audience.upgrade')}</UpgradeBanner>
{/if}
<Page.BodyHeader>
<Search placeholder="Search..." bind:value={searchValue} class="" />
</Page.BodyHeader>
{#if orgApi.audience.length || orgApi.isLoading}
<div class="w-full space-y-4">
<!-- Table -->
{#if orgApi.isLoading}
<div class="space-y-2">
{#each Array(pageSize) as _}
<Skeleton class="h-12 w-full" />
{/each}
</div>
{:else}
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
{#each headers as header}
<Table.Head>{header.value}</Table.Head>
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each paginatedRows as row}
<Table.Row>
<Table.Cell>
<a
href={`${pageStore.url.href}/${row.id}/${$currentOrg.id}`}
class="text-primary-700 flex items-center gap-2 hover:underline"
>
<Avatar src={row.avatar_url} width="w-5" height="h-5" />
{row.name}
</a>
</Table.Cell>
<Table.Cell>{row.email}</Table.Cell>
<Table.Cell>{row.date_joined}</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{/if}
<!-- Pagination -->
<div class="flex items-center justify-between">
<Pagination.Root
count={filteredRows.length}
perPage={pageSize}
page={currentPage}
onPageChange={(page) => (currentPage = page)}
>
{#snippet children({ pages, currentPage: activePage })}
<Pagination.Content>
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type === 'ellipsis'}
<Pagination.Item>
<Pagination.Ellipsis />
</Pagination.Item>
{:else}
<Pagination.Item>
<Pagination.Link {page} isActive={activePage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
</div>
</div>
{:else}
<Empty title={$t('audience.no_audience')} description={$t('audience.manage')} icon={UsersIcon} variant="page" />
{/if}
@@ -0,0 +1,2 @@
export { default as AudiencePage } from './audience.svelte';
export { default as UserAnalyticsPage } from './user-analytics.svelte';
@@ -0,0 +1,206 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { Badge } from '@cio/ui/base/badge';
import UnfoldVerticalIcon from '@lucide/svelte/icons/unfold-vertical';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import ChartLineIcon from '@lucide/svelte/icons/chart-line';
import Progress from '$lib/components/Progress/index.svelte';
import { ActivityCard, HeroProfileCard, LoadingPage } from '$lib/components/Analytics';
import { t } from '$lib/utils/functions/translations';
import { currentOrgPath } from '$lib/utils/store/org';
import { snackbar } from '$lib/components/Snackbar/store';
import { getAccessToken } from '$lib/utils/functions/supabase';
import type { UserAnalytics } from '$lib/utils/types/analytics';
let { data } = $props();
let userAnalytics: UserAnalytics | undefined = $state();
let courseFilter = $state('all');
function toggleCourseFilter(filter: 'completed' | 'incomplete') {
if (courseFilter === filter) {
courseFilter = 'all';
} else {
courseFilter = filter;
}
}
async function fetchUserAnalytics() {
const accessToken = await getAccessToken();
const response = await fetch('/api/analytics/user', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: accessToken
},
body: JSON.stringify(data)
});
if (!response.ok) {
console.error(response);
snackbar.error('Failed to fetch analytics data');
}
userAnalytics = (await response.json()) as UserAnalytics;
}
onMount(() => {
fetchUserAnalytics();
});
let userMetrics = $derived([
{
icon: BookOpenIcon,
title: $t('analytics.enrolled_courses'),
description: $t('analytics.enrolled_courses_description'),
percentage: userAnalytics?.courses?.length || 0,
hidePercentage: true
},
{
icon: ChartLineIcon,
title: $t('analytics.overall_course_progress'),
description: $t('analytics.overall_course_progress_description'),
percentage: userAnalytics?.overallCourseProgress || 0
},
{
icon: UnfoldVerticalIcon,
title: $t('analytics.total_average_grade'),
description: $t('analytics.total_average_grade_description'),
percentage: userAnalytics?.overallAverageGrade || 0
}
]);
let completedCourses = $derived(
userAnalytics?.courses?.filter((course) => course.lessons_count === course.lessons_completed)?.length
);
let incompleteCourses = $derived(
userAnalytics?.courses?.filter((course) => course.lessons_count !== course.lessons_completed)?.length
);
let filteredCourses = $derived(
userAnalytics?.courses?.filter((course) => {
if (courseFilter === 'all') {
return true;
}
return (course.lessons_count === course.lessons_completed) === (courseFilter === 'completed');
})
);
</script>
{#if userAnalytics}
<div class="p-5">
<a class="text-md flex items-center text-gray-500 dark:text-white" href={`${$currentOrgPath}/audience`}>
<ArrowLeftIcon size={16} />
{$t('community.ask.go_back')}
</a>
</div>
<div class="px-5 py-1">
<HeroProfileCard user={userAnalytics!.user} />
<div class="mt-5 px-0">
<div class="mt-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each userMetrics as activity}
<ActivityCard {activity} />
{/each}
</div>
</div>
<div class="mt-5 rounded-md border p-3 md:p-5 dark:border-neutral-600">
<h3 class="text-2xl">
{$t('analytics.courses')}
</h3>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<p class="text-sm font-medium text-gray-600 dark:text-gray-200">
{$t('analytics.progress')}
</p>
<p class="text-sm font-medium text-gray-600 dark:text-gray-200">
{$t('analytics.progress_description', {
value: completedCourses,
total: userAnalytics!.courses.length
})}
</p>
</div>
<Progress value={userAnalytics!.overallCourseProgress} />
<div class="flex items-center justify-between">
<Badge
type={courseFilter === 'incomplete' ? 'secondary' : 'outline'}
class="text-yellow-700 dark:text-yellow-500"
onclick={() => toggleCourseFilter('incomplete')}
>
{incompleteCourses}
{$t('analytics.incomplete')}
</Badge>
<Badge
type={courseFilter === 'completed' ? 'secondary' : 'outline'}
class="text-green-700 dark:text-green-500"
onclick={() => toggleCourseFilter('completed')}
>
{completedCourses}
{$t('analytics.complete')}
</Badge>
</div>
</div>
{#each filteredCourses as course, index}
{#key index}
<div
class={`mt-5 w-full rounded-md border border-gray-200 p-5 ${
course.lessons_count === course.lessons_completed
? 'border-green-200 bg-green-50 dark:bg-green-100'
: 'border-yellow-200 bg-yellow-50 dark:bg-yellow-100'
}`}
transition:fade={{ duration: 300 }}
>
<div class="flex items-center justify-between gap-4">
<div class="flex w-4/5 items-center gap-4">
<img
src={course.logo || '/images/classroomio-course-img-template.jpg'}
alt={course.title}
class="h-20 w-24 rounded-md"
/>
<div class="mb-4 gap-4">
<a href={`/courses/${course.id}`}>
<p class="text-lg font-semibold text-gray-600">
{course.title}
</p>
</a>
<p class="line-clamp-2 text-sm text-gray-600">
{course.description}
</p>
</div>
</div>
<Badge
class={`${
course.lessons_count === course.lessons_completed
? 'bg-green-200 text-green-700'
: 'bg-yellow-200 text-yellow-700'
}`}
>
{course.lessons_count === course.lessons_completed
? $t('analytics.completed')
: $t('analytics.incomplete')}
</Badge>
</div>
<div class="flex w-full items-center gap-1">
<Progress value={course.progress_percentage} />
<p>{course.progress_percentage}%</p>
</div>
</div>
{/key}
{/each}
</div>
</div>
{:else}
<LoadingPage />
{/if}
@@ -4,13 +4,12 @@ import { ZChangeEmail, ZProfileUpdateForm } from '@cio/utils/validation/account'
import type { TProfileUpdateForm as TProfileUpdateFormSchema } from '@cio/utils/validation/account';
import { authClient } from '$lib/utils/services/auth/client';
import generateUUID from '$lib/utils/functions/generateUUID';
import { handleLocaleChange } from '$lib/utils/functions/translations';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { profile as profileStore } from '$lib/utils/store/user';
import { snackbar } from '$lib/components/Snackbar/store';
import { supabase } from '$lib/utils/functions/supabase';
import { t } from '$lib/utils/functions/translations';
import { uploadImage } from '$lib/utils/services/upload';
export interface TProfileUpdateForm extends Omit<TProfileUpdateFormSchema, 'avatarUrl'> {
avatar?: string | File | undefined;
@@ -37,24 +36,6 @@ export class ProfileApi extends BaseApiWithErrors {
return true;
}
private async uploadAvatar(avatar: File): Promise<string | undefined> {
const filename = `user/${generateUUID()}.webp`;
const { data: uploadData, error: uploadError } = await supabase.storage.from('avatars').upload(filename, avatar, {
cacheControl: '3600',
upsert: false
});
if (uploadError) throw uploadError;
if (uploadData) {
const { data: urlData } = await supabase.storage.from('avatars').getPublicUrl(filename);
return urlData.publicUrl;
}
return undefined;
}
private async updateUserInfo(
fields: TProfileUpdateForm,
currentProfile: TProfile,
@@ -124,11 +105,11 @@ export class ProfileApi extends BaseApiWithErrors {
const message = error instanceof Error ? error.message : `${error}`;
if (message.includes('profile_username_key')) {
this.errors.username = 'snackbar.lms.error.username_exists';
this.errors.username = t.get('snackbar.lms.error.username_exists');
snackbar.error('snackbar.lms.error.username_exists');
} else {
this.errors.general = message;
snackbar.error(`snackbar.lms.error.update: ${message}`);
snackbar.error(`${t.get('snackbar.update_failed')}: ${message}`);
}
}
@@ -145,7 +126,7 @@ export class ProfileApi extends BaseApiWithErrors {
// Handle avatar upload if provided
let avatarUrl: string | undefined;
if (fields.avatar instanceof File) {
avatarUrl = await this.uploadAvatar(fields.avatar);
avatarUrl = await uploadImage(fields.avatar);
}
// Update user info via Better Auth (name and image)
@@ -185,7 +166,7 @@ export class ProfileApi extends BaseApiWithErrors {
const { error } = await authClient.changeEmail({
newEmail: fields.newEmail,
callbackURL: window.location.origin
callbackURL: fields.callbackURL
});
if (error) throw new Error(error.message);
@@ -197,7 +178,7 @@ export class ProfileApi extends BaseApiWithErrors {
const message = error instanceof Error ? error.message : `${error}`;
this.errors.email = message;
snackbar.error(`${t.get('snackbar.lms.error.update')} ${message}`);
snackbar.error(`${t.get('snackbar.update_failed')}: ${message}`);
} finally {
this.isLoading = false;
}
@@ -0,0 +1,22 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import { goto } from '$app/navigation';
import { currentOrgPath } from '$lib/utils/store/org';
import { Button } from '@cio/ui/base/button';
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
import PlusIcon from '@lucide/svelte/icons/plus';
function askCommunity() {
goto(`${$currentOrgPath}/community/ask`);
}
</script>
{#if isMobileStore.current}
<Button onclick={askCommunity}>
<PlusIcon size={16} />
</Button>
{:else}
<Button onclick={askCommunity}>
{$t('community.ask_button')}
</Button>
{/if}
@@ -0,0 +1,22 @@
<script lang="ts">
import { Skeleton } from '@cio/ui/base/skeleton';
</script>
<div class="relative flex h-[70px] w-full rounded-md border p-2 transition ease-in-out dark:border-neutral-600">
<div class="mr-2 flex w-10 flex-col justify-evenly gap-2 rounded-md px-1">
<Skeleton class="h-2/6 w-full rounded-sm" />
<Skeleton class="h-2/6 w-full rounded-sm" />
<Skeleton class="h-2/6 w-full rounded-sm" />
</div>
<div class="flex w-9/12 flex-col gap-2 rounded-md">
<!-- first text -->
<Skeleton class="h-4 w-3/6 rounded-sm" />
<!-- second text -->
<Skeleton class="h-4 w-1/4 rounded-sm" />
</div>
<div class="ml-auto w-10 flex-col justify-evenly rounded-md px-1">
<Skeleton class="h-4 w-full rounded-sm" />
</div>
</div>
@@ -0,0 +1,54 @@
<script lang="ts">
import Modal from '$lib/components/Modal/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { t } from '$lib/utils/functions/translations';
interface Props {
onDelete?: any;
onCancel?: any;
open?: boolean;
isQuestion?: boolean;
isDeleting?: boolean;
}
let {
onDelete = () => {},
onCancel = () => {},
open = $bindable(false),
isQuestion = false,
isDeleting = false
}: Props = $props();
</script>
<Modal
onClose={() => (open = false)}
bind:open
width="w-96"
modalHeading="{$t('community.delete.title')} {isQuestion
? $t('community.delete.question')
: $t('community.delete.comment')}"
>
<div>
<p class="mt-0 text-base dark:text-white">
{$t('community.delete.sure')}
{isQuestion ? $t('community.delete.question') : $t('community.delete.comment')}?
</p>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.OUTLINED}
label={$t('community.delete.no')}
onClick={onCancel}
/>
<PrimaryButton
className="px-6 py-3"
variant={VARIANTS.CONTAINED}
label={$t('community.delete.yes')}
onClick={onDelete}
isLoading={isDeleting}
/>
</div>
</div>
</Modal>
@@ -0,0 +1,3 @@
export { default as CommunityListLoader } from './community-list-loader.svelte';
export { default as CommunityDeleteModal } from './delete-modal.svelte';
export { default as AskCommunityButton } from './ask-community-button.svelte';
@@ -0,0 +1,183 @@
<script lang="ts">
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import * as Select from '@cio/ui/base/select';
import { Search } from '@cio/ui/custom/search';
import MessageCirclePlusIcon from '@lucide/svelte/icons/message-circle-plus';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import { courses } from '$lib/features/course/utils/store';
import { calDateDiff } from '$lib/utils/functions/date';
import { supabase } from '$lib/utils/functions/supabase';
import { fetchCourses } from '$lib/utils/services/courses';
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
import { CommunityListLoader } from '../components';
import Vote from '$lib/components/Vote/index.svelte';
import CoursesEmptyIcon from '$lib/components/Icons/CoursesEmptyIcon.svelte';
import { Empty } from '@cio/ui/custom/empty';
import * as Page from '@cio/ui/base/page';
import { AskCommunityButton } from '../components';
interface Props {
isLMS?: boolean;
}
let { isLMS = false }: Props = $props();
let isLoading = $state(false);
let discussions = $state([]);
let searchValue = $state('');
let allCourses: any[] = $state([]);
let selectedId = $state('');
function fetchCommunityQuestions(orgId?: string, profileId?: string) {
if (!orgId || !profileId) return;
untrack(async () => {
isLoading = true;
if ($courses.length) {
allCourses = [...$courses];
} else {
const courseResult = (await fetchCourses(profileId, orgId)) || { allCourses: [] };
allCourses = courseResult.allCourses;
}
const courseIds = allCourses.map((course) => course.id);
const courseIdsFilter = `(${courseIds.join(',')})`;
const { data, error } = await supabase
.from('community_question')
.select(
`
organization_id,
course_id,
title,
votes,
created_at,
slug,
comments:community_answer(count),
author:profile(
fullname
),
course!inner (
title
)
`
)
.filter('course_id', 'in', courseIdsFilter)
.order('created_at', { ascending: false });
console.log('data', data);
console.log('error', error);
isLoading = false;
if (error) {
console.error('Error loading community', error);
return goto(isLMS ? '/lms' : $currentOrgPath);
}
discussions =
data?.map((discussion) => ({
title: discussion.title,
courseId: discussion.course_id,
courseTitle: discussion.course?.title,
slug: discussion.slug,
author: discussion?.author?.fullname,
comments: discussion.comments?.[0]?.count || 0,
votes: discussion.votes,
createdAt: calDateDiff(discussion.created_at)
})) || [];
});
}
$effect(() => {
fetchCommunityQuestions($currentOrg.id, $profile.id);
});
let filteredDiscussions = $derived(
discussions.filter(
(discussion) =>
discussion.title.toLowerCase().includes(searchValue.toLowerCase()) &&
(!selectedId || discussion.courseId === selectedId)
)
);
</script>
<Page.BodyHeader>
<Search placeholder={$t('community.find_question')} bind:value={searchValue} />
<Select.Root type="single" bind:value={selectedId}>
<Select.Trigger class="w-full bg-gray-100 dark:bg-neutral-800">
<p class="truncate">
{selectedId ? allCourses.find((course) => course.id === selectedId)?.title : $t('community.all')}
</p>
</Select.Trigger>
<Select.Content>
<Select.Item value="">{$t('community.all')}</Select.Item>
{#each allCourses as course}
<Select.Item value={course.id}>{course.title}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Page.BodyHeader>
{#if isLoading}
<CommunityListLoader />
<CommunityListLoader />
<CommunityListLoader />
<CommunityListLoader />
{:else}
{#each filteredDiscussions as discussion}
<div
class="border-c m-auto my-4 flex flex-wrap items-center justify-center rounded bg-gray-100 lg:justify-start dark:bg-neutral-800"
>
<div class="border-bottom-c flex w-full p-3">
<Vote value={discussion.votes} />
<div class="flex flex-col gap-y-0.5 text-sm">
<h4 class="mt-0">
<a class="text-black dark:text-white" href="{isLMS ? '/lms' : $currentOrgPath}/community/{discussion.slug}">
{discussion.title}
</a>
</h4>
<span class="text-gray-600 dark:text-white">
{discussion.author} asked {discussion.createdAt}
</span>
<a class="m-0" href="/courses/{discussion.courseId}">
<span class="text-primary-200 text-primary-700 p-0 text-xs dark:text-black">
#{discussion.courseTitle}
</span>
</a>
</div>
<div class="flex-grow"></div>
<div class="flex items-center">
<MessageCirclePlusIcon size={16} />
<span class="ml-1">{discussion.comments}</span>
</div>
</div>
</div>
{:else}
<Empty
title={$t('community.no_question')}
description={$t('community.ask_a_question')}
icon={MessageSquareMoreIcon}
variant="page"
>
<AskCommunityButton />
</Empty>
{/each}
{/if}
<style>
h4 {
font-size: 16px;
font-weight: 900;
word-break: break-word;
overflow-wrap: break-word;
margin: 0;
}
</style>
@@ -0,0 +1 @@
export { default as CommunityPage } from './community.svelte';
@@ -0,0 +1,23 @@
<script lang="ts">
import { Skeleton } from '@cio/ui/base/skeleton';
</script>
<div class="mb-4 mr-4 h-[370px] w-72 rounded-md border p-2 transition ease-in-out">
<Skeleton class="h-[45%] w-full" />
<div class="flex h-[50%] flex-col justify-between pb-2 pt-4">
<!-- Title -->
<Skeleton class="h-4 w-2/4" />
<!-- Description -->
<div class="mb-6 mt-4 flex flex-col gap-2">
<Skeleton class=" h-4 w-full" />
<Skeleton class=" h-4 w-full" />
<Skeleton class=" h-4 w-full" />
</div>
<div class="flex justify-between">
<Skeleton class="h-4 w-1/4" />
<Skeleton class="h-4 w-1/4" />
</div>
</div>
</div>
@@ -0,0 +1,263 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Badge } from '@cio/ui/base/badge';
import UserIcon from '@lucide/svelte/icons/user';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import CircleDotIcon from '@lucide/svelte/icons/circle-dot';
import TrendingUpIcon from '@lucide/svelte/icons/trending-up';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import ImageRenderer from '$lib/components/Org/ImageRenderer.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { COURSE_TYPE } from '$lib/utils/types';
import { t } from '$lib/utils/functions/translations';
import { calcCourseDiscount } from '$lib/utils/functions/course';
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
import { copyCourseModal, deleteCourseModal } from '$lib/features/course/utils/store';
interface Props {
bannerImage: string | undefined;
id?: string;
slug?: string;
title?: string;
description?: string;
isPublished?: boolean;
totalLessons?: number;
totalStudents?: number;
currency?: string;
isOnLandingPage?: boolean;
isLMS?: boolean;
isExplore?: boolean;
progressRate?: number;
type: COURSE_TYPE;
pricingData?: {
cost: number;
currency?: string;
showDiscount?: boolean;
discount?: number;
};
}
let {
bannerImage,
id = '',
slug = '',
title = '',
description = '',
isPublished = false,
totalLessons = 0,
totalStudents = 0,
currency = 'USD',
isOnLandingPage = false,
isLMS = false,
isExplore = false,
progressRate = 45,
type,
pricingData = {
cost: 0
}
}: Props = $props();
let formatter = $derived(getCurrencyFormatter(currency));
function handleCloneCourse() {
$copyCourseModal.open = true;
$copyCourseModal.id = id;
$copyCourseModal.title = title;
$copyCourseModal.description = description;
}
function handleShareCourse() {
goto(`/courses/${id}/settings#share`);
}
function handleInvite() {
goto(`/courses/${id}/people?add=true`);
}
function handleDeleteCourse() {
$deleteCourseModal.open = true;
$deleteCourseModal.id = id;
$deleteCourseModal.title = title;
}
const COURSE_TAG: Record<
string,
{
style: string;
label: string;
icon: any;
iconStyle?: string;
}
> = {
[COURSE_TYPE.LIVE_CLASS]: {
style: '',
label: $t('course.navItem.settings.live_class'),
icon: CircleDotIcon,
iconStyle: 'custom text-red-700'
},
[COURSE_TYPE.SELF_PACED]: {
style: '',
label: $t('course.navItem.settings.self_paced'),
icon: UserIcon,
iconStyle: 'custom text-primary-700'
},
SPECIALIZATION: {
style: '',
label: $t('specialization.course_tag'),
icon: TrendingUpIcon
}
};
let cost = $derived(calcCourseDiscount(pricingData.discount, pricingData.cost ?? 0, !!pricingData.showDiscount));
let courseUrl = $derived(
isOnLandingPage || isExplore ? `/course/${slug}` : `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`
);
</script>
<div
role="button"
tabindex="0"
onclick={() => {
goto(courseUrl);
}}
onkeydown={(e) => {
if (e.key === 'Enter') {
goto(courseUrl);
}
}}
class="border-gray group relative w-full max-w-[320px] rounded border text-black dark:border-neutral-600"
>
<div class="p-4">
<div class="relative mb-5">
{#if !isLMS && !isOnLandingPage}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="absolute right-2 top-2 z-40 flex items-center justify-center rounded-full bg-gray-200 p-2 opacity-0 transition-all delay-150 duration-200 ease-in-out group-hover:opacity-100 dark:bg-neutral-800"
onclick={(e) => e.stopPropagation()}
>
<EllipsisVerticalIcon size={16} />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={handleCloneCourse}>
{$t('courses.course_card.context_menu.clone')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleShareCourse}>
{$t('courses.course_card.context_menu.share')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleInvite}>
{$t('courses.course_card.context_menu.invite')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item class="text-red-600" onclick={handleDeleteCourse}>
{$t('courses.course_card.context_menu.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
<ImageRenderer
src={bannerImage}
alt="Banner Image"
className="relative h-[200px] w-full rounded dark:border dark:border-neutral-600"
/>
{#if type}
{@const tag = COURSE_TAG[type]}
<span
class="bg-primary-50 absolute bottom-2 left-2 z-10 flex items-center gap-1 rounded-sm p-1 font-mono text-xs capitalize"
>
<tag.icon class={tag.iconStyle} />
{tag.label}
</span>
{/if}
</div>
<h3 class="title text-xl dark:text-white">{title}</h3>
<p class="description mt-2 text-sm text-gray-500 dark:text-gray-300">
{description}
</p>
</div>
<div
class="border-gray flex justify-between border border-b-0 border-l-0 border-r-0 px-4 py-2 dark:border-neutral-600 {isLMS &&
'items-center'}"
>
<div>
<p class="text-xs {!isLMS && 'pl-2'} dark:text-white">
{totalLessons}
{$t('courses.course_card.lessons_number')}
</p>
<p class="py-2 text-xs">
{#if isOnLandingPage}
<span class="px-2">
{#if !cost}
{$t('course.navItem.landing_page.pricing_section.free')}
{:else if pricingData.showDiscount}
{formatter.format(cost)}
<span class="line-through">
{formatter?.format(pricingData?.cost)}
</span>
{:else}
{formatter.format(cost)}
{/if}
</span>
{:else if isLMS}
{#if !isExplore}
<div class="flex items-center gap-2">
<div class=" relative h-1 w-[50px] bg-[#EAEAEA]">
<div style="width:{progressRate}%" class="bg-primary-700 absolute left-0 top-0 h-full"></div>
</div>
<p class="text-xs text-[#656565] dark:text-white">{progressRate}%</p>
</div>
{/if}
{:else}
<Badge variant={isPublished ? 'default' : 'secondary'}>
{#if isPublished}
{$t('courses.course_card.published')}
{:else}
{$t('courses.course_card.unpublished')}
{/if}
</Badge>
{/if}
</p>
</div>
{#if isLMS}
<PrimaryButton
label={isExplore ? $t('courses.course_card.learn_more') : $t('courses.course_card.continue_course')}
variant={VARIANTS.OUTLINED}
className="rounded-none"
/>
{:else if !isOnLandingPage}
<div class="flex flex-col justify-between">
<p class="pl-2 text-xs dark:text-white">
{totalStudents}
{$t('courses.course_card.students')}
</p>
<div></div>
</div>
{/if}
</div>
</div>
<style>
.title,
.description {
height: 42px;
line-height: 20px;
display: -webkit-box;
-webkit-box-orient: vertical;
-moz-box-orient: vertical;
-ms-box-orient: vertical;
box-orient: vertical;
-webkit-line-clamp: 2;
-moz-line-clamp: 2;
-ms-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}
</style>
@@ -0,0 +1,98 @@
<script lang="ts">
import { preventDefault } from '$lib/utils/functions/svelte';
import { goto } from '$app/navigation';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import Modal from '$lib/components/Modal/index.svelte';
import { snackbar } from '$lib/components/Snackbar/store';
import TextField from '$lib/components/Form/TextField.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import { copyCourseModalInitialState, copyCourseModal } from '$lib/features/course/utils/store';
import { classroomio } from '$lib/utils/services/api';
import generateSlug from '$lib/utils/functions/generateSlug';
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
// clone, show spinner and redirect to new course
async function createCourse() {
// Prevent double submission (Option 1: Early Return Guard)
if ($copyCourseModal.isSaving) return;
console.log('Create course');
$copyCourseModal.isSaving = true;
try {
// Generate unique slug from the title
const slug = generateSlug($copyCourseModal.title);
const response = await classroomio.course.clone.$post({
json: {
id: $copyCourseModal.id,
title: $copyCourseModal.title,
description: $copyCourseModal.description,
slug,
organizationId: $currentOrg.id
}
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || $t('courses.copy_course.error.failed_to_clone'));
}
// Option 3: Close modal and reset state before navigation
copyCourseModal.set(copyCourseModalInitialState);
// Navigate to the new course
goto(`/courses/${result.course.id}`);
$copyCourseModal.open = false;
} catch (error) {
console.error(error);
// IIFI of snackbar to Show notification error
(() => {
snackbar.error();
})();
// Stop loader
$copyCourseModal.isSaving = false;
}
}
</script>
<Modal
onClose={() => ($copyCourseModal.open = false)}
bind:open={$copyCourseModal.open}
width="w-96"
modalHeading={$t('courses.copy_course.title')}
>
<form onsubmit={preventDefault(createCourse)}>
<TextField
label={$t('courses.copy_course.course_name_label')}
bind:value={$copyCourseModal.title}
autoFocus={true}
placeholder={$t('courses.copy_course.course_name_placeholder')}
className="mb-4"
isRequired={true}
autoComplete={false}
/>
<TextArea
label={$t('courses.copy_course.course_description_label')}
bind:value={$copyCourseModal.description}
placeholder={$t('courses.copy_course.course_description_placeholder')}
className="mb-4"
rows={4}
/>
<div class="mt-5 flex flex-row-reverse items-center">
<PrimaryButton
className="px-6 py-3"
label={$t('courses.copy_course.create_button')}
type="submit"
isLoading={$copyCourseModal.isSaving}
/>
</div>
</form>
</Modal>
@@ -0,0 +1,28 @@
<script lang="ts">
import { Button, type ButtonProps } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import { isOrgAdmin } from '$lib/utils/store/org';
import PlusIcon from '@lucide/svelte/icons/plus';
import { goto } from '$app/navigation';
import { currentOrgPath } from '$lib/utils/store/org';
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
let {
variant,
isResponsive = false
}: ButtonProps & {
isResponsive?: boolean;
} = $props();
function onClick() {
goto(`${$currentOrgPath}/courses?create=true`);
}
</script>
{#if isResponsive && isMobileStore.current}
<Button variant="outline" size="icon" disabled={!$isOrgAdmin} onclick={onClick}>
<PlusIcon size={16} />
</Button>
{:else}
<Button {variant} onclick={onClick} disabled={!$isOrgAdmin}>{$t('courses.heading_button')}</Button>
{/if}
@@ -0,0 +1,7 @@
export { default as CourseCard } from './card.svelte';
export { default as CourseCardLoader } from './card-loader.svelte';
export { default as CopyCourseModal } from './copy-course-modal.svelte';
export { default as CreateCourseButton } from './create-course-button.svelte';
export { default as CourseList } from './list.svelte';
export { default as CourseListLoader } from './list-loader.svelte';
export { default as NewCourseModal } from './new-course-modal.svelte';
@@ -0,0 +1,7 @@
<script>
import { Skeleton } from '@cio/ui/base/skeleton';
</script>
<div class="my-2">
<Skeleton class="h-4 w-full" />
</div>
@@ -0,0 +1,100 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Badge } from '@cio/ui/base/badge';
import * as Table from '@cio/ui/base/table';
import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
import { t } from '$lib/utils/functions/translations';
interface Props {
id?: string;
title?: string;
type?: string;
description?: string;
isPublished?: boolean;
totalLessons?: number;
totalStudents?: number;
}
let {
id = '',
title = '',
type = '',
description = '',
isPublished = false,
totalLessons = 0,
totalStudents = 0
}: Props = $props();
function handleCloneCourse(e) {
e.stopPropagation();
// TODO: Clone course functionality
alert('WIP: Clone course');
}
function handleShareCourse(e) {
e.stopPropagation();
// TODO: Share course functionality
alert('WIP: Share course');
}
function handleInvite(e) {
e.stopPropagation();
// TODO: Invite functionality
alert('WIP: Invite people to course');
}
function handleDeleteCourse(e) {
e.stopPropagation();
// TODO: Delete course functionality
alert('WIP: Delete course');
}
</script>
<Table.Row class="cursor-pointer" onclick={() => goto(`/courses/${id}`)}>
<Table.Cell class="truncate"><p class="font-semibold">{title}</p></Table.Cell>
<Table.Cell class="truncate">
<p>{description}</p>
</Table.Cell>
{#if !isMobileStore.current}
<Table.Cell class="truncate">{type}</Table.Cell>
<Table.Cell>{totalLessons}</Table.Cell>
<Table.Cell>{totalStudents}</Table.Cell>
<Table.Cell>
<Badge variant={isPublished ? 'default' : 'secondary'}>
{#if isPublished}
{$t('courses.course_card.published')}
{:else}
{$t('courses.course_card.unpublished')}
{/if}
</Badge>
</Table.Cell>
{/if}
<Table.Cell class="text-center">
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="flex items-center justify-center rounded-md p-1 hover:bg-gray-100"
onclick={(e) => e.stopPropagation()}
>
<EllipsisVerticalIcon size={16} />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={handleCloneCourse}>
{$t('courses.course_card.context_menu.clone')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleShareCourse}>
{$t('courses.course_card.context_menu.share')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleInvite}>
{$t('courses.course_card.context_menu.invite')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item class="text-red-600" onclick={handleDeleteCourse}>
{$t('courses.course_card.context_menu.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
@@ -0,0 +1,234 @@
<script lang="ts">
import { preventDefault } from '$lib/utils/functions/svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { ComingSoon } from '$lib/features/ui';
import { validateForm } from '../utils/functions';
import { courses, createCourseModal } from '../utils/store';
import TextArea from '$lib/components/Form/TextArea.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import Modal from '$lib/components/Modal/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { ROLE } from '@cio/utils/constants';
import { supabase } from '$lib/utils/functions/supabase';
import { t } from '$lib/utils/functions/translations';
import { addDefaultNewsFeed, addGroupMember } from '$lib/utils/services/courses';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { COURSE_TYPE, COURSE_VERSION } from '$lib/utils/types';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
let isLoading = $state(false);
let errors = $state({
title: '',
description: ''
});
let step = $state(0);
const options = [
{
title: $t('new_course_modal.live_class_label'),
subtitle: $t('new_course_modal.live_class_subtitle'),
type: COURSE_TYPE.LIVE_CLASS,
isDisabled: false
},
{
title: $t('new_course_modal.self_paced_label'),
subtitle: $t('new_course_modal.self_paced_subtitle'),
type: COURSE_TYPE.SELF_PACED,
isDisabled: false
}
];
let type = $state(options[0].type);
function onClose(redirectTo) {
goto(redirectTo);
createCourseModal.update(() => ({
title: '',
description: '',
type: '',
emails: '',
tutors: '',
students: ''
}));
}
async function createCourse() {
isLoading = true;
const { hasError, fieldErrors } = validateForm($createCourseModal);
errors = fieldErrors;
if (hasError) return;
const { title, description } = $createCourseModal;
// 1. Create group
const { data: newGroup } = await supabase
.from('group')
.insert({ name: title, description, organization_id: $currentOrg.id })
.select();
console.log(`newGroup`, newGroup);
if (!newGroup) return;
const { id: group_id } = newGroup[0];
// 2. Create course with group_id
const { data: newCourseData } = await supabase
.from('course')
.insert({
title,
description,
type: type,
version: COURSE_VERSION.V2,
group_id
})
.select();
console.log(`newCourse data`, newCourseData);
if (!newCourseData) return;
const newCourse = newCourseData[0];
courses.update((_courses) => [..._courses, newCourse]);
capturePosthogEvent('course_created', {
course_id: newCourse.id,
course_title: newCourse.title,
course_description: newCourse.description,
organization_id: $currentOrg.id,
organization_name: $currentOrg.name,
user_id: $profile.id,
user_email: $profile.email
});
// 3. Add group members
const { data } = await addGroupMember({
profile_id: $profile.id,
email: $profile.email,
group_id,
role_id: ROLE.TUTOR
});
// 4. Add default news feed.
if (Array.isArray(data) && data.length) {
const { id: authorId } = data[0];
console.log('Add news feed into course');
await addDefaultNewsFeed({
content: `<h2>Welcome to this course 🎉&nbsp;</h2>
<p>Thank you for joining this course and I hope you get the best out of it.</p>`,
course_id: newCourse.id,
is_pinned: true,
author_id: authorId
});
}
onClose(`/courses/${newCourse.id}`);
isLoading = false;
}
let open = $derived(new URLSearchParams(page.url.search).get('create') === 'true');
</script>
<svelte:head>
<title>Create a new course</title>
</svelte:head>
<Modal
onClose={() => onClose(page.url.pathname)}
bind:open
width="w-4/5 md:w-2/5 md:min-w-[600px]"
containerClass="max-w-2xl mx-auto"
modalHeading={$t('courses.new_course_modal.heading')}
>
{#if step === 0}
<div>
<h2 class="my-5 text-xl font-medium">
{$t('courses.new_course_modal.type_selector_title')}
</h2>
<div class="my-8 flex flex-col items-center justify-evenly gap-4 md:flex-row">
{#each options as option}
<button
class="w-11/12 rounded-md border-2 p-5 md:h-60 md:w-[261px] dark:bg-neutral-700 {option.type === type
? 'border-primary-400'
: `border-gray-200 dark:border-neutral-600 ${
!option.isDisabled && 'hover:scale-95'
}`} flex flex-col {option.isDisabled && 'cursor-not-allowed opacity-60'} transition-all ease-in-out"
type="button"
onclick={!option.isDisabled ? () => (type = option.type) : undefined}
>
<div class="flex h-[70%] w-full flex-row-reverse">
<CircleCheckIcon size={16} filled={option.type === type} />
</div>
<div>
<p class="flex items-center text-start">
<span class="mr-2 text-sm">{option.title}</span>
{#if option.isDisabled}
<ComingSoon />
{/if}
</p>
<p class="text-start text-xs font-light">{option.subtitle}</p>
</div>
</button>
{/each}
</div>
<div class="mt-8 flex flex-row-reverse items-center">
<PrimaryButton
className="px-6 py-3"
label={$t('courses.new_course_modal.next')}
onClick={() => (step = 1)}
isDisabled={!type}
/>
</div>
</div>
{:else}
<form onsubmit={preventDefault(createCourse)}>
<div class="mb-4 flex items-end space-x-2">
<TextField
label={$t('courses.new_course_modal.course_name')}
bind:value={$createCourseModal.title}
placeholder={$t('courses.new_course_modal.course_name_placeholder')}
className="w-full "
isRequired={true}
errorMessage={errors.title}
autoComplete={false}
/>
</div>
<TextArea
label={$t('courses.new_course_modal.short_description')}
bind:value={$createCourseModal.description}
rows={4}
placeholder={$t('courses.new_course_modal.short_description_placeholder')}
className="mb-4"
isRequired={true}
errorMessage={errors.description}
isAIEnabled={true}
initAIPrompt="Write a 30 word description for a course titled: {$createCourseModal.title}"
/>
<div class="mt-5 flex items-center justify-between">
<PrimaryButton
className="px-6 py-3"
label={$t('courses.new_course_modal.back')}
variant={VARIANTS.OUTLINED}
onClick={() => (step = 0)}
/>
<PrimaryButton
className="px-6 py-3"
label={$t('courses.new_course_modal.button')}
type="submit"
isDisabled={isLoading}
{isLoading}
/>
</div>
</form>
{/if}
</Modal>
@@ -0,0 +1,200 @@
<script lang="ts">
import * as Table from '@cio/ui/base/table';
import { Empty } from '@cio/ui/custom/empty';
import { Search } from '@cio/ui/custom/search';
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
import * as Page from '@cio/ui/base/page';
import * as Select from '@cio/ui/base/select';
import GridIcon from '@lucide/svelte/icons/grid-2x2';
import ListIcon from '@lucide/svelte/icons/list';
import { IconButton } from '$lib/components/IconButton';
import {
CourseCard,
CourseCardLoader,
CreateCourseButton,
CourseList,
CopyCourseModal,
NewCourseModal
} from '$lib/features/course/components';
import DeleteModal from '$lib/components/Modal/DeleteModal.svelte';
import type { Course } from '$lib/utils/types';
import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { deleteCourse } from '$lib/utils/services/courses';
import {
deleteCourseModal,
deleteCourseModalInitialState,
courses as coursesStore,
courseMetaDeta
} from '../utils/store';
import { browser } from '$app/environment';
interface Props {
courses?: Course[];
emptyTitle?: any;
emptyDescription?: any;
isExplore?: boolean;
searchValue?: string;
selectedId?: string;
}
let {
courses = [],
emptyTitle = $t('courses.course_card.empty_title'),
emptyDescription = $t('courses.course_card.empty_description'),
isExplore = false,
searchValue = $bindable(''),
selectedId = $bindable('0')
}: Props = $props();
const filterOptions = $derived([
{ value: '0', label: $t('courses.course_filter.date_created') },
{ value: '1', label: $t('courses.course_filter.published') },
{ value: '2', label: $t('courses.course_filter.lessons') }
]);
const selectedLabel = $derived(
filterOptions.find((opt) => opt.value === selectedId)?.label || filterOptions[0].label
);
const setViewPreference = (preference: 'grid' | 'list') => {
$courseMetaDeta.view = preference;
if (browser) {
localStorage.setItem('courseView', preference);
}
};
function calcProgressRate(progressRate?: number, totalLessons?: number): number {
if (!progressRate || !totalLessons) {
return 0;
}
return Math.round((progressRate / totalLessons) * 100);
}
async function handleDeleteCourse() {
if (!$deleteCourseModal.id) return;
$deleteCourseModal.isDeleting = true;
try {
await deleteCourse($deleteCourseModal.id);
// Remove the course from the courses store
$coursesStore = $coursesStore.filter((course) => course.id !== $deleteCourseModal.id);
// Show success message
snackbar.success('snackbar.course_deleted');
// Close modal and reset state
deleteCourseModal.set(deleteCourseModalInitialState);
} catch (error) {
console.error('Error deleting course:', error);
snackbar.error('snackbar.course_settings.error.went_wrong');
// Stop deleting state on error
$deleteCourseModal.isDeleting = false;
}
}
</script>
<NewCourseModal />
<CopyCourseModal />
<DeleteModal
onDelete={handleDeleteCourse}
bind:open={$deleteCourseModal.open}
isLoading={$deleteCourseModal.isDeleting}
/>
<Page.BodyHeader align="right">
<Search placeholder={$t('courses.search_placeholder')} bind:value={searchValue} />
<Select.Root type="single" bind:value={selectedId}>
<Select.Trigger class="min-w-[150px]">
<p>{selectedId ? selectedLabel : filterOptions[0].label}</p>
</Select.Trigger>
<Select.Content>
{#each filterOptions as option}
<Select.Item value={option.value}>{option.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if $courseMetaDeta.view === 'list'}
<IconButton onClick={() => setViewPreference('grid')}>
<GridIcon size={16} />
</IconButton>
{:else}
<IconButton onClick={() => setViewPreference('list')}>
<ListIcon size={16} />
</IconButton>
{/if}
</Page.BodyHeader>
<div class="mx-auto w-full flex-1">
{#if $courseMetaDeta.isLoading}
<section class={`${$courseMetaDeta.isLoading || courses ? 'cards-container' : ''} `}>
<CourseCardLoader />
<CourseCardLoader />
<CourseCardLoader />
</section>
{:else if !courses.length}
<Empty title={emptyTitle} description={emptyDescription} icon={LibraryBigIcon} variant="page">
<CreateCourseButton isResponsive />
</Empty>
{:else if $courseMetaDeta.view === 'list'}
<div class="w-full overflow-hidden rounded-md border">
<Table.Root class="w-full table-fixed">
<Table.Header>
<Table.Row>
<Table.Head class="w-[20%]">{$t('courses.course_card.list_view.title')}</Table.Head>
<Table.Head class="w-[30%]">{$t('courses.course_card.list_view.description')}</Table.Head>
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.type')}</Table.Head>
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.lessons')}</Table.Head>
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.students')}</Table.Head>
<Table.Head class="w-[12%]">{$t('courses.course_card.list_view.published')}</Table.Head>
<Table.Head class="w-[8%]"></Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each courses as courseData}
<CourseList
id={courseData.id}
title={courseData.title}
type={$t(`course.navItem.settings.${courseData.type.toLowerCase()}`)}
description={courseData.description}
isPublished={courseData.is_published}
totalLessons={courseData.total_lessons}
totalStudents={courseData.total_students}
/>
{/each}
</Table.Body>
</Table.Root>
</div>
{:else}
<section class={`relative ${$courseMetaDeta.isLoading || courses ? 'cards-container' : ''} `}>
{#each courses as courseData}
{#key courseData.id}
<CourseCard
id={courseData.id}
slug={courseData.slug}
bannerImage={courseData.logo || '/images/classroomio-course-img-template.jpg'}
title={courseData.title}
description={courseData.description}
isPublished={courseData.is_published}
type={courseData.type}
currency={courseData.currency}
totalLessons={courseData.total_lessons}
totalStudents={courseData.total_students}
isLMS={$globalStore.isOrgSite}
{isExplore}
progressRate={calcProgressRate(courseData.progress_rate, courseData.total_lessons)}
/>
{/key}
{/each}
</section>
{/if}
</div>
@@ -0,0 +1 @@
export { default as CoursesPage } from './courses.svelte';
@@ -0,0 +1,10 @@
export const statuses = [
{
chipClassName: 'bg-green-600 text-white',
chipLabel: 'In progress'
},
{
chipClassName: 'bg-red-600 text-white',
chipLabel: 'Completed'
}
];
@@ -0,0 +1,23 @@
export function validateForm(fields) {
let hasError = false;
const { title, description } = fields;
const fieldErrors = {
title: '',
description: ''
};
if (!title) {
fieldErrors.title = 'Title is required';
hasError = true;
}
if (!description) {
fieldErrors.description = 'Description is required';
hasError = true;
}
return {
hasError,
fieldErrors
};
}
@@ -0,0 +1,57 @@
import { writable, derived } from 'svelte/store';
import type { Course } from '$lib/utils/types';
export const courses = writable<Course[]>([]);
export const view = writable('grid');
export const coursesInProgress = derived(courses, ($courses) =>
$courses.length > 0
? $courses.filter((course) => {
return course.total_lessons !== course.progress_rate;
})
: []
);
export const coursesComplete = derived(courses, ($courses) =>
$courses.length > 0
? $courses.filter((course) => {
return course.total_lessons === course.progress_rate;
})
: []
);
export const courseMetaDeta = writable<{
isLoading: boolean;
view: 'grid' | 'list';
}>({
isLoading: true,
view: 'grid'
});
export const createCourseModal = writable({
title: '',
type: '',
description: '',
emails: '',
tutors: '',
students: ''
});
export const copyCourseModalInitialState = {
open: false,
courseIdToCopy: null,
id: '',
title: '',
description: '',
isSaving: false,
error: null
};
export const copyCourseModal = writable({ ...copyCourseModalInitialState });
export const deleteCourseModalInitialState = {
open: false,
id: '',
title: '',
isDeleting: false
};
export const deleteCourseModal = writable({ ...deleteCourseModalInitialState });
@@ -0,0 +1,77 @@
import type { TCancelOrgPlan, TCreateOrgPlan, TUpdateOrgPlan } from '@cio/utils/validation/organization';
import { classroomio } from '$lib/utils/services/api';
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
/**
* Server-side API methods for organization plan operations
* These methods use API key authentication and should only be used in server-side files
* (+server.ts, +layout.server.ts) to prevent API keys from being exposed to the client.
*/
export class OrgPlanApiServer {
/**
* Creates a new organization plan (server-side)
* @param params Organization plan creation parameters
* @returns Response data or null on error
*/
static async createOrgPlan(params: TCreateOrgPlan) {
try {
const response = await classroomio.organization.plan.$post(
{
json: params
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success ? data.data : null;
} catch (error) {
console.error('Error creating org plan (server):', error);
return null;
}
}
/**
* Updates an organization plan (server-side)
* @param params Update parameters (subscriptionId, payload)
* @returns Response data or null on error
*/
static async updateOrgPlan(params: TUpdateOrgPlan) {
try {
const response = await classroomio.organization.plan.$put(
{
json: params
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success ? data.data : null;
} catch (error) {
console.error('Error updating org plan (server):', error);
return null;
}
}
/**
* Cancels an organization plan (server-side)
* @param params Cancel parameters (subscriptionId, payload)
* @returns Response data or null on error
*/
static async cancelOrgPlan(params: TCancelOrgPlan) {
try {
const response = await classroomio.organization.plan.cancel.$post(
{
json: params
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success ? data.data : null;
} catch (error) {
console.error('Error canceling org plan (server):', error);
return null;
}
}
}
@@ -1,9 +1,9 @@
import { BaseApi, classroomio } from '$lib/utils/services/api';
import type { TNewOrganizationPlan } from '@cio/db/types';
import type { TCancelOrgPlan, TCreateOrgPlan, TUpdateOrgPlan } from '@cio/utils/validation/organization';
/**
* API class for organization plan operations
* API class for organization plan operations (client-side)
* For server-side operations, use OrgPlanApiServer from './org-plan.server.ts'
*/
class OrgPlanApi extends BaseApi {
/**
@@ -11,7 +11,7 @@ class OrgPlanApi extends BaseApi {
* @param params Organization plan creation parameters
* @returns Created organization plan
*/
async createOrgPlan(params: TNewOrganizationPlan) {
async createOrgPlan(params: TCreateOrgPlan) {
return this.execute({
requestFn: () => classroomio.organization.plan.$post({ json: params }),
logContext: 'creating organization plan'
@@ -23,7 +23,7 @@ class OrgPlanApi extends BaseApi {
* @param params Update parameters (subscriptionId, payload)
* @returns Updated organization plan
*/
async updateOrgPlan(params: { subscriptionId: string; payload: Record<string, unknown> }) {
async updateOrgPlan(params: TUpdateOrgPlan) {
return this.execute({
requestFn: () => classroomio.organization.plan.$put({ json: params }),
logContext: 'updating organization plan'
@@ -35,7 +35,7 @@ class OrgPlanApi extends BaseApi {
* @param params Cancel parameters (subscriptionId, payload)
* @returns Updated organization plan
*/
async cancelOrgPlan(params: { subscriptionId: string; payload: Record<string, unknown> }) {
async cancelOrgPlan(params: TCancelOrgPlan) {
return this.execute({
requestFn: () => classroomio.organization.plan.cancel.$post({ json: params }),
logContext: 'canceling organization plan'
@@ -0,0 +1,57 @@
import { classroomio } from '$lib/utils/services/api';
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
/**
* Server-side API methods for organization operations
* These methods use API key authentication and should only be used in server-side files
* (+server.ts, +layout.server.ts) to prevent API keys from being exposed to the client.
*/
export class OrgApiServer {
/**
* Gets organization by siteName (server-side)
* @param siteName Organization site name
* @returns Organization or null
*/
static async getOrgBySiteName(siteName: string) {
try {
const response = await classroomio.organization.$get(
{
query: { siteName }
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success && data.data && data.data.length > 0 ? data.data[0] : null;
} catch (error) {
console.error('Error fetching organization by siteName (server):', error);
return null;
}
}
/**
* Gets organizations by custom domain (server-side)
* @param customDomain Custom domain
* @param isCustomDomainVerified Whether the domain is verified
* @returns Array of organizations
*/
static async getOrgsByCustomDomain(customDomain: string, isCustomDomainVerified = true) {
try {
const response = await classroomio.organization.$get(
{
query: {
customDomain,
isCustomDomainVerified
}
},
getApiKeyHeaders()
);
const data = await response.json();
return data.success && data.data ? data.data : [];
} catch (error) {
console.error('Error fetching organizations by custom domain (server):', error);
return [];
}
}
}
@@ -1,20 +1,45 @@
import { BaseApi, classroomio } from '$lib/utils/services/api';
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import type {
CoursesByOrganizationSiteName,
DeleteTeamResponse,
GetAudienceResponse,
GetCoursesBySiteNameResponse,
InviteTeamResponse,
OrganizationAudience,
OrganizationTeamMembers
} from '../utils/types';
import type { TCreateOrganization, TGetOrganizations, TUpdateOrganization } from '@cio/utils/validation/organization';
import { ZCreateOrganization, ZUpdateOrganization } from '@cio/utils/validation/organization';
import { currentOrg, orgs } from '$lib/utils/store/org';
import type { AccountOrg } from '$lib/features/app/types';
import type { GetTeamResponse } from '../utils/types';
import type { TGetOrganizations } from '@cio/utils/validation/organization';
import { orgs } from '$lib/utils/store/org';
import { ROLE } from '@cio/utils/constants';
import { ROLE_LABEL } from '$lib/utils/constants/roles';
import type { UpdateOrganizationRequestType } from './types';
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { resolve } from '$app/paths';
import { snackbar } from '$lib/components/Snackbar/store';
import { t } from '$lib/utils/functions/translations';
import { uploadImage } from '$lib/utils/services/upload';
export interface TOrgUpdateForm {
name?: string;
avatar?: string | File | undefined;
theme?: string;
landingpage?: AccountOrg['landingpage'];
siteName?: string;
customDomain?: string | null;
isCustomDomainVerified?: boolean;
customization?: AccountOrg['customization'];
}
/**
* API class for organization operations
*/
class OrgApi extends BaseApi {
class OrgApi extends BaseApiWithErrors {
teamMembers = $state<OrganizationTeamMembers>([]);
audience = $state<OrganizationAudience>([]);
orgSiteCourses = $state<CoursesByOrganizationSiteName>([]);
@@ -32,7 +57,16 @@ class OrgApi extends BaseApi {
}),
logContext: 'fetching organization team',
onSuccess: (response) => {
this.teamMembers = response.data;
// Map API response to include role and isAdmin directly in the API layer
const mappedTeam = response.data.map((member) => ({
...member,
role: ROLE_LABEL[member.roleId] || '',
isAdmin: member.roleId === ROLE.ADMIN,
verified: member.verified ?? false
}));
// Update both API state and store
this.teamMembers = mappedTeam;
}
});
}
@@ -42,7 +76,9 @@ class OrgApi extends BaseApi {
* @param orgId Organization ID
* @returns Audience array
*/
async getOrgAudience(orgId: string) {
async getOrgAudience(orgId?: string) {
if (!orgId) return;
return this.execute<GetAudienceResponse>({
requestFn: () =>
classroomio.organization[':orgId'].audience.$get({
@@ -103,6 +139,186 @@ class OrgApi extends BaseApi {
return result;
}
/**
* Creates a new organization with the current user as owner
* @param fields Organization creation data (name, siteName)
*/
async create(fields: TCreateOrganization) {
const result = ZCreateOrganization.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'organization');
return;
}
await this.execute<typeof classroomio.organization.$post>({
requestFn: () =>
classroomio.organization.$post({
json: result.data
}),
logContext: 'creating organization',
onSuccess: (response) => {
if (!response.data) {
return;
}
const { organization, organizations } = response.data;
// Update stores
orgs.set(organizations);
const createdOrg = organizations.find((org) => org.id === organization.id);
if (createdOrg) {
currentOrg.set(createdOrg);
}
snackbar.success('snackbar.success_update');
goto(resolve(`/org/${organization.siteName}`, {}));
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error(result);
return;
}
if ('error' in result && 'field' in result) {
// Field-specific error automatically mapped to this.errors[field]
this.errors[result.field as string] = result.error;
} else if ('error' in result) {
this.errors.general = result.error;
}
}
});
}
/**
* Updates an organization
* @param orgId Organization ID
* @param fields Organization update data (name, avatar, theme, landingpage)
*/
async update(orgId: string, fields: TOrgUpdateForm) {
const result = ZUpdateOrganization.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'organization');
return;
}
this.isLoading = true;
// Handle avatar upload if provided
let avatarUrl: string | undefined;
if (fields.avatar instanceof File) {
avatarUrl = await uploadImage(fields.avatar);
} else if (typeof fields.avatar === 'string') {
avatarUrl = fields.avatar;
}
// Build update payload
fields.avatar = undefined;
const updates: TUpdateOrganization = {
...fields,
avatarUrl
};
await this.execute<UpdateOrganizationRequestType>({
requestFn: () =>
classroomio.organization[':orgId'].$put({
param: { orgId },
// @ts-expect-error - the json type is not inferred correctly
json: updates
}),
logContext: 'updating organization',
onSuccess: (response) => {
if (!response.data) {
return;
}
orgs.update((_orgs) =>
_orgs.map((org) => {
if (org.id === orgId) {
return { ...org, ...response.data };
}
return org;
})
);
const currentOrgData = get(currentOrg);
if (currentOrgData.id === orgId) {
currentOrg.update((org) => ({
...org,
...response.data
}));
}
snackbar.success('snackbar.course_settings.success.update_successful');
this.success = true;
this.errors = {};
},
onError: (error) => {
console.error('Error updating organization:', error);
const message = error instanceof Error ? error.message : `${error}`;
this.errors.general = message;
snackbar.error(`${t.get('snackbar.update_failed')}: ${message}`);
}
});
}
/**
* Invites team members to the organization
* @param orgId Organization ID
* @param emails Array of email addresses
* @param roleId Role ID (ADMIN or TUTOR)
*/
async inviteTeamMembers(orgId: string, emails: string[], roleId: number) {
return this.execute<InviteTeamResponse>({
requestFn: () =>
classroomio.organization[':orgId'].team.invite.$post({
param: { orgId },
// @ts-expect-error - the json type is not inferred correctly
json: { emails, roleId }
}),
logContext: 'inviting team members',
onSuccess: () => {
this.getOrgTeam(orgId);
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error(result);
return;
}
if ('error' in result && 'field' in result) {
this.errors[result.field as string] = result.error;
}
}
});
}
/**
* Removes a team member from the organization
* @param orgId Organization ID
* @param memberId Member ID to remove
*/
async removeTeamMember(orgId: string, memberId: number) {
return this.execute<DeleteTeamResponse>({
requestFn: () =>
classroomio.organization[':orgId'].team[':memberId'].$delete({
param: { orgId, memberId: memberId.toString() }
}),
logContext: 'removing team member',
onSuccess: () => {
this.getOrgTeam(orgId);
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error(result);
}
}
});
}
}
export const orgApi = new OrgApi();
@@ -0,0 +1,5 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type UpdateOrganizationRequestType = (typeof classroomio.organization)[':orgId']['$put'];
export type UpdateOrganizationResponse = InferResponseType<UpdateOrganizationRequestType>;
export type UpdateOrganizationSuccess = Extract<UpdateOrganizationResponse, { success: true }>;
@@ -5,7 +5,16 @@ export type GetTeamResponse = (typeof classroomio.organization)[':orgId']['team'
export type OrganizationTeamResponse = InferResponseType<GetTeamResponse> | null;
export type OrganizationTeamSuccess = Extract<InferResponseType<GetTeamResponse>, { success: true }>;
export type OrganizationTeamMembers = OrganizationTeamSuccess['data'];
export type OrganizationTeamMembersRaw = OrganizationTeamSuccess['data'];
// Mapped team members with role and isAdmin (mapped in API layer)
export type OrganizationTeamMembers = Array<
OrganizationTeamMembersRaw[number] & {
role: string;
isAdmin: boolean;
verified: boolean;
}
>;
// Audience types
export type GetAudienceResponse = (typeof classroomio.organization)[':orgId']['audience']['$get'];
@@ -23,3 +32,10 @@ export type CoursesByOrganizationSiteNameSuccess = Extract<
>;
export type CoursesByOrganizationSiteName = CoursesByOrganizationSiteNameSuccess['data'];
export type InviteTeamResponse = (typeof classroomio.organization)[':orgId']['team']['invite']['$post'];
export type InviteTeamSuccess = Extract<InferResponseType<InviteTeamResponse>, { success: true }>;
export type InviteTeamData = InviteTeamSuccess['data'];
export type DeleteTeamResponse = (typeof classroomio.organization)[':orgId']['team'][':memberId']['$delete'];
export type DeleteTeamSuccess = Extract<InferResponseType<DeleteTeamResponse>, { success: true }>;
@@ -0,0 +1,41 @@
<script lang="ts">
interface Props {
sm?: number;
md?: number;
lg?: number;
class?: string;
children?: import('svelte').Snippet;
}
let { sm = 8, md = 8, lg = 8, class: className = '', children }: Props = $props();
const getColSpan = (cols: number) => {
const spanMap = {
1: 'col-span-1',
2: 'col-span-2',
3: 'col-span-3',
4: 'col-span-4',
5: 'col-span-5',
6: 'col-span-6',
7: 'col-span-7',
8: 'col-span-8',
9: 'col-span-9',
10: 'col-span-10',
11: 'col-span-11',
12: 'col-span-12',
13: 'col-span-13',
14: 'col-span-14',
15: 'col-span-15',
16: 'col-span-16'
};
return spanMap[cols] || 'col-span-full';
};
const smClass = getColSpan(sm);
const mdClass = `md:${getColSpan(md)}`;
const lgClass = `lg:${getColSpan(lg)}`;
</script>
<div class="{smClass} {mdClass} {lgClass} {className} grid">
{@render children?.()}
</div>
@@ -0,0 +1,12 @@
<script lang="ts">
interface Props {
class?: string;
children?: import('svelte').Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<div class="w-full {className}">
{@render children?.()}
</div>
@@ -0,0 +1,13 @@
<script lang="ts">
interface Props {
id?: string;
class?: string;
children?: import('svelte').Snippet;
}
let { id, class: className = '', children }: Props = $props();
</script>
<div {id} class="lg:grid-cols-16 grid grid-cols-1 gap-5 px-3 {className}">
{@render children?.()}
</div>
@@ -0,0 +1,3 @@
export { default as Grid } from './Grid.svelte';
export { default as Row } from './Row.svelte';
export { default as Column } from './Column.svelte';
@@ -0,0 +1,2 @@
export { default as LanguagePicker } from './language-picker.svelte';
export * from './Layout';
@@ -0,0 +1,39 @@
<script lang="ts">
import { Label } from '@cio/ui/base/label';
import * as Select from '@cio/ui/base/select';
import type { TLocale } from '@cio/db/types';
import { LANGUAGES } from '$lib/utils/constants/translation';
import { t, initialized } from '$lib/utils/functions/translations';
interface Props {
className?: string;
value?: TLocale;
hasLangChanged?: boolean;
change?: () => void;
}
let { className = '', value = $bindable('en'), hasLangChanged = $bindable(false), change }: Props = $props();
function handleSelect(selectedValue: string) {
value = selectedValue as TLocale;
hasLangChanged = true;
change?.();
}
</script>
{#if $initialized}
<div class={className}>
<Label class="mb-2 block">{$t('content.toggle_label')}</Label>
<Select.Root type="single" bind:value onValueChange={handleSelect}>
<Select.Trigger class="w-full">
<p>{value ? LANGUAGES.find((lang) => lang.id === value)?.text : 'Pick a Language'}</p>
</Select.Trigger>
<Select.Content>
{#each LANGUAGES as language}
<Select.Item value={language.id}>{language.text}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{/if}
@@ -0,0 +1,80 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { currentOrgPlan, isFreePlan } from '$lib/utils/store/org';
import HourglassIcon from '@lucide/svelte/icons/hourglass';
import { openUpgradeModal } from '$lib/utils/functions/org';
import ReceiptIcon from '@lucide/svelte/icons/receipt';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import { Button } from '@cio/ui/base/button';
import * as Item from '@cio/ui/base/item';
let isRedirecting = $state(false);
async function onOpenBilling() {
console.log({
plan: $currentOrgPlan
});
try {
if ($currentOrgPlan?.provider === 'polar') {
const url = new URL('/api/polar/portal', window.location.origin);
url.searchParams.set('customerId', $currentOrgPlan?.customerId || '');
window.open(url.toString(), '_blank');
}
if ($currentOrgPlan?.provider === 'lmz') {
isRedirecting = true;
const url = new URL('/api/lmz/subscription', window.location.origin);
url.searchParams.set('subscriptionId', $currentOrgPlan?.subscriptionId);
const response = await fetch(url.toString());
const result = await response.json();
isRedirecting = false;
window.open(result.customerPortal, '_blank');
}
} catch (error) {
isRedirecting = false;
console.error(error);
snackbar.error($t('settings.billing.error'));
}
}
</script>
<div class="max-w-lg! flex w-full flex-col gap-4 px-2">
<p class="text-md font-medium">{$t('settings.billing.manage')}</p>
{#if $isFreePlan}
<Item.Root variant="outline">
<Item.Media variant="icon">
<HourglassIcon />
</Item.Media>
<Item.Content>
<Item.Description>{$t('settings.billing.active')}</Item.Description>
</Item.Content>
<Item.Actions>
<Button size="sm" variant="outline" onclick={openUpgradeModal}>Upgrade</Button>
</Item.Actions>
</Item.Root>
{:else}
<Item.Root variant="outline">
<Item.Media variant="icon">
<ReceiptIcon />
</Item.Media>
<Item.Content>
<Item.Description>{$t('settings.billing.provider')}</Item.Description>
</Item.Content>
<Item.Actions>
<Button size="sm" variant="outline" onclick={onOpenBilling} loading={isRedirecting}>
<ExternalLinkIcon class="size-4" />
{$t('settings.billing.open_billing')}
</Button>
</Item.Actions>
</Item.Root>
{/if}
</div>
@@ -0,0 +1,127 @@
<script lang="ts">
import { Switch } from '@cio/ui/base/switch';
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { handleOpenWidget } from '$lib/components/CourseLandingPage/store';
import { Button } from '@cio/ui/base/button';
import { Input } from '@cio/ui/base/input';
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
import * as Field from '@cio/ui/base/field';
let widgetKey = $state('');
function widgetControl(key: string) {
widgetKey = key;
$handleOpenWidget.open = true;
}
export async function handleSave() {
await orgApi.update($currentOrg.id, {
customization: $currentOrg.customization
});
}
</script>
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Legend>{$t('components.settings.customize_lms.dashboard.title')}</Field.Legend>
<Field.Group>
<Field.Field orientation="horizontal">
<Field.Label>{$t('components.settings.customize_lms.dashboard.community')}</Field.Label>
<Switch bind:checked={$currentOrg.customization.dashboard.community} />
<Field.Description class="text-sm">
{$currentOrg.customization.dashboard.community
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Field.Description>
</Field.Field>
<Field.Field orientation="horizontal">
<Field.Label>{$t('components.settings.customize_lms.dashboard.exercises')}</Field.Label>
<Switch bind:checked={$currentOrg.customization.dashboard.exercise} />
<Field.Description class="text-sm">
{$currentOrg.customization.dashboard.exercise
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Field.Description>
</Field.Field>
<Field.Field>
<Field.Label>{$t('components.settings.customize_lms.dashboard.banner_image')}</Field.Label>
<Button variant="outline" onclick={() => widgetControl('banner-image')}>
{$t('components.settings.customize_lms.dashboard.banner_image_btn')}
</Button>
{#if $currentOrg.customization.dashboard.bannerImage}
<img alt="Banner" src={$currentOrg.customization.dashboard.bannerImage} class="mt-2 w-full rounded-md" />
{/if}
{#if $handleOpenWidget.open && widgetKey === 'banner-image'}
<UploadWidget bind:imageURL={$currentOrg.customization.dashboard.bannerImage} />
{/if}
</Field.Field>
<Field.Field>
<Field.Label>{$t('components.settings.customize_lms.dashboard.banner_text')}</Field.Label>
<Input
placeholder={$t('components.settings.customize_lms.dashboard.banner_text_placeholder')}
bind:value={$currentOrg.customization.dashboard.bannerText}
/>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('components.settings.customize_lms.course.title')}</Field.Legend>
<Field.Group>
<Field.Field orientation="horizontal">
<Field.Label>{$t('components.settings.customize_lms.course.newsfeed')}</Field.Label>
<Switch bind:checked={$currentOrg.customization.course.newsfeed} />
<Field.Description class="text-gray-600">
{$currentOrg.customization.course.newsfeed
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Field.Description>
</Field.Field>
<Field.Field orientation="horizontal">
<Field.Label>{$t('components.settings.customize_lms.course.grading')}</Field.Label>
<Switch bind:checked={$currentOrg.customization.course.grading} />
<Field.Description class="text-gray-600">
{$currentOrg.customization.course.grading
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Field.Description>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('components.settings.customize_lms.apps.title')}</Field.Legend>
<Field.Group>
<Field.Field orientation="horizontal">
<Field.Label>{$t('components.settings.customize_lms.apps.poll')}</Field.Label>
<Switch bind:checked={$currentOrg.customization.apps.poll} />
<Field.Description class="text-gray-600">
{$currentOrg.customization.apps.poll
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Field.Description>
</Field.Field>
<Field.Field orientation="horizontal">
<Field.Label>{$t('components.settings.customize_lms.apps.live_comment')}</Field.Label>
<Switch bind:checked={$currentOrg.customization.apps.comments} />
<Field.Description class="text-gray-600">
{$currentOrg.customization.apps.comments
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Field.Description>
</Field.Field>
</Field.Group>
</Field.Set>
</Field.Group>
@@ -0,0 +1,360 @@
<script lang="ts">
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import TrashIcon from '@lucide/svelte/icons/trash';
import Copy from '@lucide/svelte/icons/copy';
import isValidDomain from 'is-valid-domain';
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { parse } from 'tldts';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { blockedSubdomain } from '$lib/utils/constants/app';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import { copyToClipboard } from '$lib/utils/functions/formatYoutubeVideo';
import { updateOrgSiteNameValidation } from '$lib/utils/functions/validator';
import { sanitizeDomain, sendDomainRequest } from '$lib/utils/functions/domain';
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
import { Textarea } from '@cio/ui/base/textarea';
import { DomainInput } from '@cio/ui/custom/domain-input';
import { ComingSoon } from '$lib/features/ui';
import { UpgradeBanner } from '$lib/features/ui';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import VisitOrgSiteButton from '$lib/components/Buttons/VisitOrgSite.svelte';
import * as Field from '@cio/ui/base/field';
let siteName = $derived($currentOrg.siteName);
let customDomain = $state('');
let customCode = $state('');
let favicon = $state('');
let isLoading = $state(false);
let isCustomDomainLoading = $state(false);
let isRefreshing = $state(false);
const isDomainValid = $derived(isValidDomain(sanitizeDomain(customDomain), { subdomain: true }));
type Error = {
siteName: string;
customDomain: string;
};
let errors: Error = $state({
siteName: '',
customDomain: ''
});
async function handleSaveSiteName() {
errors = updateOrgSiteNameValidation(siteName) as Error;
if (Object.values(errors).length) {
isLoading = false;
return;
}
if (blockedSubdomain.includes(siteName || '')) {
errors.siteName = 'Sitename already exists.';
return;
}
isLoading = true;
await orgApi.update($currentOrg.id, {
siteName
});
if (orgApi.success) {
$currentOrg.siteName = siteName;
goto(`/org/${$currentOrg.siteName}/settings/domains`);
} else {
if (orgApi.errors.general) {
errors.siteName = orgApi.errors.general;
} else {
errors.siteName = $t('add_org.sitename');
}
}
isLoading = false;
}
async function handleSaveCustomDomain() {
if (!isDomainValid) return;
const sanitizedDomain = sanitizeDomain(customDomain);
const details = parse(sanitizedDomain);
if (!details.subdomain) {
errors.customDomain = $t('components.settings.domains.custom_domain_error');
return;
}
if (sanitizedDomain.includes('classroomio.com')) {
errors.customDomain = $t('components.settings.domains.custom_domain_not_classroomio');
return;
}
isCustomDomainLoading = true;
await orgApi.update($currentOrg.id, {
customDomain: sanitizedDomain
});
if (!orgApi.success) {
errors.customDomain = orgApi.errors.general || 'Failed to update custom domain';
isCustomDomainLoading = false;
return;
}
try {
const response = await sendDomainRequest('add_domain', sanitizedDomain);
const data = await response.json();
console.log('added domain to vercel', data);
} catch (error) {
console.log('Error: adding domain to vercel', error);
snackbar.error(error as string);
isCustomDomainLoading = false;
return;
}
$currentOrg.customDomain = sanitizedDomain;
isCustomDomainLoading = false;
}
async function handleRemoveCustomDomain() {
if (!$currentOrg.customDomain) return;
isCustomDomainLoading = true;
await orgApi.update($currentOrg.id, {
customDomain: null,
isCustomDomainVerified: false
});
if (!orgApi.success) {
snackbar.error(orgApi.errors.general || 'Failed to remove custom domain');
isCustomDomainLoading = false;
return;
}
try {
const response = await sendDomainRequest('remove_domain', $currentOrg.customDomain);
const data = await response.json();
if (!data.success) {
throw new Error(data.message);
}
console.log('removed domain from vercel', data);
} catch (error) {
console.log('Error: removing domain from vercel', error);
snackbar.error(error as string);
isCustomDomainLoading = false;
return;
}
$currentOrg.customDomain = '';
$currentOrg.isCustomDomainVerified = false;
isCustomDomainLoading = false;
}
async function handleRefreshCustomDomain() {
isRefreshing = true;
try {
const response = await sendDomainRequest('verify_domain', $currentOrg.customDomain || '');
const data = await response.json();
console.log('data', data);
if (data.verified && !$currentOrg.isCustomDomainVerified) {
await orgApi.update($currentOrg.id, {
isCustomDomainVerified: true
});
if (orgApi.success) {
$currentOrg.isCustomDomainVerified = true;
}
}
} catch (error) {
console.log('Error: refreshing domain', error);
snackbar.error(error as string);
}
isRefreshing = false;
}
function resetErrors(_siteName: string, _customDomain: string) {
untrack(() => {
if (errors.siteName) {
errors.siteName = '';
}
if (errors.customDomain) {
errors.customDomain = '';
}
});
}
function getSubdomain() {
const details = parse($currentOrg.customDomain || '');
return details.subdomain;
}
$effect(() => {
resetErrors(siteName, customDomain);
});
</script>
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Legend>{$t('components.settings.domains.add')}</Field.Legend>
<Field.Description class="">{$t('settings.organization.organization_profile.custom_domain.body')}</Field.Description
>
<Field.Field>
<Field.Label>URL</Field.Label>
<DomainInput bind:value={siteName} placeholder="myschool" prefix="https://" suffix=".classroomio.com" />
{#if errors.siteName}
<Field.Error>{errors.siteName}</Field.Error>
{/if}
</Field.Field>
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={handleSaveSiteName} disabled={isLoading}>
{$t('components.settings.domains.update')}
</Button>
<VisitOrgSiteButton />
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('components.settings.domains.custom')}</Field.Legend>
<UpgradeBanner>{$t('upgrade.domain')}</UpgradeBanner>
<Field.Group>
{#if $currentOrg.customDomain}
<Field.Field>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<p class="text-md flex items-center gap-2 font-medium">
{$currentOrg.customDomain}
<Button variant="outline" size="icon-sm" onclick={() => goto(`https://${$currentOrg.customDomain}`)}>
<ArrowUpRightIcon size={16} />
</Button>
</p>
<div
class="mt-1 h-2 w-2 rounded-full bg-{$currentOrg.isCustomDomainVerified ? 'green' : 'yellow'}-400"
></div>
</div>
{#if $currentOrg.isCustomDomainVerified}
<Badge variant="default" class="bg-green-500 px-3 text-xs text-white">Verified</Badge>
{:else}
<Badge variant="default" class="bg-yellow-500 px-3 text-xs text-white">Pending verification</Badge>
{/if}
</div>
</Field.Field>
<Field.Description>{$t('components.settings.domains.dns_description')}</Field.Description>
<Field.Field>
<div class="flex items-center gap-10 rounded-md border px-4 py-2">
<div class="flex h-[72px] flex-col justify-evenly">
<Field.Label class="font-light">{$t('components.settings.domains.dns_type')}</Field.Label>
<p class="flex h-[40px] items-center">CNAME</p>
</div>
<div class="flex h-[72px] flex-col justify-evenly">
<Field.Label class="font-light">{$t('components.settings.domains.dns_name')}</Field.Label>
<p class="flex h-[40px] items-center">
{getSubdomain()}
</p>
</div>
<div class="flex h-[72px] flex-col justify-evenly">
<Field.Label class="font-light">{$t('components.settings.domains.dns_value')}</Field.Label>
<p class=" flex items-center gap-1">
cname.vercel-dns.com
<Button variant="ghost" size="icon-sm" onclick={() => copyToClipboard('cname.vercel-dns.com')}>
<p>cname.vercel-dns.com</p>
<Copy />
</Button>
</p>
</div>
</div>
</Field.Field>
<Field.Field orientation="horizontal">
<Button
variant="outline"
class="flex items-center gap-2 py-2"
onclick={handleRefreshCustomDomain}
loading={isRefreshing}
>
{#if !isRefreshing}
<RotateCcwIcon size={16} />
{/if}
{$t('components.settings.domains.refresh')}
</Button>
<Button
variant="destructive"
class="flex items-center gap-2 py-2"
onclick={handleRemoveCustomDomain}
loading={isCustomDomainLoading}
>
{#if !isCustomDomainLoading}
<TrashIcon size={16} />
{/if}
{$t('components.settings.domains.remove')}
</Button>
</Field.Field>
{:else}
<Field.Field>
<Field.Label class="font-bold">{$t('components.settings.domains.your_domain')}</Field.Label>
<DomainInput bind:value={customDomain} placeholder="courses.yourwebsite" disabled={$isFreePlan} />
{#if errors.customDomain}
<Field.Error>{errors.customDomain}</Field.Error>
{/if}
</Field.Field>
<Field.Field orientation="horizontal">
<Button
onclick={$isFreePlan ? () => {} : handleSaveCustomDomain}
loading={isCustomDomainLoading}
disabled={isLoading || !isDomainValid}
>
{$t('components.settings.domains.save')}
</Button>
</Field.Field>
{/if}
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('components.settings.domains.custom_favicon')}</Field.Legend>
<ComingSoon />
<Field.Field>
<UploadImage
shape="rounded-md"
bind:avatar={favicon}
src={$currentOrg.favicon ?? '/logo-512.png'}
widthHeight="w-16 h-16 lg:w-24 lg:h-24"
flexDirection="flex-row"
isDisabled={true}
/>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('components.settings.domains.custom_code')}</Field.Legend>
<ComingSoon />
<Field.Field>
<Textarea
bind:value={customCode}
placeholder="e.g <link rel='stylesheet' href='https://example.com/style.css' />"
class="w-4/5"
rows={7}
disabled={true}
/>
</Field.Field>
</Field.Set>
</Field.Group>
@@ -0,0 +1,8 @@
export { default as ProfilePage } from './profile.svelte';
export { default as DomainsPage } from './domains.svelte';
export { default as TeamsPage } from './teams.svelte';
export { default as CustomizeLmsPage } from './customize-lms.svelte';
export { default as OrgPage } from './org.svelte';
export { default as LandingpagePage } from './landingpage.svelte';
export { default as BillingPage } from './billing.svelte';
export { default as IntegrationsPage } from './integrations.svelte';
@@ -0,0 +1,72 @@
<script lang="ts">
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
// import { snackbar } from '$lib/components/Snackbar/store';
import { Input } from '@cio/ui/base/input';
import { Button } from '@cio/ui/base/button';
import ArrowUpRightIcon from '$lib/components/Icons/ArrowTopRight.svelte';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import * as Field from '@cio/ui/base/field';
let chatId: number | null = $state(null);
async function addChatId() {
if (!chatId || `${chatId}`.length < 5) return;
// TODO: Update profile with telegram chat id
// if (error) {
// console.log(error);
// snackbar.error('snackbar.course_settings.error.failed_integration');
// } else {
// $profile.telegramChatId = chatId;
// chatId = null;
// snackbar.success('snackbar.course_settings.success.successful_integration');
// }
}
async function deleteChatId() {
// const { error } = await supabase.from('profile').update({ telegram_chat_id: null }).match({ id: $profile.id });
// if (error) {
// snackbar.error('snackbar.course_settings.error.failed_deletion');
// console.log(error);
// } else {
// $profile.telegramChatId = null;
// snackbar.success('snackbar.course_settings.success.successful_deletion');
// }
}
</script>
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Legend>
<img src="/telegram-svg.svg" alt="" class="mr-2 w-10" />
{$t('settings.integrations.heading')}
</Field.Legend>
{#if $profile.telegramChatId !== null}
<Field.Field>
<div class="flex items-center">
<CircleCheckIcon size={16} filled />
<Field.Description>{$t('settings.integrations.success_message')}</Field.Description>
</div>
<Button variant="destructive" onclick={deleteChatId}>
{$t('settings.integrations.disconnect')}
</Button>
</Field.Field>
{:else}
<Field.Group>
<Field.Description>{$t('settings.integrations.sub_heading')}</Field.Description>
<Field.Description>{$t('settings.integrations.step_authenticate')}</Field.Description>
<Button variant="outline" onclick={() => window.open('https://t.me/classroomio_bot', '_blank')}>
<span class="mr-2">{$t('settings.integrations.open_bot_button')}</span>
<ArrowUpRightIcon />
</Button>
<Field.Description>{$t('settings.integrations.step_chatId')}</Field.Description>
<Field.Field>
<Input bind:value={chatId} placeholder="360111" class="w-full lg:w-60" type="number" required />
</Field.Field>
<Button variant="default" onclick={addChatId}>{$t('settings.integrations.connect_button')}</Button>
</Field.Group>
{/if}
</Field.Set>
</Field.Group>
@@ -0,0 +1,762 @@
<script lang="ts">
import { Label } from '@cio/ui/base/label';
import { Switch } from '@cio/ui/base/switch';
import TrashIcon from '@lucide/svelte/icons/trash';
import * as RadioGroup from '@cio/ui/base/radio-group';
import { landingPageSettings } from '../utils/store';
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { handleOpenWidget } from '$lib/components/CourseLandingPage/store';
import { Checkbox } from '@cio/ui/base/checkbox';
import { Input } from '@cio/ui/base/input';
import { Textarea } from '@cio/ui/base/textarea';
import { Button } from '@cio/ui/base/button';
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
import * as Field from '@cio/ui/base/field';
import type { OrgLandingPageJson } from '$lib/utils/types/org';
import type { AccountOrg } from '$lib/features/app/types';
let creatingNewQuestion = $state(false);
let creatingNewCustomLink = $state(false);
let hasUnsavedChanges = $state(false);
let widgetKey = $state('');
const banner = [
{ value: 'video', label: `${$t('settings.landing_page.actions.banner_type.video')}` },
{ value: 'image', label: `${$t('settings.landing_page.actions.banner_type.image')}` }
];
let newQuestion = $state({
title: '',
content: ''
});
let newCustomLink = $state({
label: '',
url: '',
openInNewTab: false
});
function widgetControl(key: string) {
widgetKey = key;
$handleOpenWidget.open = true;
}
function createNewFaq() {
newQuestion = {
title: '',
content: ''
};
creatingNewQuestion = true;
}
function saveNewFAQ() {
if (newQuestion.title !== '' && newQuestion.content !== '') {
$landingPageSettings.faq.questions = [
...$landingPageSettings.faq.questions,
{
id: new Date().getTime(),
title: newQuestion.title,
content: newQuestion.content
}
];
creatingNewQuestion = false;
}
}
function cancelNewFAQ() {
creatingNewQuestion = false;
}
function deleteFaq(id: number) {
let faqs = $landingPageSettings.faq.questions;
const filteredFaq = faqs.filter((faq) => faq.id !== id);
$landingPageSettings.faq.questions = filteredFaq;
}
function createNewCustomLink() {
newCustomLink = {
label: '',
url: '',
openInNewTab: false
};
creatingNewCustomLink = true;
}
function saveNewCustomLink() {
if (newCustomLink.label !== '' && newCustomLink.url !== '') {
$landingPageSettings.customLinks.links = [
...$landingPageSettings.customLinks.links,
{
id: new Date().getTime(),
label: newCustomLink.label,
url: newCustomLink.url,
openInNewTab: newCustomLink.openInNewTab
}
];
creatingNewCustomLink = false;
}
}
function cancelNewCustomLink() {
creatingNewCustomLink = false;
}
function deleteCustomLink(id: number) {
let customLinks = $landingPageSettings.customLinks.links;
const filteredCustomLinks = customLinks.filter((link) => link.id !== id);
$landingPageSettings.customLinks.links = filteredCustomLinks;
}
const checkPrefix = (inputValue: string) => {
if (!inputValue) return;
if (inputValue.trim() !== '') {
if (!inputValue.startsWith('https://')) {
inputValue = 'https://' + inputValue;
}
}
return inputValue;
};
export async function handleSave() {
$landingPageSettings.footer.twitter = checkPrefix($landingPageSettings.footer.twitter) || '';
$landingPageSettings.footer.linkedin = checkPrefix($landingPageSettings.footer.linkedin) || '';
$landingPageSettings.footer.facebook = checkPrefix($landingPageSettings.footer.facebook) || '';
await orgApi.update($currentOrg.id, {
landingpage: $landingPageSettings as AccountOrg['landingpage']
});
if (orgApi.success) {
$currentOrg.landingpage = $landingPageSettings as AccountOrg['landingpage'];
hasUnsavedChanges = false;
}
}
// Set default from store
currentOrg.subscribe((cOrg) => {
if (cOrg.landingpage && Object.keys(cOrg.landingpage).length) {
const landingpage = { ...(cOrg.landingpage as unknown as OrgLandingPageJson) };
// Fallbacks for new keys we added into JSON, in case a user already saved the old JSON
if (!landingpage?.header?.banner) {
landingpage.header.banner = $landingPageSettings.header.banner;
}
if (!landingpage?.header?.background) {
landingpage.header.background = $landingPageSettings.header.background;
}
if (!landingpage.customLinks) {
landingpage.customLinks = $landingPageSettings.customLinks;
}
$landingPageSettings = {
...landingpage
};
}
});
</script>
<UnsavedChanges bind:hasUnsavedChanges />
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Legend>{$t('settings.landing_page.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.header.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Field.Label class="text-gray-600">
{$landingPageSettings.header.show
? $t('settings.landing_page.show_section')
: $t('settings.landing_page.hide_section')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.landing_page.title')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.header.title}
oninput={() => (hasUnsavedChanges = true)}
class="w-full"
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.title_highlight')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.highlight')}
class="w-full"
bind:value={$landingPageSettings.header.titleHighlight}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.subtitle')}</Field.Label>
<Textarea
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
class="w-full"
bind:value={$landingPageSettings.header.subtitle}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.actions.heading')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.label')}
class="w-full"
bind:value={$landingPageSettings.header.action.label}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.actions.link')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.link')}
bind:value={$landingPageSettings.header.action.link}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch
bind:checked={$landingPageSettings.header.action.redirect}
onCheckedChange={() => (hasUnsavedChanges = true)}
/>
<Field.Label class="text-gray-600">
{$landingPageSettings.header.action.redirect
? $t('settings.landing_page.actions.redirect')
: $t('settings.landing_page.actions.no_redirect')}
</Field.Label>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.actions.banner_type.heading')}</Field.Label>
<RadioGroup.Root
bind:value={$landingPageSettings.header.banner.type}
onValueChange={() => {
if (hasUnsavedChanges) return;
hasUnsavedChanges = true;
}}
>
{#each banner as item}
<div class="flex items-center space-x-2">
<RadioGroup.Item value={item.value} id={item.value} />
<Label for={item.value} class="cursor-pointer">{item.label}</Label>
</div>
{/each}
</RadioGroup.Root>
</Field.Field>
{#if $landingPageSettings.header.banner.type === 'video'}
<Field.Field>
<Field.Label>{$t('settings.landing_page.actions.link')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.video')}
bind:value={$landingPageSettings.header.banner.video}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
{:else}
<Field.Field>
<Button variant="outline" onclick={() => widgetControl('header.banner.image')}>
{$t('settings.landing_page.about.select_image')}
</Button>
</Field.Field>
{/if}
{#if $landingPageSettings.header.banner.image && $landingPageSettings.header.banner.type === 'image'}
<img alt="bannerImage" src={$landingPageSettings.header.banner.image} class="mt-2 w-full rounded-md" />
{/if}
<Field.Field orientation="horizontal">
<Switch
bind:checked={$landingPageSettings.header.banner.show}
onCheckedChange={() => (hasUnsavedChanges = true)}
/>
<Field.Label class="text-gray-600">
{$landingPageSettings.header.banner.show
? $t('settings.landing_page.actions.show_banner')
: $t('settings.landing_page.actions.hide_banner')}
</Field.Label>
</Field.Field>
{#if $handleOpenWidget.open && widgetKey === 'banner'}
<UploadWidget
bind:imageURL={$landingPageSettings.header.banner.image}
onchange={() => (hasUnsavedChanges = true)}
/>
{/if}
<Field.Field>
<Field.Label>{$t('settings.landing_page.background.title')}</Field.Label>
<Button variant="outline" onclick={() => widgetControl('background')}>
{$t('settings.landing_page.about.select_image')}
</Button>
</Field.Field>
{#if $landingPageSettings.header.background?.image}
<img alt="backgroundImage" src={$landingPageSettings.header.background.image} class="mt-2 w-full rounded-md" />
{/if}
<Field.Field orientation="horizontal">
<Switch
checked={$landingPageSettings.header.background?.show}
onCheckedChange={() => {
hasUnsavedChanges = true;
if (!$landingPageSettings.header.background) {
$landingPageSettings.header.background = {
image: '',
show: true
};
} else {
$landingPageSettings.header.background.show = !$landingPageSettings.header.background.show;
}
}}
/>
<Field.Label class="text-gray-600">
{$landingPageSettings.header.background?.show
? $t('settings.landing_page.background.show_background')
: $t('settings.landing_page.background.hide_background')}
</Field.Label>
</Field.Field>
{#if $handleOpenWidget.open && widgetKey === 'background'}
<UploadWidget
imageURL={$landingPageSettings.header.background?.image}
onchange={(imgUrl) => {
hasUnsavedChanges = true;
if (!$landingPageSettings.header.background) {
$landingPageSettings.header.background = {
image: imgUrl,
show: true
};
} else {
$landingPageSettings.header.background.image = imgUrl;
}
}}
/>
{/if}
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.landing_page.about.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.aboutUs.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Field.Label class="text-gray-600">
{$landingPageSettings.aboutUs.show
? $t('settings.landing_page.show_section')
: $t('settings.landing_page.hide_section')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.landing_page.about.title')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.aboutUs.title}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.about.content')}</Field.Label>
<Textarea
placeholder={$t('course.navItem.lessons.exercises.all_exercises.content')}
bind:value={$landingPageSettings.aboutUs.content}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label class="font-bold">{$t('settings.landing_page.about.upload_an_image')}</Field.Label>
<Button variant="outline" onclick={() => widgetControl('about-us')}>
{$t('settings.landing_page.about.select_image')}
</Button>
{#if $landingPageSettings.aboutUs.imageUrl}
<img alt="About us" src={$landingPageSettings.aboutUs.imageUrl} class="mt-2 w-full rounded-md" />
{/if}
{#if $handleOpenWidget.open && widgetKey === 'about-us'}
<UploadWidget bind:imageURL={$landingPageSettings.aboutUs.imageUrl} />
{/if}
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.landing_page.courses.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.courses.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Field.Label class="text-gray-600">
{$landingPageSettings.courses.show
? $t('settings.landing_page.courses.show_section')
: $t('settings.landing_page.courses.hide_section')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.landing_page.courses.title')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.courses.title}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.courses.title_highlight')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.highlight')}
bind:value={$landingPageSettings.courses.titleHighlight}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.courses.subtitle')}</Field.Label>
<Textarea
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
bind:value={$landingPageSettings.courses.subtitle}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.landing_page.faq.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.faq.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Field.Label class="text-gray-600">
{$landingPageSettings.faq.show
? $t('settings.landing_page.faq.show_section')
: $t('settings.landing_page.faq.hide_section')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.landing_page.faq.title')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.faq.title}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
{#each $landingPageSettings.faq.questions as item (item.id)}
<Field.Field>
<Field.Label>{$t('settings.landing_page.faq.question')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_question_here')}
bind:value={item.title}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.faq.answer')}</Field.Label>
<Textarea
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_answer_here')}
bind:value={item.content}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Button variant="ghost" size="icon" onclick={() => deleteFaq(item.id)}>
<TrashIcon size={16} />
</Button>
{/each}
{#if creatingNewQuestion}
<Field.Field>
<Field.Label>{$t('settings.landing_page.faq.question')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_question_here')}
bind:value={newQuestion.title}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.faq.answer')}</Field.Label>
<Textarea
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_answer_here')}
bind:value={newQuestion.content}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={saveNewFAQ}>
{$t('settings.landing_page.faq.save')}
</Button>
<Button variant="outline" onclick={cancelNewFAQ}>
{$t('settings.landing_page.faq.cancel')}
</Button>
</Field.Field>
{:else}
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={createNewFaq}>
{$t('settings.landing_page.faq.button')}
</Button>
</Field.Field>
{/if}
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.landing_page.contact_us.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.contact.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Field.Label class="text-gray-600">
{$landingPageSettings.contact.show
? $t('settings.landing_page.contact_us.show_section')
: $t('settings.landing_page.contact_us.hide_section')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.landing_page.contact_us.title')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.contact.title}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.contact_us.title_highlight')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.highlight')}
bind:value={$landingPageSettings.contact.titleHighlight}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.contact_us.subtitle')}</Field.Label>
<Textarea
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
bind:value={$landingPageSettings.contact.subtitle}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.contact_us.address')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.address')}
bind:value={$landingPageSettings.contact.address}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.contact_us.phone_number')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.number')}
bind:value={$landingPageSettings.contact.phone}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.contact_us.email')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.email')}
bind:value={$landingPageSettings.contact.email}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.landing_page.mailing_list.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.mailinglist.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Field.Label class="text-gray-600">
{$landingPageSettings.mailinglist.show
? $t('settings.landing_page.mailing_list.show_section')
: $t('settings.landing_page.mailing_list.hide_section')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.landing_page.mailing_list.title')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.mailinglist.title}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.mailing_list.subtitle')}</Field.Label>
<Textarea
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
bind:value={$landingPageSettings.mailinglist.subtitle}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.mailing_list.button_label')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.label')}
bind:value={$landingPageSettings.mailinglist.buttonLabel}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.landing_page.custom_links.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.customLinks.show} />
<Field.Label class="text-gray-600">
{$landingPageSettings.customLinks.show
? $t('settings.landing_page.custom_links.show_links')
: $t('settings.landing_page.custom_links.hide_links')}
</Field.Label>
</Field.Field>
<Field.Description>{$t('settings.landing_page.custom_links.description')}</Field.Description>
<Field.Group>
{#each $landingPageSettings.customLinks.links as link (link.id)}
<div class="flex flex-col gap-2">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field.Field>
<Field.Label>{$t('settings.landing_page.custom_links.label')}</Field.Label>
<Input placeholder={$t('settings.landing_page.custom_links.label_placeholder')} bind:value={link.label} />
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.custom_links.url')}</Field.Label>
<Input placeholder={$t('settings.landing_page.custom_links.url_placeholder')} bind:value={link.url} />
</Field.Field>
</div>
<div class="flex items-center justify-between">
<Field.Field orientation="horizontal">
<Checkbox id="new-tab-{link.id}" bind:checked={link.openInNewTab} />
<Field.Label class="text-sm">{$t('settings.landing_page.custom_links.new_tab')}</Field.Label>
</Field.Field>
<Button variant="ghost" size="icon" onclick={() => deleteCustomLink(link.id)}>
<TrashIcon size={16} />
</Button>
</div>
<Field.Separator />
</div>
{/each}
{#if creatingNewCustomLink}
<div class="mb-4 rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="mb-3 grid grid-cols-1 gap-4 md:grid-cols-2">
<Field.Field>
<Field.Label>{$t('settings.landing_page.custom_links.label')}</Field.Label>
<Input
placeholder={$t('settings.landing_page.custom_links.label_placeholder')}
bind:value={newCustomLink.label}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.custom_links.url')}</Field.Label>
<Input
placeholder={$t('settings.landing_page.custom_links.url_placeholder')}
bind:value={newCustomLink.url}
/>
</Field.Field>
</div>
<div class="flex items-center justify-between">
<Field.Field orientation="horizontal">
<input type="checkbox" bind:checked={newCustomLink.openInNewTab} class="mr-2" />
<Field.Label class="text-sm">{$t('settings.landing_page.custom_links.new_tab')}</Field.Label>
</Field.Field>
<div class="flex gap-2">
<Button variant="outline" onclick={saveNewCustomLink} class="px-3 py-1 text-sm">
{$t('settings.landing_page.custom_links.save')}
</Button>
<Button variant="outline" onclick={cancelNewCustomLink} class="px-3 py-1 text-sm">
{$t('settings.landing_page.custom_links.cancel')}
</Button>
</div>
</div>
</div>
{:else}
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={createNewCustomLink}>
{$t('settings.landing_page.custom_links.add')}
</Button>
</Field.Field>
{/if}
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.landing_page.footer.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.footer.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Field.Label class="text-gray-600">
{$landingPageSettings.footer.show
? $t('settings.landing_page.footer.show_section')
: $t('settings.landing_page.footer.hide_section')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.landing_page.footer.facebook')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.facebook')}
bind:value={$landingPageSettings.footer.facebook}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<!-- <TextField
label="Instagram"
placeholder="Write your Instagram link here"
className="mb-5"
bind:value={$landingPageSettings.footer.instagram}
/> -->
<Field.Field>
<Field.Label>{$t('settings.landing_page.footer.twitter')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.twitter')}
bind:value={$landingPageSettings.footer.twitter}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.footer.linkedin')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.linkedin')}
bind:value={$landingPageSettings.footer.linkedin}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
</Field.Group>
</Field.Set>
</Field.Group>
<style>
:global(.dark) {
--cp-text-color: #fff;
--cp-border-color: white;
--cp-text-color: white;
--cp-input-color: #555;
--cp-button-hover-color: #777;
}
:global(.dark .alpha) {
background: #333 !important;
}
</style>
@@ -0,0 +1,256 @@
<script lang="ts">
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import ZapIcon from '@lucide/svelte/icons/zap';
import debounce from 'lodash/debounce';
import ColorPicker from 'svelte-awesome-color-picker';
import { t } from '$lib/utils/functions/translations';
import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
import { injectCustomTheme, setCustomTheme, setTheme } from '$lib/utils/functions/theme';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { Input } from '@cio/ui/base/input';
import { Button } from '@cio/ui/base/button';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
import * as Field from '@cio/ui/base/field';
let avatar = $state<string | File | undefined>();
let hasUnsavedChanges = $state(false);
let hex = $state('');
$effect(() => {
const theme = $currentOrg.theme;
// if not a custom theme, don't update hex
if (!theme || theme.includes('theme-')) return;
// only update hex with value of `theme` when theme changes not when HEX changes
untrack(() => {
hex = theme;
});
});
const themes = {
rose: 'theme-rose',
green: 'theme-green',
orange: 'theme-orange',
violet: 'theme-violet',
default: ''
};
const saveTheme = debounce(async (theme: string) => {
await orgApi.update($currentOrg.id, {
theme
});
}, 700);
function handleChangeTheme(t = '') {
return async () => {
document.body.className = document.body.className
.split(' ')
.filter((c) => !c.includes('theme'))
.join(' ')
.concat(t ? ' ' : '', t);
$currentOrg.theme = t;
hex = '';
setTheme(t);
saveTheme(t);
};
}
async function handleCustomTheme() {
if (!hex) return;
injectCustomTheme(hex);
setCustomTheme('theme-custom');
$currentOrg.theme = hex;
saveTheme(hex);
}
export async function handleUpdate() {
await orgApi.update($currentOrg.id, {
name: $currentOrg.name,
avatar
});
if (orgApi.success) {
hasUnsavedChanges = false;
avatar = undefined;
}
}
function gotoSettings(pathname: string) {
goto(`${$currentOrgPath}/settings${pathname}`);
}
let isCustomTheme = $derived(hex && !hex.includes('theme-'));
</script>
<UnsavedChanges bind:hasUnsavedChanges />
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.organization.organization_profile.organization_name')}</Field.Label>
<Input bind:value={$currentOrg.name} oninput={() => (hasUnsavedChanges = true)} class="w-full lg:w-60" />
{#if orgApi.errors.name}
<Field.Error>{$t(orgApi.errors.name)}</Field.Error>
{/if}
</Field.Field>
<Field.Field>
<UploadImage
bind:avatar
src={$currentOrg.avatarUrl}
shape="rounded-md"
widthHeight="w-24 h-24"
change={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.organization.organization_profile.theme.heading')}</Field.Legend>
<Field.Description>{$t('settings.organization.organization_profile.theme.sub_heading')}</Field.Description>
<Field.Field>
<div class="flex items-center gap-5">
<button
class="rounded-full border-2 {$currentOrg.theme === themes.default &&
'border-[#1d4ee2]'} flex h-fit items-center justify-center"
onclick={handleChangeTheme(themes.default)}
aria-label="Default blue theme"
>
<div class="m-1 h-6 w-6 rounded-full bg-[#1d4ee2] md:h-6 md:w-6"></div>
</button>
<button
class="rounded-full border-2 {$currentOrg.theme === themes.rose &&
'border-[#be1241]'} flex h-fit items-center justify-center"
onclick={handleChangeTheme(themes.rose)}
aria-label="Rose theme"
>
<div class="m-1 h-6 w-6 rounded-full bg-[#be1241] md:h-6 md:w-6"></div>
</button>
<button
class="rounded-full border-2 {$currentOrg.theme === themes.green &&
'border-[#0c891b]'} flex h-fit items-center justify-center"
onclick={handleChangeTheme(themes.green)}
aria-label="Green theme"
>
<div class="m-1 h-6 w-6 rounded-full bg-[#0c891b] md:h-6 md:w-6"></div>
</button>
<button
class="rounded-full border-2 {$currentOrg.theme === themes.orange &&
'border-[#cc4902]'} flex h-fit items-center justify-center"
onclick={handleChangeTheme(themes.orange)}
aria-label="Orange theme"
>
<div class="m-1 h-6 w-6 rounded-full bg-[#cc4902] md:h-6 md:w-6"></div>
</button>
<button
class="rounded-full border-2 {$currentOrg.theme === themes.violet &&
'border-[#cf00ce]'} flex h-fit items-center justify-center"
onclick={handleChangeTheme(themes.violet)}
aria-label="Violet theme"
>
<div class="m-1 h-6 w-6 rounded-full bg-[#cf00ce] md:h-6 md:w-6"></div>
</button>
<div
class="h-auto w-fit border-2 {isCustomTheme
? 'border-primary-700'
: 'dark:border-neutral-700'} group relative rounded-full"
>
<!-- plus icon positioned over the color picker -->
<div
class="pointer-events-none absolute inset-0 flex items-center justify-center transition-opacity duration-200"
>
<svg
class="h-6 w-6 text-{isCustomTheme ? 'white' : 'black'} z-10 opacity-100 dark:text-white"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"></path>
</svg>
</div>
<ColorPicker position="responsive" label="" bind:hex on:input={handleCustomTheme} />
</div>
</div>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.organization.organization_profile.customize_lms.heading')}</Field.Legend>
<Field.Description>{$t('settings.organization.organization_profile.customize_lms.sub_heading')}</Field.Description>
<Field.Description>{$t('settings.organization.organization_profile.customize_lms.body')}</Field.Description>
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={() => gotoSettings('/customize-lms')}>
{$t('settings.organization.organization_profile.customize_lms.button')}
</Button>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.organization.organization_profile.custom_domain.heading')}</Field.Legend>
<Field.Description>{$t('settings.organization.organization_profile.custom_domain.sub_heading')}</Field.Description>
<Field.Description>{$t('settings.organization.organization_profile.custom_domain.body')}</Field.Description>
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={() => gotoSettings('/domains')}>
{#if $isFreePlan}
<ZapIcon size={16} class="filled" />
{/if}
{$t('settings.organization.organization_profile.custom_domain.button')}
</Button>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.organization.organization_profile.team.heading')}</Field.Legend>
<Field.Description>{$t('settings.organization.organization_profile.team.sub_heading')}</Field.Description>
<Field.Description>{$t('settings.organization.organization_profile.team.body')}</Field.Description>
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={() => gotoSettings('/teams')}>
{#if $isFreePlan}
<ZapIcon size={16} class="filled" />
{/if}
{$t('settings.organization.organization_profile.team.button')}
</Button>
</Field.Field>
</Field.Set>
</Field.Group>
<style>
:global(.dark) {
--cp-text-color: #fff;
--cp-border-color: white;
--cp-text-color: white;
--cp-input-color: #555;
--cp-button-hover-color: #777;
}
:global(.dark .alpha) {
background: #333 !important;
}
</style>
@@ -0,0 +1,168 @@
<script lang="ts">
import { page } from '$app/state';
import type { TLocale } from '@cio/db/types';
import { profile } from '$lib/utils/store/user';
import { CircleCheckBig } from '@lucide/svelte';
import { profileApi } from '$lib/features/auth/api/profile.svelte';
import { t } from '$lib/utils/functions/translations';
import LanguagePicker from '../components/language-picker.svelte';
import { Input } from '@cio/ui/base/input';
import { Button } from '@cio/ui/base/button';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
import * as Field from '@cio/ui/base/field';
let avatar = $state<string | File | undefined>();
let hasLangChanged = $state(false);
let locale = $derived<TLocale | undefined>($profile.locale || undefined);
let hasUnsavedChanges = $state(false);
let email = $derived($profile.email || '');
let isChangingEmail = $state(false);
let emailChangeInitiated = $state(false);
export async function handleUpdate() {
await profileApi.submit(
{
fullname: $profile.fullname,
username: $profile.username,
locale,
avatar
},
$profile,
hasLangChanged,
locale
);
if (profileApi.success) {
hasUnsavedChanges = false;
avatar = undefined;
}
}
async function handleEmailChange() {
if (email === $profile.email) {
return;
}
const url = new URL(window.location.href);
url.searchParams.set('trigger', 'app');
await profileApi.changeEmail({
newEmail: email,
callbackURL: url.toString()
});
if (profileApi.success) {
isChangingEmail = false;
emailChangeInitiated = true;
// Reset email field to current email after successful change request
email = $profile.email || '';
}
}
const isVerificationSent = $derived(page.url.searchParams.get('trigger') === 'app');
</script>
<UnsavedChanges bind:hasUnsavedChanges />
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Legend>{$t('settings.profile.profile_picture.heading')}</Field.Legend>
<Field.Field>
<UploadImage
bind:avatar
src={$profile.avatarUrl}
widthHeight="w-16 h-16 lg:w-24 lg:h-24"
isDisabled={profileApi.isLoading}
change={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.profile.personal_information.heading')}</Field.Legend>
{#if isVerificationSent}
<div class="flex items-center gap-2 rounded-md border border-gray-200 p-2 text-green-500">
<CircleCheckBig class="size-8" />
<p class="text-sm">{$t('settings.profile.personal_information.email_change_verification_sent')}</p>
</div>
{/if}
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.profile.personal_information.full_name')}</Field.Label>
<Input bind:value={$profile.fullname} class="" oninput={() => (hasUnsavedChanges = true)} />
{#if profileApi.errors.fullname}
<Field.Error>{$t(profileApi.errors.fullname)}</Field.Error>
{/if}
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.profile.personal_information.username')}</Field.Label>
<Input bind:value={$profile.username} oninput={() => (hasUnsavedChanges = true)} />
{#if profileApi.errors.username}
<Field.Error>{$t(profileApi.errors.username)}</Field.Error>
{/if}
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.profile.personal_information.email')}</Field.Label>
<Input
bind:value={email}
class="w-full"
type="email"
disabled={profileApi.isLoading}
oninput={() => {
if (email !== $profile.email) {
isChangingEmail = true;
emailChangeInitiated = false;
}
}}
/>
{#if profileApi.errors.newEmail}
<Field.Error>{$t(profileApi.errors.newEmail)}</Field.Error>
{/if}
{#if isChangingEmail && email !== $profile.email}
<div class="mt-2 flex gap-2">
<Button
variant="default"
class="text-sm"
loading={profileApi.isLoading}
disabled={profileApi.isLoading}
onclick={handleEmailChange}
>
{$t('settings.profile.personal_information.confirm')}
</Button>
<Button
variant="ghost"
class="text-primary-700 text-sm"
disabled={profileApi.isLoading}
onclick={() => {
email = $profile.email || '';
isChangingEmail = false;
emailChangeInitiated = false;
profileApi.errors.newEmail = '';
}}
>
{$t('settings.profile.personal_information.cancel')}
</Button>
</div>
{/if}
{#if emailChangeInitiated}
<div class="mt-2 flex items-center gap-2 rounded-md border border-gray-200 p-2 text-amber-500">
<CircleCheckBig class="size-8" />
<p class="text-sm">{$t('settings.profile.personal_information.email_change_verification_note')}</p>
</div>
{/if}
</Field.Field>
<Field.Field>
<LanguagePicker
change={() => (hasUnsavedChanges = true)}
bind:hasLangChanged
bind:value={locale}
className=""
/>
</Field.Field>
</Field.Group>
</Field.Set>
</Field.Group>
@@ -0,0 +1,158 @@
<script lang="ts">
import * as Select from '@cio/ui/base/select';
import { Spinner } from '@cio/ui/base/spinner';
import { profile } from '$lib/utils/store/user';
import { isFreePlan, currentOrg } from '$lib/utils/store/org';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import type { OrgTeamMember } from '$lib/utils/types/org';
import { ROLE } from '@cio/utils/constants';
import { ROLE_LABEL } from '$lib/utils/constants/roles';
import { validateEmailInString } from '$lib/utils/functions/validator';
import { Badge } from '@cio/ui/base/badge';
import { Input } from '@cio/ui/base/input';
import { Button } from '@cio/ui/base/button';
import { ComingSoon, UpgradeBanner } from '$lib/features/ui';
import * as Field from '@cio/ui/base/field';
let emailsStr = $state('');
let errorMessage = $state('');
let role = $state(ROLE.TUTOR.toString());
let isRemoving: number | null = $state(null);
async function onSendInvite() {
const { hasError, error: _error, emails } = validateEmailInString(emailsStr);
if (hasError) {
errorMessage = _error;
return;
}
errorMessage = '';
await orgApi.inviteTeamMembers($currentOrg.id, emails, parseInt(role));
if (orgApi.success) {
snackbar.success('snackbar.team_members.invite_sent');
emailsStr = '';
} else {
// Errors are handled by orgApi, but check for field-specific errors
if (orgApi.errors.emails) {
errorMessage = orgApi.errors.emails;
} else if (orgApi.errors.general) {
snackbar.error(orgApi.errors.general);
}
}
}
async function onRemove(id: number) {
isRemoving = id;
await orgApi.removeTeamMember($currentOrg.id, id);
if (orgApi.success) {
snackbar.success('snackbar.team_members.remove_success');
} else {
snackbar.error('snackbar.team_members.remove_fail');
}
isRemoving = null;
}
const isTeamMemberAdmin = (members: OrgTeamMember[], profileId: string | undefined) => {
return members.some((member) => member.profileId === profileId && member.isAdmin);
};
$effect(() => {
orgApi.getOrgTeam($currentOrg.id);
});
</script>
<UpgradeBanner>{$t('upgrade.team')}</UpgradeBanner>
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Legend>{$t('course.navItem.people.teams.add')}</Field.Legend>
<Field.Description class="mb-5">{$t('course.navItem.people.teams.add_team')}</Field.Description>
<Field.Group>
<Field.Field>
<Field.Label>{$t('course.navItem.people.teams.invite')}</Field.Label>
<Input
placeholder={$t('course.navItem.people.teams.placeholder')}
bind:value={emailsStr}
class="w-full"
disabled={$isFreePlan}
/>
{#if errorMessage}
<Field.Error>{errorMessage}</Field.Error>
{/if}
</Field.Field>
<Field.Field>
<Field.Label>{$t('course.navItem.people.teams.role')}</Field.Label>
<Select.Root type="single" bind:value={role} disabled={$isFreePlan}>
<Select.Trigger id="invite-role" class="w-40">
<p>{role ? $t(ROLE_LABEL[role]) : $t('course.navItem.people.teams.select_role')}</p>
</Select.Trigger>
<Select.Content>
<Select.Item value={ROLE.ADMIN.toString()}>{$t(ROLE_LABEL[ROLE.ADMIN])}</Select.Item>
<Select.Item value={ROLE.TUTOR.toString()}>{$t(ROLE_LABEL[ROLE.TUTOR])}</Select.Item>
</Select.Content>
</Select.Root>
</Field.Field>
<Button
variant="default"
onclick={onSendInvite}
loading={orgApi.isLoading}
disabled={orgApi.isLoading || $isFreePlan}
>
{$t('course.navItem.people.teams.send_invite')}
</Button>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('course.navItem.people.teams.members')}</Field.Legend>
<Field.Group>
{#if orgApi.isLoading && orgApi.teamMembers.length === 0}
<Spinner class="size-10! text-blue-700!" />
{:else if orgApi.teamMembers.length > 0}
{#each orgApi.teamMembers as teamMember}
<Field.Field>
<div class="flex items-center justify-between">
<div class="flex items-center">
<p class="mr-3 text-sm text-gray-500 dark:text-white">
{teamMember.email}
</p>
<Badge variant="secondary" class="mr-3 text-xs">{$t(teamMember.role)}</Badge>
{#if !teamMember.verified}
<Badge variant="outline" class="bg-yellow-200 text-xs text-yellow-700">
{$t('course.navItem.people.teams.invite_sent')}
</Badge>
{:else if teamMember.profileId === $profile.id}
<ComingSoon label={$t('course.navItem.people.teams.you')} />
{/if}
</div>
{#if teamMember.profileId !== $profile.id && isTeamMemberAdmin(orgApi.teamMembers, $profile.id)}
<Button
variant="ghost"
onclick={() => onRemove(teamMember.id)}
loading={isRemoving === teamMember.id}
disabled={isRemoving === teamMember.id}
class="text-red-500 hover:text-red-700"
>
{$t('course.navItem.people.teams.remove')}
</Button>
{/if}
</div>
</Field.Field>
{/each}
{/if}
</Field.Group>
</Field.Set>
</Field.Group>
@@ -0,0 +1,116 @@
import type { OrgLandingPageJson } from '$lib/utils/types/org';
import type { Writable } from 'svelte/store';
import { writable } from 'svelte/store';
export const landingPageSettings: Writable<OrgLandingPageJson> = writable({
header: {
title: 'Web Development Courses for',
titleHighlight: 'Everyone',
subtitle: 'Practical project-based courses that are easy to understand and straight to the point',
action: {
label: 'Start learning Today',
link: '#contact',
redirect: false
},
banner: {
video: 'https://www.youtube.com/watch?v=qqAYW7uxErI',
image: '',
type: 'video',
show: true
},
background: {
image: '',
show: true
},
show: true
},
aboutUs: {
title: 'Our Story',
content:
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.',
imageUrl: 'https://pgrest.classroomio.com/storage/v1/object/public/avatars/landingpage/riverside.jpeg',
show: true
},
courses: {
title: 'Explore our',
titleHighlight: 'Courses',
subtitle: 'Find courses you will love from best teachers all over the world🌎.',
show: true
},
faq: {
title: 'Frequently Asked Questions.',
questions: [
{
id: 1,
title: 'Lorem ipsum dolor sit, amet con',
content:
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
},
{
id: 2,
title: 'Lorem ipsum dolor sit, amet con',
content:
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
},
{
id: 3,
title: 'Lorem ipsum dolor sit, amet con',
content:
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
}
],
show: true
},
contact: {
title: 'Have you got any',
titleHighlight: 'Question?',
subtitle: 'Reach out to us with any question you might have.',
address: 'Ashwoodfield House Farm, Kidderminster Rd, Stourbridge, Kingswinford DY6 0AA',
phone: '+4407339904995',
email: 'help@lms.com',
show: true
},
mailinglist: {
title: 'Join our mailing list',
subtitle:
'We are constantly releasing new courses and sharing them with our email list. Subscribe to get notified once we release a new course',
buttonLabel: 'Subscribe',
show: true
},
customLinks: {
show: true,
links: [
{
id: 1,
label: 'About Us',
url: '#aboutus',
openInNewTab: false
},
{
id: 2,
label: 'Courses',
url: '#courses',
openInNewTab: false
},
{
id: 3,
label: 'FAQ',
url: '#faq',
openInNewTab: false
},
{
id: 4,
label: 'Contact Us',
url: '#contact',
openInNewTab: false
}
]
},
footer: {
facebook: '#',
instagram: '#',
twitter: '#',
linkedin: '#',
show: true
}
});
@@ -0,0 +1,176 @@
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
export interface SetupItem {
id: string;
title: string;
desc: string;
is_completed: boolean;
button_label: string;
}
export interface SetupProgressData {
setup: SetupItem[];
courses: Array<{ id: string; [key: string]: unknown }>;
lessons: Array<{ id: string; [key: string]: unknown }>;
orgSiteName: string;
}
type TCreateSetupData = {
orgHasAvatarUrl?: boolean;
isCourseCreated?: boolean;
isLessonCreated?: boolean;
isExerciseCreated?: boolean;
isCoursePublished?: boolean;
};
// Base setup data structure - created once
const createSetupData = ({
orgHasAvatarUrl = false,
isCourseCreated = false,
isLessonCreated = false,
isExerciseCreated = false,
isCoursePublished = false
}: TCreateSetupData = {}): SetupItem[] => [
{
id: 'profile',
title: 'setup.personal_profile.title',
desc: 'setup.personal_profile.desc',
is_completed: false,
button_label: 'setup.personal_profile.button_label'
},
{
id: 'organization',
title: 'setup.organization_profile.title',
desc: 'setup.organization_profile.desc',
is_completed: orgHasAvatarUrl,
button_label: 'setup.organization_profile.button_label'
},
{
id: 'course',
title: 'setup.course.title',
desc: 'setup.course.desc',
is_completed: isCourseCreated,
button_label: 'setup.course.button_label'
},
{
id: 'lesson',
title: 'setup.lesson.title',
desc: 'setup.lesson.desc',
is_completed: isLessonCreated,
button_label: 'setup.lesson.button_label'
},
{
id: 'exercise',
title: 'setup.exercise.title',
desc: 'setup.exercise.desc',
is_completed: isExerciseCreated,
button_label: 'setup.exercise.button_label'
},
{
id: 'publish',
title: 'setup.publish_course.title',
desc: 'setup.publish_course.desc',
is_completed: isCoursePublished,
button_label: 'setup.publish_course.button_label'
}
];
/**
* API class for setup progress operations
*/
class SetupProgressApi extends BaseApiWithErrors {
progress = $state<SetupProgressData>({
orgSiteName: '',
setup: [],
courses: [],
lessons: []
});
constructor() {
super();
this.isLoading = true;
}
/**
* Computed setup list with profile completion check
* This computation is shared across components to avoid duplication
* Note: Profile completion check should be done in components using $profile
* This returns the base setup list, and components can derive the final list
*/
get setupList(): SetupItem[] {
return this.progress.setup || [];
}
/**
* Fetches setup progress data for an organization
* @param siteName Organization site name
*/
async fetchSetupProgress(siteName: string) {
await this.execute<typeof classroomio.organization.setup.$get>({
requestFn: () =>
classroomio.organization.setup.$get({
query: { siteName }
}),
logContext: 'fetching setup progress',
onSuccess: (response) => {
if (!response.data) {
// Set default empty state if no data
const setup = createSetupData();
this.progress = {
orgSiteName: siteName,
setup,
courses: [],
lessons: []
};
return;
}
const {
isCourseCreated,
isCoursePublished,
orgHasAvatarUrl,
courseData,
lessonData,
isLessonCreated,
isExerciseCreated
} = response.data;
const setup = createSetupData({
orgHasAvatarUrl,
isCourseCreated,
isLessonCreated,
isExerciseCreated,
isCoursePublished
});
this.progress = {
orgSiteName: siteName,
setup,
courses: courseData || [],
lessons: lessonData || []
};
},
onError: (error) => {
console.error('Error fetching setup progress:', error);
// Set default empty state on error
const setup = createSetupData();
this.progress = {
orgSiteName: siteName,
setup,
courses: [],
lessons: []
};
}
});
}
}
export const setupProgressApi = new SetupProgressApi();
/**
* Calculates progress percentage from setup items
*/
export function calculateProgress(setupItems: SetupItem[]): number {
if (!setupItems || setupItems.length === 0) return 0;
const completed = setupItems.filter((item) => item.is_completed).length;
return Math.round((completed / setupItems.length) * 100);
}
@@ -0,0 +1,53 @@
<script lang="ts">
import { calculateProgress, setupProgressApi } from '../api/setup-progress.svelte';
import type { SetupItem } from '../api/setup-progress.svelte';
import { Skeleton } from '@cio/ui/base/skeleton';
let { setupItems, size = 'default' } = $props<{
setupItems?: SetupItem[];
size?: 'small' | 'default';
}>();
const effectiveSetupItems = $derived(setupItems ?? setupProgressApi.progress.setup ?? []);
const progressPercentage = $derived(calculateProgress(effectiveSetupItems));
const isLoading = $derived(setupProgressApi.isLoading);
const sizeClass = $derived(size === 'small' ? 'size-10' : 'size-14');
</script>
<div class="relative shrink-0">
{#if isLoading}
<!-- Loading skeleton - round circle -->
<Skeleton class="{sizeClass} rounded-full" />
{:else}
<svg class="{sizeClass} -rotate-90" viewBox="0 0 100 100">
<!-- Background circle -->
<circle
cx="50"
cy="50"
r="45"
fill="none"
stroke="currentColor"
stroke-width="8"
class="text-gray-200 dark:text-gray-700"
/>
<!-- Progress circle -->
<circle
cx="50"
cy="50"
r="45"
fill="none"
stroke="currentColor"
stroke-width="8"
stroke-linecap="round"
class="text-green-600"
stroke-dasharray={2 * Math.PI * 45}
stroke-dashoffset={2 * Math.PI * 45 * (1 - progressPercentage / 100)}
style="transition: stroke-dashoffset 0.5s ease-in-out;"
/>
</svg>
<!-- Center text -->
<div class="absolute inset-0 flex flex-col items-center justify-center">
<p class="{size === 'small' ? 'text-[10px]' : 'text-sm'} font-bold">{progressPercentage}%</p>
</div>
{/if}
</div>
@@ -0,0 +1,124 @@
<script lang="ts">
import { page } from '$app/state';
import Avatar from '$lib/components/Avatar/index.svelte';
import { t } from '$lib/utils/functions/translations';
import { currentOrg } from '$lib/utils/store/org';
import GoogleIconColored from '$lib/components/Icons/GoogleIconColored.svelte';
import { authClient } from '$lib/utils/services/auth/client';
import * as Card from '@cio/ui/base/card';
import { Button } from '@cio/ui/base/button';
import { Separator } from '@cio/ui/base/separator';
import { preventDefault } from '$lib/utils/functions/svelte';
import { ROUTE } from '$lib/utils/constants/routes';
interface Props {
isLogin?: boolean;
showOnlyContent?: boolean;
isLoading?: boolean;
showLogo?: boolean;
hideGoogleAuth?: boolean;
redirectPathname?: string;
handleSubmit?: () => void;
children?: import('svelte').Snippet;
}
let {
isLogin = true,
showOnlyContent = false,
isLoading = false,
showLogo = false,
hideGoogleAuth = false,
redirectPathname = '',
handleSubmit = () => {},
children
}: Props = $props();
async function signInWithGoogle() {
if (isLoading) {
return;
}
const params = new URLSearchParams(window.location.search);
console.log({ params });
// const redirectTo = `https://app.classroomio.com?forwardTo=${
// window.location.origin + params.get('redirect')
// }`;
const pathname = redirectPathname || params.get('redirect') || '';
const redirectTo = `${window.location.origin + pathname}`;
const errorCallbackURL = `${window.location.origin + ROUTE.AUTH_FAILED}`;
console.log({ redirectTo });
try {
console.log('signInWithGoogle');
const data = await authClient.signIn.social({
provider: 'google',
callbackURL: redirectTo,
errorCallbackURL: errorCallbackURL
});
console.log('data', data);
} catch (error) {
console.log('catch error', error);
}
}
</script>
<div class="flex min-h-screen w-full items-center justify-center p-4">
<Card.Root class="ui:w-full max-w-[400px]">
{#if !showOnlyContent || showLogo}
<Card.Header class="ui:flex ui:flex-col ui:items-center ui:gap-4">
<Avatar
src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : '/logo-192.png'}
name={$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}
shape="rounded-md"
width="w-10"
height="max-h-10"
className="mr-2"
/>
<a href="/">
<Card.Title class="ui:text-2xl ui:font-bold">
{isLogin ? $t('login.welcome') : $t('login.create_account')}
</Card.Title>
</a>
{#if isLogin}
<Card.Description class="ui:text-center">Sign in to continue</Card.Description>
{/if}
</Card.Header>
{/if}
<Card.Content>
<form onsubmit={preventDefault(handleSubmit)}>
{@render children?.()}
</form>
{#if !showOnlyContent && !hideGoogleAuth}
<div class="mt-6 flex flex-col gap-6">
<div class="relative flex items-center justify-center">
<Separator />
<span class="ui:bg-card ui:text-muted-foreground absolute px-2 text-sm"> Or continue With </span>
</div>
<Button variant="outline" onclick={signInWithGoogle} disabled={isLoading} class="w-full">
<GoogleIconColored />
<span>
{isLogin ? $t('login.login_with_google') : $t('login.signup_with_google')}
</span>
</Button>
</div>
{/if}
</Card.Content>
{#if !showOnlyContent}
<Card.Footer class="flex-col gap-2 border-t pt-6">
<p class="text-muted-foreground text-center text-sm">
{#if isLogin}
{$t('login.not_registered_yet')}
<a class="text-primary hover:underline" href="/signup{page.url.search}">{$t('login.signup')}</a>
{:else}
{$t('login.already_have_account')}
<a class="text-primary hover:underline" href="/login{page.url.search}">{$t('login.login')}</a>
{/if}
</p>
</Card.Footer>
{/if}
</Card.Root>
</div>
@@ -5,6 +5,6 @@
let { label = $t('course.navItem.lessons.exercises.new_exercise_modal.coming_soon') } = $props();
</script>
<Badge variant="outline" class="ui:text-white bg-blue-500 dark:bg-blue-600 dark:text-black">
<Badge variant="outline" class="ui:text-white border-none! bg-blue-600 dark:bg-blue-700 dark:text-black">
{label}
</Badge>
+6 -1
View File
@@ -1,3 +1,8 @@
export { default as AppHeader } from './navigation/app-header.svelte';
export { default as AuthUI } from './auth-ui.svelte';
export { default as ComingSoon } from './coming-soon.svelte';
export { default as PageLoadProgress } from './page-load-progress.svelte';
export { default as AppHeader } from './navigation/app-header.svelte';
export { default as PoweredBy } from './upgrade-powered-by.svelte';
export { default as RoleBasedSecurity } from './role-based-security.svelte';
export { default as UpgradeBanner } from './upgrade-banner.svelte';
export { default as UpgradeModal } from './upgrade-modal.svelte';
@@ -30,7 +30,7 @@
</script>
<Breadcrumb.Root>
<Breadcrumb.List>
<Breadcrumb.List class="flex-nowrap!">
<Breadcrumb.Item>
<OrgSwitcher variant="breadcrumb" />
</Breadcrumb.Item>
@@ -6,10 +6,21 @@
import * as Popover from '@cio/ui/base/popover';
import Search from '../search.svelte';
import AppBreadcrumbs from './app-breadcrumbs.svelte';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import * as Empty from '@cio/ui/base/empty';
import { currentOrg } from '$lib/utils/store/org';
import { setupProgressApi } from '$lib/features/setup/api/setup-progress.svelte';
import AppSetup from './app-setup.svelte';
$effect(() => {
if ($currentOrg.siteName) {
setupProgressApi.fetchSetupProgress($currentOrg.siteName);
}
});
</script>
<header
class="group-has-data-[collapsible=icon]/sidebar-wrapper:h-8 border-border flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear"
class="group-has-data-[collapsible=icon]/sidebar-wrapper:h-8 border-border ui:bg-background bg-background sticky top-0 z-50 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear"
>
<div class="flex w-full items-center gap-2 px-4">
<Sidebar.Trigger />
@@ -22,6 +33,8 @@
<span class="grow"></span>
<AppSetup />
<Search />
<Popover.Root>
@@ -31,11 +44,21 @@
</Button>
</Popover.Trigger>
<Popover.Content>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-2">
<p class="text-sm">Your notifications will show here</p>
</div>
</div>
<Empty.Root class="ui:from-muted/50 ui:to-background ui:h-full ui:bg-gradient-to-b ui:from-30%">
<Empty.Header>
<Empty.Media variant="icon">
<BellIcon />
</Empty.Media>
<Empty.Title>No Notifications</Empty.Title>
<Empty.Description>You're all caught up. New notifications will appear here.</Empty.Description>
</Empty.Header>
<Empty.Content>
<Button variant="outline" size="sm">
<RefreshCcwIcon />
Refresh
</Button>
</Empty.Content>
</Empty.Root>
</Popover.Content>
</Popover.Root>
</div>
@@ -0,0 +1,64 @@
<script lang="ts">
import * as HoverCard from '@cio/ui/base/hover-card';
import { Button } from '@cio/ui/base/button';
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
import CircleIcon from '@lucide/svelte/icons/circle';
import SetupProgress from '$lib/features/setup/components/setup-progress.svelte';
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import BadgeCheckIcon from '@lucide/svelte/icons/badge-check';
import { calculateProgress, setupProgressApi } from '$lib/features/setup/api/setup-progress.svelte';
function handleRefresh() {
if ($currentOrg.siteName) {
setupProgressApi.fetchSetupProgress($currentOrg.siteName);
}
}
const setupList = $derived(
setupProgressApi.setupList.map((item) => {
if (item.id === 'profile') {
return {
...item,
is_completed: !$profile.avatarUrl?.includes('avatars/avatar.png')
};
}
return item;
})
);
const progressPercentage = $derived(calculateProgress(setupList));
</script>
{#if progressPercentage < 100}
<HoverCard.Root openDelay={200}>
<HoverCard.Trigger class="hidden md:block">
<SetupProgress size="small" setupItems={setupList} />
</HoverCard.Trigger>
<HoverCard.Content class="hidden w-80 md:block">
{#if $currentOrg.id}
<div class="space-y-2">
<div class="flex items-center justify-between">
<h4 class="text-sm font-semibold">{$t('setup.your_checklist')}</h4>
<Button variant="secondary" size="icon" onclick={handleRefresh} disabled={setupProgressApi.isLoading}>
<RefreshCcwIcon class="size-3 {setupProgressApi.isLoading ? 'animate-spin' : ''}" />
</Button>
</div>
{#each setupList as item}
<div class="flex items-center gap-2 text-sm">
{#if item.is_completed}
<BadgeCheckIcon class="custom text-green-600! size-4 shrink-0 opacity-50" />
<span class="text-muted-foreground line-through opacity-50">{$t(item.title)}</span>
{:else}
<CircleIcon class="size-4 shrink-0 text-gray-400" />
<span>{$t(item.title)}</span>
{/if}
</div>
{/each}
</div>
{/if}
</HoverCard.Content>
</HoverCard.Root>
{/if}
@@ -64,7 +64,7 @@ export function generateBreadcrumbs(
// Handle items with query params (like settings tabs)
if (matchedNavItem?.items && searchParams) {
const subItem = matchedNavItem.items.find((sub) => isActive(pathWithQuery, sub.url, true));
const subItem = matchedNavItem.items.find((sub) => isActive(pathWithQuery, sub.url, undefined, true));
if (subItem) {
breadcrumbs.push({
label: subItem.title,
@@ -31,6 +31,7 @@ export interface NavItemConfig {
useHashUrl?: boolean; // Use '#' as URL (for collapsible items like settings)
nestedRoutes?: NestedRouteConfig[]; // Static nested routes
supportsDynamicSegment?: boolean; // Supports dynamic segments (like [slug])
matchPattern?: string | ((orgSlug: string) => string); // Regex pattern for route matching
}
export interface NestedRouteConfig {
@@ -43,18 +44,21 @@ const baseNavConfig: NavItemConfig[] = [
{
titleKey: 'org_navigation.dashboard',
path: '',
icon: LayoutDashboardIcon
icon: LayoutDashboardIcon,
matchPattern: '^/org/[^/]+/?$' // Exact match only
},
{
titleKey: 'org_navigation.courses',
path: '/courses',
icon: LibraryBigIcon
icon: LibraryBigIcon,
matchPattern: '^/org/[^/]+/courses(/.*)?$' // Matches nested routes
},
{
titleKey: 'org_navigation.community',
path: '/community',
icon: MessageSquareMoreIcon,
supportsDynamicSegment: true, // Supports /community/[slug]
matchPattern: '^/org/[^/]+/community(/.*)?$', // Matches nested routes
nestedRoutes: [
{
path: 'ask',
@@ -65,19 +69,22 @@ const baseNavConfig: NavItemConfig[] = [
{
titleKey: 'org_navigation.audience',
path: '/audience',
icon: UsersIcon
icon: UsersIcon,
matchPattern: '^/org/[^/]+/audience(/.*)?$' // Matches nested routes
},
{
titleKey: 'org_navigation.setup',
path: '/setup',
icon: Settings2Icon,
requiresAdmin: true
requiresAdmin: true,
matchPattern: '^/org/[^/]+/setup(/.*)?$' // Matches nested routes
},
{
titleKey: 'org_navigation.settings',
path: '/settings',
icon: CogIcon,
useHashUrl: true, // Use '#' for collapsible parent
matchPattern: '^/org/[^/]+/settings(/.*)?$', // Matches nested routes
items: [
{
titleKey: 'Profile',
@@ -85,16 +92,20 @@ const baseNavConfig: NavItemConfig[] = [
},
{
titleKey: 'Organization',
path: '/settings?tab=org'
path: '/settings/org'
},
{
titleKey: 'Landing Page',
path: '/settings?tab=landingpage'
path: '/settings/landingpage'
},
{
titleKey: 'Billing',
path: '/settings?tab=billing'
path: '/settings/billing'
}
// {
// titleKey: 'Integrations',
// path: '/settings/integrations'
// }
],
nestedRoutes: [
{
@@ -134,13 +145,17 @@ export function getOrgNavigationItems(
const url = config.path === '' ? currentOrgPath : `${currentOrgPath}${config.path}`;
const fullPath = config.path === '' ? `/org/${currentOrg.siteName}` : `/org/${currentOrg.siteName}${config.path}`;
// Extract match pattern (handle function case)
const matchPattern =
typeof config.matchPattern === 'function' ? config.matchPattern(currentOrg.siteName) : config.matchPattern;
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,
isActive: isActive(pagePathname, fullPath, matchPattern),
isExpanded: config.items ? isActive(pagePathname, fullPath, matchPattern) : undefined,
useHashUrl: config.useHashUrl,
nestedRoutes: config.nestedRoutes,
supportsDynamicSegment: config.supportsDynamicSegment
@@ -0,0 +1,42 @@
<script lang="ts">
import { profile } from '$lib/utils/store/user';
import { group } from '$lib/components/Course/store';
import { isOrgAdmin } from '$lib/utils/store/org';
interface Props {
allowedRoles?: number[];
onDenied?: any;
// Since orgAdmin can see all, we need a way to show the content only to students
onlyStudent?: boolean;
children?: import('svelte').Snippet;
}
let { allowedRoles = [], onDenied = () => {}, onlyStudent = false, children }: Props = $props();
const userRole: number = $derived.by(() => {
const user = $group.people.find((person) => person.profile_id === $profile.id);
return user ? user.role_id : 0;
});
const show = $derived.by(() => {
if (onlyStudent) {
return isAllowed(userRole);
} else {
return isAllowed(userRole) || !!$isOrgAdmin;
}
});
function isAllowed(userRole) {
return allowedRoles.includes(userRole);
}
$effect(() => {
if (!$isOrgAdmin && $group.people.length && !isAllowed(userRole)) {
onDenied();
}
});
</script>
{#if show}
{@render children?.()}
{/if}
@@ -44,7 +44,7 @@
<Sidebar.MenuSub>
{#each item.items ?? [] as subItem (subItem.title)}
<Sidebar.MenuSubItem>
<Sidebar.MenuSubButton isActive={isActive(pathWithQuery, subItem.url, true)}>
<Sidebar.MenuSubButton isActive={isActive(pathWithQuery, subItem.url, undefined, true)}>
{#snippet child({ props })}
<a href={subItem.url} {...props}>
<span>{subItem.title}</span>
@@ -22,10 +22,10 @@
<Sidebar.Content>
<NavMain />
<UpgradeTrigger />
</Sidebar.Content>
<Sidebar.Footer>
<Sidebar.Footer class="gap-4!">
<UpgradeTrigger />
<SidebarFooterMenu />
</Sidebar.Footer>
@@ -1,7 +1,6 @@
<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 { Button } from '@cio/ui/base/button';
import RocketIcon from '@lucide/svelte/icons/rocket';
import { t } from '$lib/utils/functions/translations';
import { openUpgradeModal } from '$lib/utils/functions/org';
import { isFreePlan } from '$lib/utils/store/org';
@@ -11,24 +10,18 @@
</script>
{#if $isFreePlan}
<Sidebar.Content>
<div class="animate-icon">
{#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"
class="animate-gradient-border mx-2 flex flex-col items-center justify-center gap-4 rounded-md px-2 py-6 text-center transition-all ease-in-out"
>
<SparklesIcon class="my-3 size-6" />
<RocketIcon class="rocket-launch 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" />
<Button data-sidebar="upgrade-trigger" data-slot="upgrade-trigger" type="button" onclick={openUpgradeModal}>
<RocketIcon class="rocket-launch custom size-4" />
{$t('org_navigation.upgrade')}
</Button>
</div>
@@ -38,13 +31,13 @@
data-slot="upgrade-trigger"
variant="ghost"
size="icon"
class="mx-1 flex w-full items-center justify-center"
class="rocket-launch"
type="button"
onclick={openUpgradeModal}
>
<SparklesIcon class="custom size-4" />
<RocketIcon class="custom size-4" />
<span class="sr-only">{$t('org_navigation.upgrade')}</span>
</Button>
{/if}
</Sidebar.Content>
</div>
{/if}
@@ -0,0 +1,41 @@
<script lang="ts">
import { openUpgradeModal } from '$lib/utils/functions/org';
import ZapIcon from '@lucide/svelte/icons/zap';
import { isFreePlan } from '$lib/utils/store/org';
import * as Item from '@cio/ui/base/item';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
interface Props {
className?: string;
onClick?: any;
children?: import('svelte').Snippet;
}
let { className = '', onClick = () => {}, children }: Props = $props();
</script>
{#if $isFreePlan}
<Item.Root variant="outline" size="sm" class="border-blue-700! py-2! {className}">
{#snippet child({ props })}
<button
{...props}
onclick={() => {
onClick();
openUpgradeModal();
}}
>
<Item.Media variant="icon">
<ZapIcon class="custom size-5 text-blue-700" />
</Item.Media>
<Item.Content>
<Item.Title class="text-blue-700!">
{@render children?.()}
</Item.Title>
</Item.Content>
<Item.Actions>
<ChevronRightIcon class="text-blue-700! size-4" />
</Item.Actions>
</button>
{/snippet}
</Item.Root>
{/if}
@@ -0,0 +1,225 @@
<script lang="ts">
import { untrack } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { Progress } from '@cio/ui/base/progress';
import CheckIcon from '@lucide/svelte/icons/check';
import RocketIcon from '@lucide/svelte/icons/rocket';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { Badge } from '@cio/ui/base/badge';
import SparklesIcon from '@lucide/svelte/icons/sparkles';
import { PLANS } from '@cio/utils/plans';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { toggleConfetti } from '$lib/components/Confetti/store';
import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
import Confetti from '$lib/components/Confetti/index.svelte';
const planNames = Object.keys(PLANS);
let isLoadingPlan: string | null = $state(null);
let upgraded = $state(false);
let isYearlyPlan = $state(false);
const query = $derived(new URLSearchParams(page.url.search));
let open = $state(false);
let isConfirming = $derived((query.get('confirmation') || '') === 'true');
$effect(() => {
open = (query.get('upgrade') || '') === 'true';
});
async function handleClick(plan, planName: string) {
if (plan.CTA.IS_DISABLED || !$profile.id || !$profile.email) {
return;
}
if (!plan.CTA.PRODUCT_ID && plan.CTA.DASHBOARD_LINK) {
window.open(plan.CTA.DASHBOARD_LINK, '_blank');
return;
}
isLoadingPlan = planName;
try {
const checkoutURL = new URL('/api/polar/subscribe', page.url);
checkoutURL.searchParams.set('productId', isYearlyPlan ? plan.CTA.PRODUCT_ID_YEARLY : plan.CTA.PRODUCT_ID);
checkoutURL.searchParams.set('customerEmail', $profile.email);
checkoutURL.searchParams.set('customerName', $profile.fullname);
checkoutURL.searchParams.set(
'metadata',
JSON.stringify({
triggeredBy: $currentOrg.roleId,
orgId: $currentOrg.id,
orgSlug: $currentOrg.siteName
})
);
window.location.href = checkoutURL.toString();
} catch (error) {
console.error('Error subscribing', error);
snackbar.error('snackbar.upgrade.failed');
}
isLoadingPlan = null;
}
function onUpgrade() {
untrack(() => {
upgraded = true;
});
toggleConfetti();
setTimeout(() => {
toggleConfetti();
}, 1000);
}
function onClose() {
const path = upgraded ? $currentOrgPath : page.url.pathname;
goto(path);
}
function handleOpenChange(newOpen: boolean) {
if (!newOpen) {
onClose();
}
}
function onLearnMore() {
window.open('https://classroomio.com/blog/early-adopter', '_blank');
}
function handleUpgradeSuccess(upgradeSuccessful: boolean) {
if (upgradeSuccessful) {
onUpgrade();
}
}
$effect(() => {
handleUpgradeSuccess(Boolean($currentOrg.id && !$isFreePlan && open));
});
</script>
{#if upgraded}
<Confetti />
{/if}
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
<Dialog.Content class="max-w-5xl! {upgraded || isConfirming ? 'max-w-[600px]!' : ''}">
<Dialog.Header>
<Dialog.Title>{$t('pricing.modal.heading')}</Dialog.Title>
</Dialog.Header>
{#if upgraded}
<div class="animate-icon flex w-full flex-col items-center justify-center gap-4">
<RocketIcon class="rocket-launch my-3 size-6" />
<p class="text-lg">{$t('pricing.modal.thanks')}</p>
<p class="ui:mb-4 ui:text-center">
{$t('pricing.modal.plan')}
</p>
<div class="flex items-center gap-4">
<Button variant="outline" onclick={onClose}>
{$t('pricing.modal.close')}
</Button>
<Button variant="default" onclick={onLearnMore}>
{$t('pricing.modal.learn')}
</Button>
</div>
</div>
{:else if isConfirming}
<div class="items-center">
<div class="flex w-full flex-col items-center justify-center py-8">
<Progress />
</div>
</div>
{:else}
<div class="flex h-full flex-col items-center justify-center">
<div class="white 0 relative mb-6 flex items-center rounded-full border p-1">
<Button
variant="ghost"
size="sm"
class="rounded-full! transition-all duration-500 ease-in-out {isYearlyPlan
? 'ui:text-foreground bg-transparent'
: 'bg-[#1D4EE2] text-white'}"
onclick={() => (isYearlyPlan = false)}
>
{$t('pricing.modal.monthly')}
</Button>
<Button
variant="ghost"
size="sm"
class="rounded-full! transition-all duration-500 ease-in-out {isYearlyPlan
? 'bg-[#1D4EE2] text-white'
: 'ui:text-foreground bg-transparent'}"
onclick={() => (isYearlyPlan = true)}
>
{$t('pricing.modal.annually')}
</Button>
<Badge variant="default" class="rotate-5 absolute -top-3 right-[-20%]">
<SparklesIcon class="text-amber-500! size-2" />
{$t('pricing.modal.save')}
</Badge>
</div>
<div class="grid w-full grid-cols-1 gap-6 overflow-y-auto p-2 md:grid-cols-3 md:overflow-y-visible">
{#each planNames as planName}
{@const plan = PLANS[planName]}
{@const isPopular = planName === 'EARLY_ADOPTER'}
<div
class="relative flex max-w-sm flex-col rounded-lg p-6 {isPopular ? 'animate-gradient-border' : 'border '}"
>
{#if isPopular}
<Badge
variant="default"
class="bg-linear-to-r absolute -top-3 left-1/2 -translate-x-1/2 from-pink-500 to-orange-500"
>
Popular
</Badge>
{/if}
<div class="mb-4">
<h3 class="mb-2 text-lg font-semibold">
{plan?.NAME}
</h3>
<div class="mb-1 flex items-baseline gap-1">
<span class="text-xl">
{plan?.PRICE?.CURRENCY}
{isYearlyPlan ? plan?.PRICE?.YEARLY : plan?.PRICE?.MONTHLY}
</span>
</div>
<p class="text-sm text-gray-500">Per Organization</p>
</div>
<Button
variant={isPopular ? 'default' : 'outline'}
class="mb-6 w-full"
disabled={plan?.CTA?.IS_DISABLED || isLoadingPlan === planName}
loading={isLoadingPlan === planName}
onclick={() => {
if (isLoadingPlan === planName) return;
handleClick(plan, planName);
}}
>
{plan?.CTA?.DASHBOARD_LABEL}
</Button>
<ul class="space-y-3">
{#each plan?.FEATURES as feature}
<li class="flex items-start gap-2">
<CheckIcon class="mt-0.5 size-4 shrink-0" />
<span class="text-sm leading-relaxed">
{feature}
</span>
</li>
{/each}
</ul>
</div>
{/each}
</div>
</div>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,27 @@
<script>
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import { t } from '$lib/utils/functions/translations';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
</script>
{#if $isFreePlan}
<a
href={`https://classroomio.com?utm_source=${$currentOrg.siteName}.classroomio.com`}
target="_blank"
class="group fixed bottom-14 right-9 z-50 hover:no-underline"
>
<span
class="flex items-center gap-1 overflow-hidden rounded-md border border-gray-50 bg-white px-2 py-1 text-sm font-medium text-black shadow-md transition duration-500 group-hover:border-blue-600 group-hover:bg-blue-600 group-hover:text-white dark:text-black"
>
<ArrowUpRightIcon
size={16}
class="
custom absolute left-3 -translate-x-full translate-y-full text-white
opacity-0 transition duration-500 group-hover:translate-x-0 group-hover:translate-y-0 group-hover:opacity-100"
/>
<img src="/logo-192.png" alt="logo" class="h-[24px] opacity-100 group-hover:opacity-0" />
{$t('course.navItem.landing_page.powered_by')} ClassroomIO
</span>
</a>
{/if}