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,124 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { preventDefault } from '$lib/utils/functions/svelte';
|
||||
|
||||
import { page } from '$app/state';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import GoogleIconColored from '../Icons/GoogleIconColored.svelte';
|
||||
import { authClient } from '$lib/utils/services/auth/client';
|
||||
|
||||
interface Props {
|
||||
handleSubmit?: any;
|
||||
isLogin?: boolean;
|
||||
showOnlyContent?: boolean;
|
||||
isLoading?: boolean;
|
||||
showLogo?: boolean;
|
||||
formRef?: any;
|
||||
hideGoogleAuth?: boolean;
|
||||
redirectPathname?: string;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
handleSubmit = () => {},
|
||||
isLogin = true,
|
||||
showOnlyContent = false,
|
||||
isLoading = false,
|
||||
showLogo = false,
|
||||
formRef = $bindable(),
|
||||
hideGoogleAuth = false,
|
||||
redirectPathname = '',
|
||||
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}`;
|
||||
|
||||
console.log({ redirectTo });
|
||||
|
||||
try {
|
||||
console.log('signInWithGoogle');
|
||||
const data = await authClient.signIn.social({
|
||||
provider: 'google',
|
||||
callbackURL: redirectTo
|
||||
});
|
||||
|
||||
console.log('data', data);
|
||||
} catch (error) {
|
||||
console.log('catch error', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="app-background flex min-h-screen w-full items-center justify-center">
|
||||
<div class="border-gray container border bg-white dark:bg-black">
|
||||
<div class="flex flex-col items-center p-2 lg:px-8 lg:py-3">
|
||||
{#if !showOnlyContent || showLogo}
|
||||
<div class="flex w-full flex-col items-center justify-center pt-2">
|
||||
<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="/">
|
||||
<h4 class="mt-0 text-xl dark:text-white">
|
||||
{$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}
|
||||
</h4>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
<form bind:this={formRef} onsubmit={preventDefault(handleSubmit)} class="flex w-10/12 flex-col items-center">
|
||||
{@render children?.()}
|
||||
</form>
|
||||
{#if !showOnlyContent && !hideGoogleAuth}
|
||||
<div class="mb-3 w-10/12">
|
||||
<p class="mb-5 text-sm dark:text-white">{$t('login.signup_with')}:</p>
|
||||
<PrimaryButton
|
||||
variant={VARIANTS.OUTLINED}
|
||||
onClick={signInWithGoogle}
|
||||
isDisabled={isLoading}
|
||||
className="py-3 sm:w-full w-full"
|
||||
>
|
||||
<GoogleIconColored />
|
||||
<span class="ml-2">
|
||||
{isLogin ? $t('login.login_with_google') : $t('login.signup_with_google')}
|
||||
</span>
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !showOnlyContent}
|
||||
<div class="border-grey w-full border-t p-6 text-center">
|
||||
{#if isLogin}
|
||||
{$t('login.not_registered_yet')}
|
||||
<a class="text-primary-700 hover:underline" href="/signup{page.url.search}">{$t('login.signup')}</a>
|
||||
{:else}
|
||||
{$t('login.already_have_account')}
|
||||
<a class="text-primary-700 hover:underline" href="/login{page.url.search}">{$t('login.login')}</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
width: 450px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import { Circle } from 'svelte-loading-spinners';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import IconButton, { type Props as IconButtonProps } from '$lib/components/IconButton/index.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
<IconButton {onClick} {color} {contained} {size} disabled={isClosing}>
|
||||
{#if isClosing}
|
||||
<Circle size="20" color="#1d4ee2" unit="px" duration="1s" />
|
||||
<Spinner class="text-blue-700! size-5" />
|
||||
{:else}
|
||||
<XIcon size={size === 'large' ? 24 : 16} class="dark:text-black" />
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import ArrowUpRightIcon from '$lib/components/Icons/ArrowTopRight.svelte';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
|
||||
import { currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { isMobile } from '$lib/utils/store/useMobile';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
@@ -18,7 +17,7 @@
|
||||
</script>
|
||||
|
||||
<a {href} target="_blank" class="{className} ml-2 hover:no-underline">
|
||||
<PrimaryButton variant={VARIANTS.CONTAINED_DARK}>
|
||||
<Button variant="outline">
|
||||
{#if !$isMobile}
|
||||
{#if isLMS}
|
||||
{$t('dashboard.visit_site')}
|
||||
@@ -27,5 +26,5 @@
|
||||
{/if}
|
||||
{/if}
|
||||
<ArrowUpRightIcon />
|
||||
</PrimaryButton>
|
||||
</Button>
|
||||
</a>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import Plain from './templates/Plain.svelte';
|
||||
import Professional from './templates/Professional.svelte';
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
|
||||
import { UpgradeBanner } from '$lib/features/ui';
|
||||
import BlueBadgePattern from './templates/BlueBadgePattern.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import PurpleBadgePattern from './templates/PurpleBadgePattern.svelte';
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import TextareaQuestion from '$lib/components/Question/TextareaQuestion/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
|
||||
import { RoleBasedSecurity } from '$lib/features/ui';
|
||||
import Progress from '$lib/components/Progress/index.svelte';
|
||||
import { removeDuplicate } from '$lib/utils/functions/removeDuplicate';
|
||||
import { QUESTION_TYPE } from '$lib/components/Question/constants';
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
import UpdateDescription from './UpdateDescription.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
|
||||
import { RoleBasedSecurity } from '$lib/features/ui';
|
||||
|
||||
interface Props {
|
||||
exerciseId?: string;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { Circle } from 'svelte-loading-spinners';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { lesson } from '../store/lessons';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
@@ -15,11 +15,11 @@
|
||||
|
||||
import { PageBody } from '$lib/components/Page';
|
||||
import Exercise from '../Exercise/index.svelte';
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
import Backdrop from '$lib/components/Backdrop/index.svelte';
|
||||
import * as Empty from '@cio/ui/base/empty';
|
||||
import { QUESTION_TYPES } from '$lib/components/Question/constants';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
|
||||
import { RoleBasedSecurity } from '$lib/features/ui';
|
||||
import NewExerciseModal from '$lib/components/Course/components/Lesson/Exercises/NewExerciseModal.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -160,7 +160,7 @@
|
||||
|
||||
{#if isFetching}
|
||||
<Backdrop>
|
||||
<Circle size="60" color="#1d4ed8" unit="px" duration="1s" />
|
||||
<Spinner class="size-14! text-blue-700!" />
|
||||
</Backdrop>
|
||||
{/if}
|
||||
|
||||
@@ -204,22 +204,24 @@
|
||||
<p class="mt-4 text-sm dark:text-white">{formatDate(exercise.created_at)}</p>
|
||||
</a>
|
||||
{:else}
|
||||
<Box className="mt-3 text-center">
|
||||
<div class="flex justify-between flex-col items-center w-[80%] md:w-96">
|
||||
<img src="/images/empty-exercise-icon.svg" alt="Exercise" class="my-2.5 mx-auto" />
|
||||
<h2 class="text-xl my-1.5 font-normal">
|
||||
<Empty.Root class="mt-3">
|
||||
<Empty.Header>
|
||||
<Empty.Media variant="icon">
|
||||
<img src="/images/empty-exercise-icon.svg" alt="Exercise" class="size-6" />
|
||||
</Empty.Media>
|
||||
<Empty.Title>
|
||||
{$t('course.navItem.lessons.exercises.no_exercises')}
|
||||
</h2>
|
||||
<p class="text-sm text-center text-slate-500">
|
||||
</Empty.Title>
|
||||
<Empty.Description>
|
||||
{#if $globalStore.isStudent}
|
||||
{$t('course.navItem.lessons.exercises.no_assigned_exercises')}
|
||||
<strong> {$t('course.navItem.lessons.exercises.chill')} :)</strong>
|
||||
{:else}
|
||||
{$t('course.navItem.lessons.exercises.add_exercise')}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</Box>
|
||||
</Empty.Description>
|
||||
</Empty.Header>
|
||||
</Empty.Root>
|
||||
{/each}
|
||||
</div>
|
||||
</PageBody>
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
import ListChecksIcon from '@lucide/svelte/icons/list-checks';
|
||||
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
|
||||
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
import TextChip from '$lib/components/Chip/Text.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
|
||||
import { RoleBasedSecurity } from '$lib/features/ui';
|
||||
import * as Empty from '@cio/ui/base/empty';
|
||||
import { lessons, handleSaveLesson } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
@@ -188,14 +188,16 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Box>
|
||||
<div class="flex justify-between flex-col items-center w-[90%] md:w-96">
|
||||
<img src="/images/empty-lesson-icon.svg" alt="Lesson" class="my-2.5 mx-auto" />
|
||||
<h2 class="text-xl my-1.5 font-normal">{$t('course.navItem.lessons.body_header')}</h2>
|
||||
<p class="text-sm text-center text-slate-500">
|
||||
<Empty.Root>
|
||||
<Empty.Header>
|
||||
<Empty.Media variant="icon">
|
||||
<img src="/images/empty-lesson-icon.svg" alt="Lesson" class="size-6" />
|
||||
</Empty.Media>
|
||||
<Empty.Title>{$t('course.navItem.lessons.body_header')}</Empty.Title>
|
||||
<Empty.Description>
|
||||
{$t('course.navItem.lessons.body_content')}
|
||||
</p>
|
||||
</div>
|
||||
</Box>
|
||||
</Empty.Description>
|
||||
</Empty.Header>
|
||||
</Empty.Root>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
import { updateLesson, updateLessonSection } from '$lib/utils/services/courses';
|
||||
import type { Lesson } from '$lib/utils/types';
|
||||
import DeleteLessonConfirmation from '$lib/components/Course/components/Lesson/DeleteLessonConfirmation.svelte';
|
||||
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
|
||||
import { RoleBasedSecurity } from '$lib/features/ui';
|
||||
|
||||
type CrudParam = {
|
||||
sectionId?: string;
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
import FileTextIcon from '@lucide/svelte/icons/file-text';
|
||||
import { DocumentUploader } from '$lib/utils/services/courses/presign';
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
|
||||
import { UpgradeBanner } from '$lib/features/ui';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { lesson } from '../../store/lessons';
|
||||
import { CloseButton } from '$lib/components/Buttons/Close';
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
import { preventDefault } from '$lib/utils/functions/svelte';
|
||||
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
|
||||
import { UpgradeBanner } from '$lib/features/ui';
|
||||
import { lesson, lessonVideoUpload, cancelVideoUpload } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import Chip from '$lib/components/Chip/index.svelte';
|
||||
import DeleteFeedConfirmation from './DeleteFeedConfirmation.svelte';
|
||||
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
|
||||
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
|
||||
import { RoleBasedSecurity } from '$lib/features/ui';
|
||||
|
||||
interface Props {
|
||||
feed: Feed;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import { qrInviteNodeStore } from './store';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { getOrgTeam } from '$lib/utils/services/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';
|
||||
@@ -37,10 +37,8 @@
|
||||
|
||||
let tutors: Tutor[] = $state([]);
|
||||
let selectedIds: string[] = $state([]);
|
||||
let isLoadingTutors = $state(false);
|
||||
|
||||
const selectedTutors = $derived(tutors.filter((tutor) => selectedIds.includes(tutor.id.toString())));
|
||||
let copied = $state(false);
|
||||
let qrImage = $state('');
|
||||
let isLoadingQRDownload = $state(false);
|
||||
|
||||
@@ -108,18 +106,13 @@
|
||||
if (!orgId) return;
|
||||
|
||||
untrack(async () => {
|
||||
isLoadingTutors = true;
|
||||
|
||||
const { team, error } = await getOrgTeam(orgId);
|
||||
if (error) {
|
||||
console.error('Error fetching teams', error);
|
||||
isLoadingTutors = false;
|
||||
await orgApi.getOrgTeam(orgId);
|
||||
if (orgApi.error) {
|
||||
console.error('Error fetching teams', orgApi.error);
|
||||
return;
|
||||
}
|
||||
|
||||
tutors = getTutors(team);
|
||||
|
||||
isLoadingTutors = false;
|
||||
tutors = getTutors(orgApi.teamMembers);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -132,10 +125,6 @@
|
||||
|
||||
const link = getStudentInviteLink($course, $currentOrg.siteName, $currentOrgDomain);
|
||||
copy(link);
|
||||
copied = true;
|
||||
setTimeout(() => {
|
||||
copied = false;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
@@ -197,7 +186,7 @@
|
||||
<form onsubmit={preventDefault(onSubmit)}>
|
||||
<div class="mb-8">
|
||||
<p class="mb-1 text-base font-semibold">{$t('course.navItem.people.invite_modal.invite')}</p>
|
||||
<Select.Root type="multiple" bind:value={selectedIds} disabled={isLoadingTutors}>
|
||||
<Select.Root type="multiple" bind:value={selectedIds} disabled={orgApi.isLoading}>
|
||||
<Select.Trigger class="w-full">
|
||||
<div>
|
||||
{#if selectedTutors.length > 0}
|
||||
@@ -231,7 +220,7 @@
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{#if isLoadingTutors}
|
||||
{#if orgApi.isLoading}
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<Spinner class="h-4 w-4" />
|
||||
<span class="text-sm">Loading...</span>
|
||||
@@ -287,7 +276,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute left-[-1000px] w-[40rem]">
|
||||
<div class="w-160 absolute left-[-1000px]">
|
||||
<ShareQrImage {qrImage} course={$course} currentOrg={$currentOrg} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { CurrentOrg } from '$lib/utils/types/org.ts';
|
||||
import type { Course } from '$lib/utils/types/index.ts';
|
||||
import type { AccountOrg } from '$lib/features/app/types';
|
||||
import type { Course } from '$lib/utils/types';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { qrInviteNodeStore } from './store';
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
interface Props {
|
||||
qrImage: string;
|
||||
currentOrg: CurrentOrg;
|
||||
currentOrg: AccountOrg;
|
||||
course: Course;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div
|
||||
bind:this={node}
|
||||
id="qr-container"
|
||||
class="flex h-[60rem] w-[40rem] flex-col items-center justify-center rounded-xl bg-blue-900 p-10 pb-20"
|
||||
class="h-160 w-160 flex flex-col items-center justify-center rounded-xl bg-blue-900 p-10 pb-20"
|
||||
>
|
||||
<div class="rounded-3xl bg-white p-6 pb-3 text-center">
|
||||
<div class="my-4 rounded-xl bg-gray-100 p-2 text-xl">Scan QR</div>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import { Row, Grid, Column } from '$lib/components/Org/Settings/Layout';
|
||||
import SectionTitle from '$lib/components/Org/SectionTitle.svelte';
|
||||
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
|
||||
import { UpgradeBanner } from '$lib/features/ui';
|
||||
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { get } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Circle } from 'svelte-loading-spinners';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import Navigation from '../Course/components/Navigation/index.svelte';
|
||||
import Backdrop from '$lib/components/Backdrop/index.svelte';
|
||||
import { course, group, courseStore } from '../Course/store';
|
||||
@@ -65,7 +65,7 @@
|
||||
|
||||
{#if isFetching}
|
||||
<Backdrop>
|
||||
<Circle size="60" color="#1d4ed8" unit="px" duration="1s" />
|
||||
<Spinner class="size-14! text-blue-700!" />
|
||||
</Backdrop>
|
||||
{/if}
|
||||
|
||||
|
||||
+4
-4
@@ -7,7 +7,7 @@
|
||||
import type { Course } from '$lib/utils/types';
|
||||
import { NAV_ITEM_KEY } from '../../constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { uploadAvatar } from '$lib/utils/services/courses';
|
||||
import { uploadImage } from '$lib/utils/services/upload';
|
||||
|
||||
import UploadImage from '$lib/components/UploadImage/index.svelte';
|
||||
|
||||
@@ -18,18 +18,18 @@
|
||||
|
||||
let { course = $bindable(), setter }: Props = $props();
|
||||
|
||||
let avatar: string | undefined = $state();
|
||||
let avatar: File | undefined = $state();
|
||||
let templateUrl = $derived(get(course, 'metadata.certificate.templateUrl', '/images/certificate-template.svg'));
|
||||
let isUploading = $state(false);
|
||||
|
||||
let show = $derived(get(course, `metadata.sectionDisplay.${NAV_ITEM_KEY.CERTIFICATE}`) ?? true);
|
||||
|
||||
async function onTemplateChange(_avatar: string | undefined) {
|
||||
async function onTemplateChange(_avatar: File | undefined) {
|
||||
untrack(async () => {
|
||||
if (!_avatar || !course.id) return;
|
||||
isUploading = true;
|
||||
|
||||
const logo = await uploadAvatar(course.id, _avatar);
|
||||
const logo = await uploadImage(_avatar);
|
||||
|
||||
if (logo) {
|
||||
templateUrl = logo;
|
||||
|
||||
+5
-4
@@ -3,7 +3,7 @@
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import UploadImage from '$lib/components/UploadImage/index.svelte';
|
||||
import { uploadAvatar } from '$lib/utils/services/courses';
|
||||
import { uploadImage } from '$lib/utils/services/upload';
|
||||
import type { Course } from '$lib/utils/types';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
@@ -19,13 +19,14 @@
|
||||
let imgUrl = $state(get(course, 'metadata.instructor.imgUrl'));
|
||||
let description = $state(get(course, 'metadata.instructor.description'));
|
||||
let courseNo = $state(get(course, 'metadata.instructor.courseNo'));
|
||||
let avatar: string | undefined = $state();
|
||||
let avatar: File | undefined = $state();
|
||||
let isUploading = $state(false);
|
||||
|
||||
async function onAvatarChange(_avatar: string | undefined) {
|
||||
async function onAvatarChange(_avatar: File | undefined) {
|
||||
if (!_avatar || !course.id) return;
|
||||
|
||||
isUploading = true;
|
||||
const logo = await uploadAvatar(course.id, _avatar);
|
||||
const logo = await uploadImage(_avatar);
|
||||
|
||||
if (!logo) return;
|
||||
|
||||
|
||||
+5
-5
@@ -5,26 +5,26 @@
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { uploadAvatar } from '$lib/utils/services/courses';
|
||||
import { uploadImage } from '$lib/utils/services/upload';
|
||||
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import UploadImage from '$lib/components/UploadImage/index.svelte';
|
||||
|
||||
let { courseId, reviews = $bindable([]), review = $bindable({}), errors = {}, onExpand = () => {} } = $props();
|
||||
let { reviews = $bindable([]), review = $bindable({}), errors = {}, onExpand = () => {} } = $props();
|
||||
|
||||
let avatar = $state<string | undefined>();
|
||||
let avatar = $state<File | undefined>();
|
||||
|
||||
// function to delete review
|
||||
function deleteReviewData() {
|
||||
reviews = reviews.filter((r) => r.id !== review.id);
|
||||
}
|
||||
|
||||
async function onAvatarChange(_avatar) {
|
||||
async function onAvatarChange(_avatar: File | undefined) {
|
||||
if (!_avatar) return;
|
||||
|
||||
review.avatar_url = await uploadAvatar(courseId, _avatar);
|
||||
review.avatar_url = await uploadImage(_avatar);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
import ImageRenderer from '../Org/ImageRenderer.svelte';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import PricingSection from './components/PricingSection.svelte';
|
||||
import PoweredBy from '$lib/components/Upgrade/PoweredBy.svelte';
|
||||
import { PoweredBy } from '$lib/features/ui';
|
||||
import SectionsDisplay from './components/SectionsDisplay.svelte';
|
||||
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
|
||||
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
</script>
|
||||
|
||||
<div class="mb-4 mr-4 h-[370px] w-72 rounded-md border bg-gray-100 p-2 transition ease-in-out dark:bg-black">
|
||||
<Skeleton class="h-[45%] w-full" />
|
||||
<div class="flex h-[50%] flex-col justify-between pb-2 pt-4">
|
||||
<!-- Title -->
|
||||
<Skeleton class="h-4 w-2/4" />
|
||||
|
||||
<!-- Description -->
|
||||
<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>
|
||||
@@ -1,263 +0,0 @@
|
||||
<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/components/Courses/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>
|
||||
@@ -1,98 +0,0 @@
|
||||
<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/components/Courses/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>
|
||||
@@ -1,7 +0,0 @@
|
||||
<script>
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
</script>
|
||||
|
||||
<div class="my-2">
|
||||
<Skeleton class="h-4 w-full" />
|
||||
</div>
|
||||
@@ -1,100 +0,0 @@
|
||||
<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 { isMobile } from '$lib/utils/store/useMobile';
|
||||
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 !$isMobile}
|
||||
<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>
|
||||
@@ -1,234 +0,0 @@
|
||||
<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 '$lib/components/Courses/functions';
|
||||
import { courses, createCourseModal } from '$lib/components/Courses/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>
|
||||
@@ -1,10 +0,0 @@
|
||||
export const statuses = [
|
||||
{
|
||||
chipClassName: 'bg-green-600 text-white',
|
||||
chipLabel: 'In progress'
|
||||
},
|
||||
{
|
||||
chipClassName: 'bg-red-600 text-white',
|
||||
chipLabel: 'Completed'
|
||||
}
|
||||
];
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
|
||||
import Box from '../Box/index.svelte';
|
||||
import Card from './components/Card/index.svelte';
|
||||
import List from './components/List/index.svelte';
|
||||
import CardLoader from './components/Card/Loader.svelte';
|
||||
import CoursesEmptyIcon from '../Icons/CoursesEmptyIcon.svelte';
|
||||
import DeleteModal from '$lib/components/Modal/DeleteModal.svelte';
|
||||
import CopyCourseModal from './components/CopyCourseModal/index.svelte';
|
||||
|
||||
import { courseMetaDeta } from './store';
|
||||
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 } from './store';
|
||||
|
||||
interface Props {
|
||||
courses?: Course[];
|
||||
emptyTitle?: any;
|
||||
emptyDescription?: any;
|
||||
isExplore?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
courses = [],
|
||||
emptyTitle = $t('courses.course_card.empty_title'),
|
||||
emptyDescription = $t('courses.course_card.empty_description'),
|
||||
isExplore = false
|
||||
}: Props = $props();
|
||||
|
||||
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>
|
||||
|
||||
<CopyCourseModal />
|
||||
<DeleteModal
|
||||
onDelete={handleDeleteCourse}
|
||||
bind:open={$deleteCourseModal.open}
|
||||
isLoading={$deleteCourseModal.isDeleting}
|
||||
/>
|
||||
|
||||
<div class="mx-auto my-4 w-full">
|
||||
{#if $courseMetaDeta.isLoading}
|
||||
<section class={`${$courseMetaDeta.isLoading || courses ? 'cards-container' : ''} `}>
|
||||
<CardLoader />
|
||||
<CardLoader />
|
||||
<CardLoader />
|
||||
</section>
|
||||
{: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}
|
||||
<List
|
||||
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}
|
||||
<Card
|
||||
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>
|
||||
{#if !$courseMetaDeta.isLoading && !courses.length}
|
||||
<Box className="w-full">
|
||||
<CoursesEmptyIcon />
|
||||
<h3 class="my-5 text-2xl dark:text-white">{emptyTitle}</h3>
|
||||
<p class="w-1/3 text-center dark:text-white">
|
||||
{emptyDescription}
|
||||
</p>
|
||||
</Box>
|
||||
{/if}
|
||||
@@ -1,57 +0,0 @@
|
||||
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 });
|
||||
@@ -2,9 +2,10 @@
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { goto } from '$app/navigation';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import CoursesEmptyIcon from '$lib/components/Icons/CoursesEmptyIcon.svelte';
|
||||
import { courses } from '$lib/components/Courses/store';
|
||||
import { courses } from '$lib/features/course/utils/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
|
||||
const gotoCourse = (id: string | undefined) => {
|
||||
if (!id) return;
|
||||
@@ -54,11 +55,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center">
|
||||
<CoursesEmptyIcon size={16} />
|
||||
<h3 class="text-center">{$t('dashboard.no_courses')}</h3>
|
||||
<p class="w-[75%] text-center">{$t('dashboard.start_course')}</p>
|
||||
</div>
|
||||
<Empty title={$t('dashboard.no_courses')} description={$t('dashboard.start_course')} icon={BookOpenIcon} />
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
message = $t('username already exists');
|
||||
}
|
||||
|
||||
snackbar.error(`${$t('snackbar.lms.error.update')}: ${message}`);
|
||||
snackbar.error(`${$t('snackbar.update_failed')}: ${message}`);
|
||||
|
||||
loading = false;
|
||||
} finally {
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="dialog fixed inset-0 z-50 flex items-center justify-center overflow-x-hidden bg-gray-100 bg-opacity-50"
|
||||
class="dialog z-100 fixed inset-0 flex items-center justify-center overflow-x-hidden bg-gray-100 bg-opacity-50"
|
||||
onclick={onClose}
|
||||
role="presentation"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
@@ -11,45 +9,24 @@
|
||||
onMobileClick?: (() => void) | undefined;
|
||||
}
|
||||
|
||||
let {
|
||||
disableSignup = false,
|
||||
redirect = '',
|
||||
isMobile = false,
|
||||
onMobileClick = undefined
|
||||
}: Props = $props();
|
||||
|
||||
function handleLogin() {
|
||||
if (onMobileClick) onMobileClick();
|
||||
goto('/login' + redirect);
|
||||
}
|
||||
|
||||
function handleSignup() {
|
||||
if (onMobileClick) onMobileClick();
|
||||
goto('/signup' + redirect);
|
||||
}
|
||||
let { disableSignup = false, redirect = '', isMobile = false, onMobileClick = undefined }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex {isMobile ? 'flex-col items-center justify-center space-y-3' : ''}">
|
||||
<li class={isMobile ? '' : 'block'}>
|
||||
<div class="flex">
|
||||
<PrimaryButton
|
||||
label={$t('navigation.login')}
|
||||
variant={VARIANTS.TEXT}
|
||||
onClick={handleLogin}
|
||||
className={isMobile ? 'w-full justify-center' : ''}
|
||||
/>
|
||||
<Button variant="link" size="sm" href={'/login' + redirect} onclick={onMobileClick}>
|
||||
{$t('navigation.login')}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
{#if !disableSignup}
|
||||
<li class={isMobile ? '' : 'block'}>
|
||||
<div class="flex">
|
||||
<PrimaryButton
|
||||
label={$t('navigation.signup')}
|
||||
variant={VARIANTS.CONTAINED}
|
||||
onClick={handleSignup}
|
||||
className={isMobile ? 'w-full justify-center' : ''}
|
||||
/>
|
||||
<Button variant="link" size="sm" href={'/signup' + redirect} onclick={onMobileClick}>
|
||||
{$t('navigation.signup')}
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { TCustomLinks } from './types';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
interface Props {
|
||||
customLinks?: TCustomLinks | undefined;
|
||||
@@ -18,17 +19,16 @@
|
||||
<div class={isMobile ? 'space-y-4' : 'mr-4 hidden items-center space-x-6 lg:flex'}>
|
||||
{#each customLinks.links as link}
|
||||
<li>
|
||||
<a
|
||||
<Button
|
||||
href={link.url}
|
||||
target={link.openInNewTab ? '_blank' : '_self'}
|
||||
rel={link.openInNewTab ? 'noopener noreferrer' : ''}
|
||||
class={isMobile
|
||||
? 'hover:text-primary-600 block rounded-md px-3 py-2 font-medium text-gray-700 transition-colors duration-200 hover:bg-gray-50'
|
||||
: 'hover:text-primary-600 rounded-md px-4 py-2 font-medium text-gray-700 no-underline transition-all duration-200 ease-in-out hover:bg-gray-100'}
|
||||
variant="link"
|
||||
size="sm"
|
||||
onclick={handleLinkClick}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</Button>
|
||||
</li>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import { isCoursePage } from '$lib/utils/functions/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { TCustomLinks } from './types';
|
||||
import { ModeSwitcher } from '@cio/ui/base/dark-mode';
|
||||
|
||||
import Logo from './Logo.svelte';
|
||||
import CustomLinks from './CustomLinks.svelte';
|
||||
@@ -77,8 +76,6 @@
|
||||
{:else if !isOrgSite && !page.url.pathname?.includes('/404')}
|
||||
<AuthButtons {disableSignup} {redirect} />
|
||||
{/if}
|
||||
|
||||
<ModeSwitcher />
|
||||
</ul>
|
||||
|
||||
{#if showLinks}
|
||||
|
||||
@@ -2,104 +2,29 @@
|
||||
import { preventDefault } from '$lib/utils/functions/svelte';
|
||||
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { DomainInput } from '@cio/ui/custom/domain-input';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { generateSitename } from '$lib/utils/functions/org';
|
||||
import { getOrganizations } from '$lib/utils/services/org';
|
||||
import { blockedSubdomain } from '$lib/utils/constants/app';
|
||||
import { newOrgModal } from '../store';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { createOrgValidation } from '$lib/utils/functions/validator';
|
||||
import { goto } from '$app/navigation';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { orgApi } from '$lib/features/org/api/org.svelte';
|
||||
|
||||
type Error = {
|
||||
orgName: string;
|
||||
siteName: string;
|
||||
};
|
||||
let loading = $state(false);
|
||||
let orgName = $state('');
|
||||
let siteName = $derived(generateSitename(orgName));
|
||||
|
||||
let errors: Error = $state({
|
||||
orgName: '',
|
||||
siteName: ''
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
orgName = '';
|
||||
siteName = '';
|
||||
loading = false;
|
||||
|
||||
errors = {
|
||||
orgName: '',
|
||||
siteName: ''
|
||||
};
|
||||
orgApi.errors = {};
|
||||
}
|
||||
|
||||
async function createNewOrg() {
|
||||
errors = createOrgValidation({
|
||||
orgName,
|
||||
siteName
|
||||
}) as Error;
|
||||
await orgApi.create({ name: orgName, siteName });
|
||||
|
||||
if (Object.values(errors).length) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
// Validate if domain is among our seculeded subdomains
|
||||
if (blockedSubdomain.includes(siteName || '')) {
|
||||
errors.siteName = 'Sitename already exists.';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: org, error } = await supabase
|
||||
.from('organization')
|
||||
.insert({
|
||||
name: orgName,
|
||||
siteName: siteName
|
||||
})
|
||||
.select();
|
||||
console.log('Create organisation', org);
|
||||
|
||||
if (error) {
|
||||
console.log('Error: create organisation', error);
|
||||
errors.siteName = 'Sitename already exists.';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(org) && org.length) {
|
||||
const orgData = org[0];
|
||||
const { data, error } = await supabase
|
||||
.from('organizationmember')
|
||||
.insert({
|
||||
organization_id: orgData.id,
|
||||
profile_id: $profile.id,
|
||||
role_id: 1
|
||||
})
|
||||
.select();
|
||||
|
||||
console.log('Create organisation member', data);
|
||||
|
||||
if (error) {
|
||||
console.log('Error: create organisation member', error);
|
||||
errors.siteName = $t('add_org.error_organization');
|
||||
|
||||
// Delete organization so it can be recreated.
|
||||
await supabase.from('organization').delete().match({ siteName });
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
snackbar.success();
|
||||
await getOrganizations($profile.id);
|
||||
goto(`/org/${siteName}`);
|
||||
if (orgApi.success) {
|
||||
$newOrgModal.open = false;
|
||||
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
@@ -112,32 +37,28 @@
|
||||
modalHeading={$t('add_org.create_org')}
|
||||
>
|
||||
<form onsubmit={preventDefault(createNewOrg)} class="px-2">
|
||||
<TextField
|
||||
label={$t('add_org.name')}
|
||||
bind:value={orgName}
|
||||
autoFocus={true}
|
||||
placeholder="e.g Pepsi Co"
|
||||
className="mb-4"
|
||||
isRequired={true}
|
||||
errorMessage={errors.orgName}
|
||||
autoComplete={false}
|
||||
/>
|
||||
<!-- Org Site Name -->
|
||||
<TextField
|
||||
label={$t('add_org.org_sitename')}
|
||||
helperMessage={`https://${siteName || ''}.classroomio.com`}
|
||||
bind:value={siteName}
|
||||
name="sitename"
|
||||
type="text"
|
||||
placeholder="e.g edforall"
|
||||
className="mb-5 w-full"
|
||||
labelClassName="text-lg font-normal"
|
||||
errorMessage={errors.siteName}
|
||||
isRequired={true}
|
||||
/>
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('add_org.name')}</Field.Label>
|
||||
<Input bind:value={orgName} placeholder="e.g Pepsi Co" autofocus />
|
||||
{#if orgApi.errors.name || orgApi.errors.orgName}
|
||||
<Field.Error>{orgApi.errors.name || orgApi.errors.orgName}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
|
||||
<div class="mt-5 flex flex-row-reverse items-center">
|
||||
<PrimaryButton className="px-6 py-3" label={$t('add_org.create')} type="submit" isLoading={loading} />
|
||||
</div>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('add_org.org_sitename')}</Field.Label>
|
||||
<DomainInput bind:value={siteName} placeholder="myschool" prefix="https://" suffix=".classroomio.com" />
|
||||
{#if orgApi.errors.siteName || orgApi.errors.general}
|
||||
<Field.Error>{orgApi.errors.siteName || orgApi.errors.general}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field orientation="horizontal">
|
||||
<Button type="submit" loading={orgApi.isLoading} class="ml-auto">
|
||||
{$t('add_org.create')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import { page as pageStore } from '$app/state';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import * as Pagination from '@cio/ui/base/pagination';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg, orgAudience } from '$lib/utils/store/org';
|
||||
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
interface Props {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
let { isLoading = false }: Props = $props();
|
||||
|
||||
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
|
||||
? $orgAudience.filter((row) =>
|
||||
Object.values(row).some((val) => String(val).toLowerCase().includes(searchValue.toLowerCase()))
|
||||
)
|
||||
: $orgAudience
|
||||
);
|
||||
|
||||
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>
|
||||
|
||||
<div class="w-full space-y-4">
|
||||
<!-- Search Toolbar -->
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative flex-1">
|
||||
<Search class="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input type="text" placeholder="Search..." bind:value={searchValue} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
{#if 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>
|
||||
@@ -1,39 +0,0 @@
|
||||
<script>
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
import AudienceList from './AudienceList.svelte';
|
||||
import AudienceEmptyIcon from '$lib/components/Icons/AudienceEmptyIcon.svelte';
|
||||
import { getOrgAudience } from '$lib/utils/services/org';
|
||||
import { orgAudience, currentOrg } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let isLoading = $state(false);
|
||||
|
||||
function fetchInitData(orgId) {
|
||||
if (!orgId) return;
|
||||
|
||||
untrack(async () => {
|
||||
isLoading = true;
|
||||
await getOrgAudience(orgId);
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
fetchInitData($currentOrg.id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="m-auto my-4 flex flex-wrap items-center justify-center lg:justify-start">
|
||||
{#if $orgAudience.length || isLoading}
|
||||
<AudienceList {isLoading} />
|
||||
{:else}
|
||||
<Box>
|
||||
<AudienceEmptyIcon size={16} />
|
||||
<h3 class="my-5 text-center text-2xl dark:text-white">{$t('audience.no_audience')}!</h3>
|
||||
<p class="w-1/3 text-center dark:text-white">
|
||||
{$t('audience.manage')}.
|
||||
</p>
|
||||
</Box>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,54 +0,0 @@
|
||||
<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>
|
||||
@@ -1,26 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative mx-4 my-2 flex h-[70px] w-full rounded-md border border-gray-100 px-3 pt-3 transition ease-in-out dark:border-neutral-600 dark:bg-neutral-800"
|
||||
>
|
||||
<div class="mr-2 flex w-10 flex-col justify-evenly rounded-md px-1">
|
||||
<Skeleton class="h-[40%] w-full rounded-sm" />
|
||||
<Skeleton class="h-[40%] w-full rounded-sm" />
|
||||
<Skeleton class="h-[40%] w-full rounded-sm" />
|
||||
</div>
|
||||
|
||||
<div class="mt-1.5 flex w-9/12 flex-col justify-evenly rounded-md">
|
||||
<!-- first text -->
|
||||
<div class="h-full w-3/6">
|
||||
<Skeleton class="h-[50%] w-full rounded-sm" />
|
||||
</div>
|
||||
<!-- second text -->
|
||||
<Skeleton class="h-4 w-1/4 rounded-sm" />
|
||||
</div>
|
||||
|
||||
<div class="ml-auto mt-1.5 w-10 flex-col justify-evenly rounded-md px-1">
|
||||
<Skeleton class="h-[41%] w-full rounded-sm" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,185 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import Search from '@lucide/svelte/icons/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/components/Courses/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 CommunityLoader from './Loader.svelte';
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
import Vote from '$lib/components/Vote/index.svelte';
|
||||
import CoursesEmptyIcon from '$lib/components/Icons/CoursesEmptyIcon.svelte';
|
||||
|
||||
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>
|
||||
|
||||
<div class="flex w-full gap-2">
|
||||
<div class="relative flex-1">
|
||||
<Search class="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={$t('community.find_question')}
|
||||
bind:value={searchValue}
|
||||
class="bg-gray-100 dark:bg-neutral-800"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-[25%]">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
>
|
||||
{#if isLoading}
|
||||
<CommunityLoader />
|
||||
<CommunityLoader />
|
||||
<CommunityLoader />
|
||||
<CommunityLoader />
|
||||
{:else}
|
||||
{#each filteredDiscussions as discussion}
|
||||
<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>
|
||||
{:else}
|
||||
<Box className="">
|
||||
<CoursesEmptyIcon size={16} />
|
||||
<h3 class="dark:text-white text-2xl my-5">{$t('community.no_question')}</h3>
|
||||
<p class="dark:text-white w-1/3 text-center">{$t('community.ask_a_question')}</p>
|
||||
</Box>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import CardLoader from '$lib/components/Courses/components/Card/Loader.svelte';
|
||||
import { CourseCardLoader } from '$lib/features/course/components';
|
||||
</script>
|
||||
|
||||
<section class="h-screen w-screen p-5">
|
||||
@@ -19,8 +19,8 @@
|
||||
</div>
|
||||
<!-- cards -->
|
||||
<div class="my-4 flex flex-wrap items-center justify-center gap-5 overflow-hidden rounded-md">
|
||||
<CardLoader />
|
||||
<CardLoader />
|
||||
<CardLoader />
|
||||
<CourseCardLoader />
|
||||
<CourseCardLoader />
|
||||
<CourseCardLoader />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -12,32 +12,29 @@
|
||||
import LinkedinIcon from '@lucide/svelte/icons/linkedin';
|
||||
import { preventDefault } from '$lib/utils/functions/svelte';
|
||||
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import Navigation from '$lib/components/Navigation/index.svelte';
|
||||
import PoweredBy from '$lib/components/Upgrade/PoweredBy.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import Card from '$lib/components/Courses/components/Card/index.svelte';
|
||||
import { PoweredBy } from '$lib/features/ui';
|
||||
import { CourseCard, CourseCardLoader } from '$lib/features/course/components';
|
||||
import { landingPageSettings } from '$lib/components/Org/Settings/store';
|
||||
import CoursesEmptyIcon from '$lib/components/Icons/CoursesEmptyIcon.svelte';
|
||||
import CardLoader from '$lib/components/Courses/components/Card/Loader.svelte';
|
||||
|
||||
import PageLoader from './PageLoader.svelte';
|
||||
import { Row, Grid, Column } from '../Settings/Layout';
|
||||
import * as Accordion from '@cio/ui/base/accordion';
|
||||
import * as Button from '@cio/ui/base/button';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { CurrentOrg } from '$lib/utils/types/org';
|
||||
import { getSupabase } from '$lib/utils/functions/supabase';
|
||||
import { getCourseBySiteName } from '$lib/utils/services/org';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import type { AccountOrg } from '$lib/features/app/types';
|
||||
import { orgApi } from '$lib/features/org/api/org.svelte';
|
||||
import { validateEmail } from '$lib/utils/functions/validateEmail';
|
||||
import { courseMetaDeta, courses } from '$lib/components/Courses/store';
|
||||
import { courseMetaDeta, courses } from '$lib/features/course/utils/store';
|
||||
import { orgLandingpageValidation } from '$lib/utils/functions/validator';
|
||||
|
||||
interface Props {
|
||||
orgSiteName?: string;
|
||||
org: CurrentOrg | null;
|
||||
org: AccountOrg;
|
||||
}
|
||||
|
||||
let { orgSiteName = '', org }: Props = $props();
|
||||
@@ -57,20 +54,11 @@
|
||||
});
|
||||
let contactError: Record<string, string> = $state({});
|
||||
|
||||
const supabase = getSupabase();
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!email || !validateEmail(email) || !org) return;
|
||||
isAdding = true;
|
||||
|
||||
const { error } = await supabase.from('organization_emaillist').insert({
|
||||
email,
|
||||
organization_id: org.id
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Error', error);
|
||||
}
|
||||
// TODO: Implement email list submission
|
||||
|
||||
success = true;
|
||||
setTimeout(() => {
|
||||
@@ -89,18 +77,7 @@
|
||||
}
|
||||
|
||||
// Save to db
|
||||
const { error } = await supabase.from('organization_contacts').insert({
|
||||
name: contact.name,
|
||||
email: contact.email,
|
||||
phone: contact.phone,
|
||||
message: contact.message,
|
||||
organization_id: org?.id
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Something went wrong', error, '\n\nContact', contact);
|
||||
isContactSubmiting = false;
|
||||
}
|
||||
// TODO: Implement contact submission
|
||||
|
||||
isContactSubmiting = false;
|
||||
successContactSaved = true;
|
||||
@@ -123,11 +100,17 @@
|
||||
untrack(async () => {
|
||||
try {
|
||||
$courseMetaDeta.isLoading = true;
|
||||
const coursesResult = await getCourseBySiteName(siteName);
|
||||
courses.set(coursesResult);
|
||||
await orgApi.getCourseBySiteName(siteName);
|
||||
if (orgApi.error) {
|
||||
console.error('Error fetching courses', orgApi.error);
|
||||
courses.set([]);
|
||||
} else {
|
||||
courses.set(orgApi.orgSiteCourses);
|
||||
}
|
||||
$courseMetaDeta.isLoading = false;
|
||||
} catch (error) {
|
||||
console.log('error', error);
|
||||
$courseMetaDeta.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -157,18 +140,18 @@
|
||||
}
|
||||
|
||||
function getBgImage(settings) {
|
||||
const defaultImg = '/images/org-landingpage-banner.jpeg';
|
||||
|
||||
const { show, image } = settings.header.background || {
|
||||
show: true,
|
||||
image: '/images/org-landingpage-banner.jpeg'
|
||||
image: defaultImg
|
||||
};
|
||||
|
||||
if (!show) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (image) {
|
||||
return `url('${image}')`;
|
||||
}
|
||||
return `url('${image || defaultImg}')`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
@@ -195,7 +178,7 @@
|
||||
{:else}
|
||||
<main>
|
||||
<Navigation
|
||||
logo={org.avatar_url}
|
||||
logo={org.avatarUrl}
|
||||
orgName={org.name}
|
||||
disableSignup={true}
|
||||
isOrgSite={true}
|
||||
@@ -205,12 +188,12 @@
|
||||
{#if $landingPageSettings.header.show}
|
||||
<header
|
||||
id="header"
|
||||
class={`relative mb-10 h-[100vh] w-full md:h-[90vh] ${
|
||||
class={`relative mb-10 h-screen w-full md:h-[90vh] ${
|
||||
$landingPageSettings.header.background?.show ? 'bg-cover bg-center' : 'border-b border-gray-300'
|
||||
}`}
|
||||
style="background-image: {getBgImage($landingPageSettings)}"
|
||||
>
|
||||
<div class="absolute top-0 z-10 h-[100vh] w-full bg-white opacity-80 md:h-[90vh]"></div>
|
||||
<div class="absolute top-0 z-10 h-screen w-full bg-white opacity-80 md:h-[90vh]"></div>
|
||||
{#if $landingPageSettings.header.banner.show}
|
||||
<div class="flex items-center justify-center py-2 md:h-full">
|
||||
<div
|
||||
@@ -230,13 +213,14 @@
|
||||
{$landingPageSettings.header.subtitle}
|
||||
</p>
|
||||
|
||||
<PrimaryButton
|
||||
label={$landingPageSettings.header.action.label}
|
||||
className="mt-2 md:mt-5 px-10 w-fit"
|
||||
onClick={() => {
|
||||
<Button.Root
|
||||
class="mt-2 w-fit px-10 md:mt-5"
|
||||
onclick={() => {
|
||||
$landingPageSettings.header.action.redirect && goto($landingPageSettings.header.action.link);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{$landingPageSettings.header.action.label}
|
||||
</Button.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex w-5/6 md:w-1/2 md:max-w-[650px]">
|
||||
@@ -275,13 +259,14 @@
|
||||
{$landingPageSettings.header.subtitle}
|
||||
</p>
|
||||
|
||||
<PrimaryButton
|
||||
label={$landingPageSettings.header.action.label}
|
||||
className="mt-5 px-10 w-fit"
|
||||
onClick={() => {
|
||||
<Button.Root
|
||||
class="mt-5 w-fit px-10"
|
||||
onclick={() => {
|
||||
$landingPageSettings.header.action.redirect && goto($landingPageSettings.header.action.link);
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{$landingPageSettings.header.action.label}
|
||||
</Button.Root>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -322,14 +307,14 @@
|
||||
</div>
|
||||
{#if $courseMetaDeta.isLoading}
|
||||
<div class="cards-container mx-2 my-4">
|
||||
<CardLoader />
|
||||
<CardLoader />
|
||||
<CardLoader />
|
||||
<CourseCardLoader />
|
||||
<CourseCardLoader />
|
||||
<CourseCardLoader />
|
||||
</div>
|
||||
{:else if $courses.length > 0}
|
||||
<div class="cards-container mx-2 my-4">
|
||||
{#each $courses.slice(0, viewAll ? $courses.length : 3) as courseData}
|
||||
<Card
|
||||
<CourseCard
|
||||
id={courseData.id}
|
||||
slug={courseData.slug}
|
||||
bannerImage={courseData.logo || '/images/classroomio-course-img-template.jpg'}
|
||||
@@ -350,25 +335,19 @@
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<Box>
|
||||
<CoursesEmptyIcon />
|
||||
<h3 class="my-5 text-2xl dark:text-white">
|
||||
{$t('course.navItem.landing_page.no_course_published')}
|
||||
</h3>
|
||||
<p class="w-1/3 text-center dark:text-white">
|
||||
{$t('course.navItem.landing_page.coming_your_way')}
|
||||
</p>
|
||||
</Box>
|
||||
<Empty
|
||||
icon={CoursesEmptyIcon}
|
||||
title={$t('course.navItem.landing_page.no_course_published')}
|
||||
description={$t('course.navItem.landing_page.coming_your_way')}
|
||||
variant="page"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if $courses.length > 3}
|
||||
<div class="mt-3 flex w-full justify-center">
|
||||
<PrimaryButton
|
||||
variant={VARIANTS.OUTLINED}
|
||||
onClick={() => (viewAll = !viewAll)}
|
||||
label={viewAll ? $t('course.navItem.landing_page.view_less') : $t('course.navItem.landing_page.view_all')}
|
||||
className="px-10 py-5 w-fit"
|
||||
/>
|
||||
<Button.Root variant="outline" onclick={() => (viewAll = !viewAll)} class="w-fit px-10 py-5">
|
||||
{viewAll ? $t('course.navItem.landing_page.view_less') : $t('course.navItem.landing_page.view_all')}
|
||||
</Button.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -386,13 +365,11 @@
|
||||
<Accordion.Root type="single" class="w-full">
|
||||
{#each $landingPageSettings.faq.questions as faq, index}
|
||||
<Accordion.Item value="item-{index}">
|
||||
<Accordion.Trigger class="text-left text-lg font-medium">
|
||||
<Accordion.Trigger>
|
||||
{faq.title}
|
||||
</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
<p class="text-lg">
|
||||
{faq.content}
|
||||
</p>
|
||||
{faq.content}
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
{/each}
|
||||
@@ -412,30 +389,30 @@
|
||||
</h1>
|
||||
<p class="text-md text-center">{$landingPageSettings.contact.subtitle}</p>
|
||||
</div>
|
||||
<Grid class="max-w-[700px] pb-10">
|
||||
<div class="mx-auto max-w-[700px] pb-10">
|
||||
<!-- Contact Details -->
|
||||
<Row>
|
||||
<Column
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div
|
||||
class="mx-2 flex cursor-pointer flex-col items-center justify-center break-all rounded-lg py-2 text-center transition-all duration-500 hover:shadow-xl"
|
||||
>
|
||||
<MapPinIcon size={16} class="filled" />
|
||||
<p class="mt-3 max-w-[200px] text-xs md:text-sm">
|
||||
{$landingPageSettings.contact.address}
|
||||
</p>
|
||||
</Column>
|
||||
<Column
|
||||
</div>
|
||||
<div
|
||||
class="mx-2 flex cursor-pointer flex-col items-center justify-center break-all rounded-lg py-2 text-center transition-all duration-500 hover:shadow-xl"
|
||||
>
|
||||
<PhoneIcon size={16} />
|
||||
<p class="mt-3 text-xs md:text-sm">{$landingPageSettings.contact.phone}</p>
|
||||
</Column>
|
||||
<Column
|
||||
</div>
|
||||
<div
|
||||
class="mx-2 flex cursor-pointer flex-col items-center justify-center break-all rounded-lg py-2 text-center transition-all duration-500 hover:shadow-xl"
|
||||
>
|
||||
<MailIcon size={16} />
|
||||
<p class="mt-3 text-xs md:text-sm">{$landingPageSettings.contact.email}</p>
|
||||
</Column>
|
||||
</Row>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Form -->
|
||||
<div class="mt-8 rounded-lg bg-white p-7">
|
||||
@@ -447,48 +424,69 @@
|
||||
<form onsubmit={preventDefault(handleContactSubmit)}>
|
||||
<div class="flex w-full flex-col justify-between md:flex-row">
|
||||
<div class="mr-5 w-full md:w-2/4">
|
||||
<TextField
|
||||
label={$t('course.navItem.landing_page.name')}
|
||||
bind:value={contact.name}
|
||||
errorMessage={contactError.name}
|
||||
className="mb-5"
|
||||
labelClassName="font-bold"
|
||||
placeholder="Elon Musk"
|
||||
/>
|
||||
<TextField
|
||||
label={$t('course.navItem.landing_page.email')}
|
||||
bind:value={contact.email}
|
||||
errorMessage={contactError.email}
|
||||
className="text-xs font-normal mb-5"
|
||||
placeholder="musk@x.com"
|
||||
/>
|
||||
<TextField
|
||||
label={$t('course.navItem.landing_page.phone')}
|
||||
bind:value={contact.phone}
|
||||
errorMessage={contactError.phone}
|
||||
className="text-xs font-normal mb-5"
|
||||
placeholder="+1194802480"
|
||||
/>
|
||||
<Field.Field class="mb-5">
|
||||
<Field.Label class="font-bold">
|
||||
{$t('course.navItem.landing_page.name')}
|
||||
</Field.Label>
|
||||
<Input bind:value={contact.name} placeholder="Elon Musk" aria-invalid={!!contactError.name} />
|
||||
{#if contactError.name}
|
||||
<Field.Error>{contactError.name}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
<Field.Field class="mb-5">
|
||||
<Field.Label class="text-xs font-normal">
|
||||
{$t('course.navItem.landing_page.email')}
|
||||
</Field.Label>
|
||||
<Input
|
||||
bind:value={contact.email}
|
||||
type="email"
|
||||
placeholder="musk@x.com"
|
||||
aria-invalid={!!contactError.email}
|
||||
/>
|
||||
{#if contactError.email}
|
||||
<Field.Error>{contactError.email}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
<Field.Field class="mb-5">
|
||||
<Field.Label class="text-xs font-normal">
|
||||
{$t('course.navItem.landing_page.phone')}
|
||||
</Field.Label>
|
||||
<Input
|
||||
bind:value={contact.phone}
|
||||
placeholder="+1194802480"
|
||||
aria-invalid={!!contactError.phone}
|
||||
/>
|
||||
{#if contactError.phone}
|
||||
<Field.Error>{contactError.phone}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
</div>
|
||||
<div class="w-full md:w-2/4">
|
||||
<TextArea
|
||||
label={$t('course.navItem.landing_page.message')}
|
||||
bind:value={contact.message}
|
||||
errorMessage={contactError.message}
|
||||
rows={9}
|
||||
placeholder={$t('course.navItem.landing_page.your_message')}
|
||||
/>
|
||||
<Field.Field>
|
||||
<Field.Label>
|
||||
{$t('course.navItem.landing_page.message')}
|
||||
</Field.Label>
|
||||
<Textarea
|
||||
bind:value={contact.message}
|
||||
rows={9}
|
||||
placeholder={$t('course.navItem.landing_page.your_message')}
|
||||
aria-invalid={!!contactError.message}
|
||||
/>
|
||||
{#if contactError.message}
|
||||
<Field.Error>{contactError.message}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PrimaryButton className="w-full mx-auto mt-5 md:mt-0" type="submit" isLoading={isContactSubmiting}>
|
||||
<Button.Root class="mx-auto mt-5 w-full md:mt-0" type="submit" loading={isContactSubmiting}>
|
||||
<span class="text-md mr-2">{$t('course.navItem.landing_page.submit')}</span>
|
||||
<RocketIcon size={16} />
|
||||
</PrimaryButton>
|
||||
</Button.Root>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
@@ -496,38 +494,35 @@
|
||||
<!-- Waitlist Section -->
|
||||
{#if $landingPageSettings.mailinglist.show}
|
||||
<section id="waitlist" transition:fade class="mx-auto my-10 w-[95%] max-w-6xl">
|
||||
<div class="bg-primary-700 flex flex-col rounded-lg px-4 py-14 md:px-10 lg:flex-row lg:items-center">
|
||||
<div class="w-full md:mr-4 md:w-[65%]">
|
||||
<h1 class="mb-5 mt-0 text-4xl text-white">
|
||||
{$landingPageSettings.mailinglist.title}
|
||||
</h1>
|
||||
<p class="text-lg text-white">
|
||||
{$landingPageSettings.mailinglist.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<form onsubmit={preventDefault(handleSubmit)} class="my-4 w-full md:w-fit">
|
||||
<div class="flex flex-col items-center sm:flex-row">
|
||||
{#if success}
|
||||
<p class="text-white">{$t('course.navItem.landing_page.successful_sub')}</p>
|
||||
{:else}
|
||||
<TextField
|
||||
bind:value={email}
|
||||
type="email"
|
||||
placeholder={$t('course.navItem.landing_page.enter')}
|
||||
className="sm:mr-3 my-0 w-full md:w-fit"
|
||||
isRequired={true}
|
||||
isDisabled={isAdding}
|
||||
inputClassName="py-2"
|
||||
/>
|
||||
<PrimaryButton
|
||||
className="my-1 w-full mt-2"
|
||||
variant={VARIANTS.CONTAINED_LIGHT}
|
||||
type="submit"
|
||||
isLoading={isAdding}>{$landingPageSettings.mailinglist.buttonLabel}</PrimaryButton
|
||||
>
|
||||
{/if}
|
||||
<div class="bg-primary-700 gap-4 rounded-lg px-4 py-14 md:px-10 lg:flex-row lg:items-center">
|
||||
<div class="mx-auto flex max-w-[700px] flex-col lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="w-3/5">
|
||||
<h1 class="mb-5 mt-0 text-4xl text-white">
|
||||
{$landingPageSettings.mailinglist.title}
|
||||
</h1>
|
||||
<p class="text-md text-white">
|
||||
{$landingPageSettings.mailinglist.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
<form onsubmit={preventDefault(handleSubmit)} class="">
|
||||
<div class="flex max-w-60 flex-col items-center">
|
||||
{#if success}
|
||||
<p class="text-white">{$t('course.navItem.landing_page.successful_sub')}</p>
|
||||
{:else}
|
||||
<Input
|
||||
bind:value={email}
|
||||
type="email"
|
||||
placeholder={$t('course.navItem.landing_page.enter')}
|
||||
required={true}
|
||||
disabled={isAdding}
|
||||
/>
|
||||
<Button.Root class="my-1 mt-2 w-full" type="submit" loading={isAdding}>
|
||||
{$landingPageSettings.mailinglist.buttonLabel}
|
||||
</Button.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
@@ -543,7 +538,7 @@
|
||||
<div class="logo">
|
||||
<a href="/" title={`Go to ${org.name} Home`} id="logo" data-hveid="8" class="flex items-center">
|
||||
<img
|
||||
src={org.avatar_url || '/logo-192.png'}
|
||||
src={org.avatarUrl || '/logo-192.png'}
|
||||
alt={`${org.name} logo`}
|
||||
class="mx-auto inline-block max-h-10 w-10 rounded"
|
||||
data-atf="1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { SyncLoader } from 'svelte-loading-spinners';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import PlayContainer from './Container.svelte';
|
||||
import PlayHeader from './Header/index.svelte';
|
||||
import { quizStore, playQuizStore } from '$lib/utils/store/org';
|
||||
@@ -43,7 +43,7 @@
|
||||
<div class="">
|
||||
<p>2. Enter Pin</p>
|
||||
{#if isGettingPin}
|
||||
<SyncLoader size="50" color="#1d4ed8" unit="px" duration="1s" />
|
||||
<Spinner class="size-10! text-blue-700!" />
|
||||
{:else}
|
||||
<h3>{$quizStore.pin}</h3>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { SyncLoader } from 'svelte-loading-spinners';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { themeImages, STEPS } from '$lib/utils/constants/quiz';
|
||||
import { quizStore, playQuizStore } from '$lib/utils/store/org';
|
||||
import ConnectToPlay from './ConnectToPlay.svelte';
|
||||
@@ -39,7 +39,7 @@
|
||||
<Podium />
|
||||
{:else}
|
||||
<div class="m-auto w-full">
|
||||
<SyncLoader size="20" color="#1d4ed8" unit="px" duration="1s" />
|
||||
<Spinner class="size-8! text-blue-700!" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
if (error) {
|
||||
const message = error?.message || 'snackbar.lms.error.try_again';
|
||||
console.error('Error updating customizations', message);
|
||||
snackbar.error('snackbar.lms.error.update');
|
||||
snackbar.error('snackbar.update_failed');
|
||||
} else {
|
||||
snackbar.success('snackbar.success_update');
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import { ComingSoon } from '$lib/features/ui';
|
||||
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
|
||||
import { UpgradeBanner } from '$lib/features/ui';
|
||||
import UploadImage from '$lib/components/UploadImage/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import VisitOrgSiteButton from '$lib/components/Buttons/VisitOrgSite.svelte';
|
||||
|
||||
@@ -139,8 +139,8 @@
|
||||
.match({ id: $currentOrg.id });
|
||||
|
||||
if (error) {
|
||||
const message = error?.message || 'snackbar.lms.error.try_again';
|
||||
snackbar.error(`snackbar.lms.error.update ${message}`);
|
||||
const message = error?.message || $t('snackbar.lms.error.try_again');
|
||||
snackbar.error(`${$t('snackbar.update_failed')}: ${message}`);
|
||||
} else {
|
||||
$currentOrg.landingpage = $landingPageSettings as any;
|
||||
snackbar.success('snackbar.success_update');
|
||||
|
||||
@@ -139,7 +139,7 @@
|
||||
message = $t('snackbar.lms.error.username_exists');
|
||||
}
|
||||
|
||||
snackbar.error(`${$t('snackbar.lms.error.update')} ${message}`);
|
||||
snackbar.error(`${$t('snackbar.update_failed')}: ${message}`);
|
||||
loading = false;
|
||||
} finally {
|
||||
loading = false;
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Circle } from 'svelte-loading-spinners';
|
||||
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { getOrgTeam } from '$lib/utils/services/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import type { OrgTeamMember } from '$lib/utils/types/org';
|
||||
import { currentOrg, orgTeam } from '$lib/utils/store/org';
|
||||
import { ROLE_LABEL } from '$lib/utils/constants/roles';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { validateEmailInString } from '$lib/utils/functions/validator';
|
||||
import { triggerSendEmail, NOTIFICATION_NAME } from '$lib/utils/services/notification/notification';
|
||||
|
||||
import { Row, Grid, Column } from './Layout';
|
||||
import SectionTitle from '../SectionTitle.svelte';
|
||||
import TextChip from '$lib/components/Chip/Text.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import { ComingSoon } from '$lib/features/ui';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
|
||||
let emailsStr = $state('');
|
||||
let errorMessage = $state('');
|
||||
let role = $state(ROLE.TUTOR.toString());
|
||||
let isFetching = $state(false);
|
||||
let isLoading = $state(false);
|
||||
let isRemoving: number | null = $state(null);
|
||||
|
||||
async function onSendInvite() {
|
||||
const { hasError, error: _error, emails } = validateEmailInString(emailsStr);
|
||||
|
||||
if (hasError) {
|
||||
errorMessage = _error;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
let apiError = '';
|
||||
emails.forEach(async (email: string, index: number) => {
|
||||
if (apiError) return;
|
||||
|
||||
const doesEmailExist = $orgTeam.some((teamMember) => teamMember.email.toLowerCase() === email.toLowerCase());
|
||||
|
||||
if (doesEmailExist) {
|
||||
snackbar.error('snackbar.team_members.user_exists');
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
const { data, error } = await supabase
|
||||
.from('organizationmember')
|
||||
.insert({
|
||||
organization_id: $currentOrg.id,
|
||||
email,
|
||||
role_id: role,
|
||||
verified: false
|
||||
})
|
||||
.select();
|
||||
console.log('data', data);
|
||||
|
||||
if (error) {
|
||||
apiError = `${error}`;
|
||||
|
||||
console.error('onSendInvite:', error);
|
||||
snackbar.error(`snackbar.team_members.invite_fail`);
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
const [newMember] = data || [];
|
||||
if (newMember) {
|
||||
orgTeam.update((team) => [
|
||||
{
|
||||
id: newMember.id,
|
||||
email: newMember?.email,
|
||||
fullname: newMember?.fullname || '',
|
||||
verified: newMember?.verified,
|
||||
role: ROLE_LABEL[newMember?.role_id] || '',
|
||||
isAdmin: newMember?.role_id === ROLE.ADMIN
|
||||
},
|
||||
...team
|
||||
]);
|
||||
}
|
||||
|
||||
triggerSendEmail(NOTIFICATION_NAME.INVITE_TEACHER, {
|
||||
email: newMember.email,
|
||||
org: {
|
||||
id: $currentOrg.id,
|
||||
name: $currentOrg.name,
|
||||
siteName: $currentOrg.siteName
|
||||
}
|
||||
});
|
||||
|
||||
const isLast = index === emails.length - 1;
|
||||
if (isLast) {
|
||||
snackbar.success('snackbar.team_members.invite_sent');
|
||||
|
||||
emailsStr = '';
|
||||
isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onRemove(id: number) {
|
||||
console.log('onRemove called');
|
||||
isRemoving = id;
|
||||
const { error } = await supabase.from('organizationmember').delete().match({ id });
|
||||
|
||||
if (error) {
|
||||
console.error('onRemove:', error);
|
||||
snackbar.error('snackbar.team_members.remove_fail');
|
||||
} else {
|
||||
orgTeam.update((team) => [...team.filter((member) => member.id !== id)]);
|
||||
}
|
||||
|
||||
isRemoving = null;
|
||||
}
|
||||
|
||||
const fetchTeam = (id: string) => {
|
||||
if (!id) return;
|
||||
|
||||
untrack(async () => {
|
||||
isFetching = true;
|
||||
await getOrgTeam(id);
|
||||
isFetching = false;
|
||||
isFetching = false;
|
||||
});
|
||||
};
|
||||
|
||||
const isTeamMemberAdmin = (members: OrgTeamMember[], profileId: string | undefined) => {
|
||||
return members.some((member) => member.profileId === profileId && member.isAdmin);
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
fetchTeam($currentOrg.id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Grid class="relative mt-5 w-full rounded border border-gray-200 dark:border-neutral-600">
|
||||
<Row class="border-bottom-c py-7">
|
||||
<Column sm={4} md={4} lg={4} class="text-lg"
|
||||
><SectionTitle>{$t('course.navItem.people.teams.add')}</SectionTitle></Column
|
||||
>
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
<p class="text-md mb-5 text-gray-500 dark:text-white">
|
||||
{$t('course.navItem.people.teams.add_team')}
|
||||
</p>
|
||||
|
||||
<div class="">
|
||||
<TextField
|
||||
label={$t('course.navItem.people.teams.invite')}
|
||||
placeholder={$t('course.navItem.people.teams.placeholder')}
|
||||
bind:value={emailsStr}
|
||||
className="mb-3"
|
||||
isDisabled={$isFreePlan}
|
||||
{errorMessage}
|
||||
/>
|
||||
|
||||
<div class="mb-5">
|
||||
<Label class="mb-2 block">{$t('course.navItem.people.teams.role')}</Label>
|
||||
<Select.Root type="single" bind:value={role} disabled={$isFreePlan}>
|
||||
<Select.Trigger 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>
|
||||
</div>
|
||||
|
||||
<PrimaryButton
|
||||
label={$t('course.navItem.people.teams.send_invite')}
|
||||
onClick={onSendInvite}
|
||||
{isLoading}
|
||||
isDisabled={isLoading || $isFreePlan}
|
||||
/>
|
||||
</div>
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
<Row class="border-bottom-c py-7">
|
||||
<Column sm={4} md={4} lg={4} class="text-lg"
|
||||
><SectionTitle>{$t('course.navItem.people.teams.members')}</SectionTitle></Column
|
||||
>
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
{#if isFetching}
|
||||
<Circle />
|
||||
{:else}
|
||||
{#each $orgTeam as teamMember}
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<div class="flex">
|
||||
<p class="mr-3 text-sm text-gray-500 dark:text-white">
|
||||
{teamMember.email}
|
||||
</p>
|
||||
<TextChip value={$t(teamMember.role)} className="text-xs mr-3" size="sm" />
|
||||
{#if !teamMember.verified}
|
||||
<TextChip
|
||||
value={$t('course.navItem.people.teams.invite_sent')}
|
||||
className="text-xs bg-yellow-200 text-yellow-700"
|
||||
size="sm"
|
||||
/>
|
||||
{:else if teamMember.profileId === $profile.id}
|
||||
<ComingSoon label={$t('course.navItem.people.teams.you')} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if teamMember.profileId !== $profile.id && isTeamMemberAdmin($orgTeam, $profile.id)}
|
||||
<PrimaryButton
|
||||
label={$t('course.navItem.people.teams.remove')}
|
||||
variant={VARIANTS.TEXT_DANGER}
|
||||
onClick={() => onRemove(teamMember.id)}
|
||||
isLoading={isRemoving === teamMember.id}
|
||||
isDisabled={isRemoving === teamMember.id}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</Column>
|
||||
</Row>
|
||||
</Grid>
|
||||
@@ -1,42 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { group } from '../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}
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Editor } from '@cio/ui/custom/editor';
|
||||
import type { Content, TiptapEditor } from '@cio/ui/custom/editor';
|
||||
import type { HTMLContent, TiptapEditor } from '@cio/ui/custom/editor';
|
||||
|
||||
interface Props {
|
||||
placeholder?: string | ((node: any) => string);
|
||||
// Content of the editor
|
||||
content?: Content;
|
||||
content?: HTMLContent;
|
||||
// Whether the toolbar should be visible
|
||||
showToolBar?: boolean;
|
||||
// Whether the editor is editable
|
||||
@@ -21,7 +21,7 @@
|
||||
// CSS class for the editor itself
|
||||
editorClass?: string;
|
||||
// Callback functions
|
||||
onChange?: (content: Content) => void;
|
||||
onChange?: (content: HTMLContent) => void;
|
||||
onReady?: (editor: TiptapEditor) => void;
|
||||
onEditorDestroy?: () => void;
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
||||
import ZapIcon from '@lucide/svelte/icons/zap';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
onClick?: any;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { className = '', onClick = () => {}, children }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if $isFreePlan}
|
||||
<button
|
||||
class="flex w-full items-center gap-2 rounded-md border border-blue-700 bg-blue-50 px-2 py-2 transition-all ease-in-out hover:scale-95 {className}"
|
||||
onclick={() => {
|
||||
onClick();
|
||||
openUpgradeModal();
|
||||
}}
|
||||
>
|
||||
<ZapIcon size={16} class="filled" />
|
||||
<p class="text-sm font-semibold text-blue-700">
|
||||
{@render children?.()}
|
||||
</p>
|
||||
</button>
|
||||
{/if}
|
||||
@@ -1,227 +0,0 @@
|
||||
<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 { 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 { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
|
||||
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import Confetti from '$lib/components/Confetti/index.svelte';
|
||||
import StepDoneIcon from '$lib/components/Icons/StepDoneIcon.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
|
||||
const disabledClass = 'bg-gray-300 text-gray-400 hover:cursor-not-allowed';
|
||||
|
||||
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 = $derived((query.get('upgrade') || '') === 'true');
|
||||
let isConfirming = $derived((query.get('confirmation') || '') === '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 onLearnMore() {
|
||||
window.open('https://classroomio.com/blog/early-adopter', '_blank');
|
||||
}
|
||||
function toggleIsYearlyPlan() {
|
||||
isYearlyPlan = !isYearlyPlan;
|
||||
}
|
||||
|
||||
function handleUpgradeSuccess(upgradeSuccessful: boolean) {
|
||||
if (upgradeSuccessful) {
|
||||
onUpgrade();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
handleUpgradeSuccess(Boolean($currentOrg.id && !$isFreePlan && open));
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if upgraded}
|
||||
<Confetti />
|
||||
{/if}
|
||||
|
||||
<Modal
|
||||
{onClose}
|
||||
{open}
|
||||
width="w-4/5"
|
||||
maxWidth={upgraded || isConfirming ? 'max-w-[600px]' : undefined}
|
||||
modalHeading={$t('pricing.modal.heading')}
|
||||
containerClass="pt-4"
|
||||
>
|
||||
{#if upgraded}
|
||||
<div class="relative mb-4 flex w-full flex-col items-center justify-center">
|
||||
<StepDoneIcon />
|
||||
<h4 class="text-2xl">{$t('pricing.modal.thanks')}</h4>
|
||||
<p class="mb-4 text-center">
|
||||
{$t('pricing.modal.plan')}
|
||||
</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<PrimaryButton label={$t('pricing.modal.close')} variant={VARIANTS.OUTLINED} onClick={onClose} />
|
||||
<PrimaryButton label={$t('pricing.modal.learn')} variant={VARIANTS.CONTAINED_DARK} onClick={onLearnMore} />
|
||||
</div>
|
||||
</div>
|
||||
{:else if isConfirming}
|
||||
<div class="relative mb-4 flex w-full flex-col items-center justify-center">
|
||||
<Progress />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="my-2 flex h-full flex-col items-center justify-center">
|
||||
<div class="relative mb-2 flex items-center rounded-[30px] border p-1">
|
||||
<button
|
||||
style="background-color: {isYearlyPlan ? 'initial' : '#1D4EE2'}; color: {isYearlyPlan ? '#5e636b' : '#fff'}"
|
||||
class="rounded-[30px] bg-blue-700 px-3 py-1 text-xs text-white transition-all duration-500 ease-in-out lg:px-4 lg:py-2"
|
||||
onclick={toggleIsYearlyPlan}
|
||||
>
|
||||
{$t('pricing.modal.monthly')}
|
||||
</button>
|
||||
<button
|
||||
style="background-color: {isYearlyPlan ? '#1D4EE2' : ''}; color: {isYearlyPlan ? '#fff' : '#5e636b'}"
|
||||
class="relative rounded-[30px] px-3 py-1 text-xs text-white transition-all duration-500 ease-in-out lg:px-4 lg:py-2"
|
||||
onclick={toggleIsYearlyPlan}
|
||||
>
|
||||
{$t('pricing.modal.annually')}
|
||||
<div class="absolute -top-4 right-[-40%] rounded-full bg-[#006600] px-1.5 py-1 text-[0.7rem] text-white">
|
||||
{$t('pricing.modal.save')}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="isolate grid grid-cols-1 gap-3 overflow-y-scroll p-2 md:overflow-y-hidden lg:grid-cols-3">
|
||||
{#each planNames as planName}
|
||||
<div
|
||||
class="max-w-xl rounded-xl {planName === 'EARLY_ADOPTER' &&
|
||||
'cio-bg-blue'} p-4 ring-1 ring-gray-200 lg:max-w-sm"
|
||||
>
|
||||
<p
|
||||
class="mb-2 text-lg font-semibold leading-8 {planName === 'EARLY_ADOPTER'
|
||||
? 'text-white'
|
||||
: 'text-gray-900'} lg:text-xl dark:text-white"
|
||||
>
|
||||
{PLANS[planName]?.NAME}
|
||||
</p>
|
||||
<p
|
||||
class="text-baseline flex items-baseline gap-x-1 font-medium {planName === 'EARLY_ADOPTER'
|
||||
? 'text-white'
|
||||
: 'text-black'} dark:text-gray-300"
|
||||
>
|
||||
{PLANS[planName]?.PRICE?.CURRENCY}
|
||||
{isYearlyPlan ? PLANS[planName]?.PRICE?.YEARLY : PLANS[planName]?.PRICE?.MONTHLY}
|
||||
</p>
|
||||
<p
|
||||
class=" mt-4 text-sm font-light leading-6 {planName === 'EARLY_ADOPTER'
|
||||
? 'text-white'
|
||||
: 'text-black'} lg:leading-6 dark:text-gray-300"
|
||||
>
|
||||
{PLANS[planName]?.DESCRIPTION}
|
||||
</p>
|
||||
|
||||
<button
|
||||
class="text-md mt-4 block w-full rounded-md {isLoadingPlan === planName && disabledClass} {planName ===
|
||||
'EARLY_ADOPTER'
|
||||
? 'bg-white text-slate-900 hover:bg-indigo-50'
|
||||
: PLANS[planName]?.CTA?.IS_DISABLED
|
||||
? disabledClass
|
||||
: 'bg-primary-900 hover:bg-primary-700 text-white'} flex items-center justify-center py-3 text-center font-medium hover:no-underline lg:rounded-md lg:py-3 lg:text-lg lg:font-semibold"
|
||||
onclick={() => {
|
||||
if (isLoadingPlan === planName) return;
|
||||
|
||||
handleClick(PLANS[planName], planName);
|
||||
}}
|
||||
>
|
||||
{#if isLoadingPlan === planName}
|
||||
<Progress />
|
||||
{:else}
|
||||
{PLANS[planName]?.CTA?.DASHBOARD_LABEL}
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<ul
|
||||
class="mt-4 space-y-2 text-sm {planName === 'EARLY_ADOPTER'
|
||||
? 'text-white'
|
||||
: 'text-black'} dark:text-gray-300"
|
||||
>
|
||||
{#each PLANS[planName]?.FEATURES as features}
|
||||
<li class="flex items-center">
|
||||
<div>
|
||||
<CheckIcon
|
||||
class="custom mr-2 size-4 lg:mr-3 {planName === 'EARLY_ADOPTER'
|
||||
? 'text-white'
|
||||
: 'text-[#1D4EE2]'}"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm">
|
||||
{features}
|
||||
</p>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</Modal>
|
||||
@@ -1,27 +0,0 @@
|
||||
<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="
|
||||
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}
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import Tabs from '$lib/components/Tabs/index.svelte';
|
||||
import { getSupabase } from '$lib/utils/functions/supabase';
|
||||
import TabContent from '$lib/components/TabContent/index.svelte';
|
||||
import { snackbar } from '../Snackbar/store';
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
@@ -11,6 +10,7 @@
|
||||
import { queryUnsplash } from './utils';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { uploadImage } from '$lib/utils/services/upload';
|
||||
|
||||
interface Props {
|
||||
imageURL?: string;
|
||||
@@ -19,8 +19,6 @@
|
||||
|
||||
let { imageURL = $bindable(''), onchange }: Props = $props();
|
||||
|
||||
const supabase = getSupabase();
|
||||
|
||||
const tabs = [
|
||||
{ label: 'Unsplash', value: 'unsplash' },
|
||||
{ label: 'Upload', value: 'upload' }
|
||||
@@ -65,28 +63,20 @@
|
||||
let reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => {
|
||||
uploadImage(file);
|
||||
handleUploadImage(file);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const uploadImage = async (image: File) => {
|
||||
const handleUploadImage = async (image: File) => {
|
||||
isUploading = true;
|
||||
if (!image) {
|
||||
return;
|
||||
}
|
||||
const filename = `uploadwidget/${Date.now()}` + image.name;
|
||||
const { data } = await supabase.storage.from('avatars').upload(filename, image, {
|
||||
cacheControl: '3600',
|
||||
upsert: false
|
||||
});
|
||||
|
||||
if (data) {
|
||||
const { data: response } = await supabase.storage.from('avatars').getPublicUrl(filename);
|
||||
imageURL = response.publicUrl;
|
||||
imageURL = await uploadImage(image);
|
||||
|
||||
onchange?.(response.publicUrl);
|
||||
}
|
||||
onchange?.(imageURL);
|
||||
isUploading = false;
|
||||
|
||||
snackbar.success(`snackbar.landing_page_settings.success.complete`);
|
||||
|
||||
Reference in New Issue
Block a user