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:
@@ -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 🎉 </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>
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user