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:
+191
-11
@@ -1,4 +1,6 @@
|
||||
/*Write your global styles here, in PostCSS syntax */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Geist:wght@100..900&display=swap');
|
||||
|
||||
@import 'tailwindcss';
|
||||
|
||||
@import '@cio/ui/output.css';
|
||||
@@ -44,17 +46,6 @@ html .dark {
|
||||
border: 2px solid var(--scrollbarBG);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: revert;
|
||||
font-weight: revert;
|
||||
margin: 0.8em 0;
|
||||
}
|
||||
|
||||
.prose h1,
|
||||
.prose h2,
|
||||
.prose h3,
|
||||
@@ -809,6 +800,7 @@ svg.lucide-icon:not(.custom) {
|
||||
--animate-roll: quizRoll 0.8s ease-in-out;
|
||||
--animate-gather: audienceGather 0.7s ease-in-out;
|
||||
--animate-setup-configure: setupConfigure 1s ease-in-out;
|
||||
--animate-border: border 4s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes shakeAnnouncement {
|
||||
@@ -1152,3 +1144,191 @@ svg.lucide-icon:not(.custom) {
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Animated Gradient Border */
|
||||
@property --border-angle {
|
||||
inherits: false;
|
||||
initial-value: 0deg;
|
||||
syntax: '<angle>';
|
||||
}
|
||||
|
||||
@keyframes border {
|
||||
to {
|
||||
--border-angle: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
/* Animated Gradient Border Class */
|
||||
.animate-gradient-border {
|
||||
border: 1px solid transparent;
|
||||
background:
|
||||
linear-gradient(45deg, white, white) padding-box,
|
||||
conic-gradient(
|
||||
from var(--border-angle),
|
||||
rgb(148 163 184 / 0.3) 75%,
|
||||
rgb(37 99 235) 82%,
|
||||
rgb(96 165 250) 88%,
|
||||
rgb(37 99 235) 92%,
|
||||
rgb(148 163 184 / 0.3) 100%
|
||||
)
|
||||
border-box;
|
||||
transition: --border-angle 3s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-gradient-border:hover {
|
||||
animation: border 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dark .animate-gradient-border {
|
||||
background:
|
||||
linear-gradient(45deg, rgb(0 0 0), rgb(0 0 0)) padding-box,
|
||||
conic-gradient(
|
||||
from var(--border-angle),
|
||||
rgb(71 85 105 / 0.48) 75%,
|
||||
rgb(37 99 235) 82%,
|
||||
rgb(96 165 250) 88%,
|
||||
rgb(37 99 235) 92%,
|
||||
rgb(71 85 105 / 0.48) 100%
|
||||
)
|
||||
border-box;
|
||||
transition: --border-angle 3s ease-in-out;
|
||||
}
|
||||
|
||||
.dark .animate-gradient-border:hover {
|
||||
animation: border 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/**
|
||||
Rocket launch animation.
|
||||
**/
|
||||
.rocket-launch {
|
||||
transform-origin: center bottom;
|
||||
transform-box: fill-box;
|
||||
}
|
||||
|
||||
/* Full sequence runs once per hover */
|
||||
.animate-icon:hover .rocket-launch {
|
||||
animation: rocket-sequence 5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Optional: flame flicker during pre-launch + launch */
|
||||
.animate-icon:hover .rocket-launch path:first-of-type {
|
||||
animation: flame-sequence 5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/*
|
||||
Rocket:
|
||||
- 0–60% (~3s): jitter/pre-launch, small moves along nose direction
|
||||
- 60–70%: launch along ~60° diagonal (up-right), fade out
|
||||
- 70–80%: invisible pause
|
||||
- 80–100%: re-enter from opposite side (down-left)
|
||||
*/
|
||||
@keyframes rocket-sequence {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
10% {
|
||||
transform: translate(1px, -2px) scale(1.02);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: translate(2px, -3px) scale(1.03);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translate(1px, -2px) scale(1.02);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: translate(0, 0) scale(1.05);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Launch along a ~60° diagonal: up (-Y) and to the right (+X) */
|
||||
70% {
|
||||
transform: translate(100%, -173%) scale(1.1);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Off-screen / invisible pause (~1s, 70–80%) */
|
||||
80% {
|
||||
/* Reposition off-screen in the opposite direction: down-left */
|
||||
transform: translate(-100%, 173%) scale(1.1);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Fly back into view from below-left to original position */
|
||||
100% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Flame sequence:
|
||||
- Moves opposite rocket travel (down-left: -X, +Y) to match exhaust
|
||||
- No vertical-only scaling; just uniform scale + diagonal translation
|
||||
- Visible 0–70%, then off
|
||||
*/
|
||||
@keyframes flame-sequence {
|
||||
0% {
|
||||
stroke: var(--main-primary-color);
|
||||
opacity: 1;
|
||||
transform: translate(-0.5px, 0.9px) scale(1.02);
|
||||
}
|
||||
|
||||
10% {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
|
||||
20% {
|
||||
opacity: 1;
|
||||
transform: translate(0, 0) scale(1.02);
|
||||
}
|
||||
|
||||
30% {
|
||||
opacity: 0.8;
|
||||
transform: translate(-0.5px, 0.9px) scale(1);
|
||||
}
|
||||
|
||||
40% {
|
||||
opacity: 0.8;
|
||||
transform: translate(0, 0) scale(1.02);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translate(-0.5px, 0.9px) scale(1);
|
||||
}
|
||||
|
||||
60% {
|
||||
opacity: 0.8;
|
||||
transform: translate(0, 0) scale(1.02);
|
||||
}
|
||||
|
||||
70% {
|
||||
opacity: 0;
|
||||
transform: translate(-0.5px, 0.9px) scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-1px, 1.7px) scale(1.12);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,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,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,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>
|
||||
@@ -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`);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { AccountOrg } from '$lib/features/app/types';
|
||||
import type { Cookies } from '@sveltejs/kit';
|
||||
import type { CurrentOrg } from '$lib/utils/types/org';
|
||||
import { OrgApiServer } from '$lib/features/org/api/org.server';
|
||||
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
|
||||
import { blockedSubdomain } from '$lib/utils/constants/app';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { getCurrentOrg } from '$lib/utils/services/org';
|
||||
|
||||
export interface OrgSiteInfo {
|
||||
isOrgSite: boolean;
|
||||
org: CurrentOrg | null;
|
||||
org: AccountOrg | null;
|
||||
subdomain: string;
|
||||
orgSiteName: string;
|
||||
}
|
||||
@@ -26,14 +26,14 @@ export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise<OrgSit
|
||||
|
||||
// Student dashboard
|
||||
if (subdomain) {
|
||||
const org = (await getCurrentOrg(subdomain, true)) || null;
|
||||
const org = await OrgApiServer.getOrgBySiteName(subdomain);
|
||||
|
||||
// Organization by subdomain not found
|
||||
if (!org) {
|
||||
return response;
|
||||
}
|
||||
|
||||
response.org = org;
|
||||
response.org = org as AccountOrg;
|
||||
response.isOrgSite = true;
|
||||
response.orgSiteName = subdomain;
|
||||
response.subdomain = subdomain;
|
||||
@@ -59,12 +59,14 @@ export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise<OrgSit
|
||||
|
||||
// Custom domain
|
||||
if (isURLCustomDomain(url)) {
|
||||
response.org = (await getCurrentOrg(url.host, true, true)) || null;
|
||||
const orgs = await OrgApiServer.getOrgsByCustomDomain(url.host, true);
|
||||
|
||||
if (!response.org) {
|
||||
if (!orgs || orgs.length === 0) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const org = orgs[0];
|
||||
response.org = org as AccountOrg;
|
||||
response.isOrgSite = true;
|
||||
response.orgSiteName = response.org?.siteName || '';
|
||||
response.subdomain = subdomain;
|
||||
@@ -82,7 +84,11 @@ export async function getOrgSiteInfo(url: URL, cookies: Cookies): Promise<OrgSit
|
||||
|
||||
response.isOrgSite = debugMode || !!subdomain;
|
||||
response.orgSiteName = debugMode ? _orgSiteName : subdomain;
|
||||
response.org = (await getCurrentOrg(response.orgSiteName, true)) || null;
|
||||
|
||||
if (response.orgSiteName) {
|
||||
const org = await OrgApiServer.getOrgBySiteName(response.orgSiteName);
|
||||
response.org = (org as AccountOrg) || null;
|
||||
}
|
||||
|
||||
const shouldDeleteCookie = !response.org && _orgSiteName;
|
||||
if (shouldDeleteCookie) {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
// Components exports will be added here when needed
|
||||
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import { page as pageStore } from '$app/state';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import * as Pagination from '@cio/ui/base/pagination';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import { orgApi } from '$lib/features/org/api/org.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { UpgradeBanner } from '$lib/features/ui';
|
||||
import { currentOrg, currentOrgMaxAudience } from '$lib/utils/store/org';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
|
||||
$effect(() => {
|
||||
orgApi.getOrgAudience($currentOrg.id);
|
||||
});
|
||||
|
||||
const headers = [
|
||||
{ key: 'name', value: $t('audience.name') },
|
||||
{ key: 'email', value: $t('audience.email') },
|
||||
{ key: 'date_joined', value: $t('audience.date_joined') }
|
||||
];
|
||||
let pageSize = $state(5);
|
||||
let currentPage = $state(1);
|
||||
let searchValue = $state('');
|
||||
|
||||
const filteredRows = $derived(
|
||||
searchValue
|
||||
? orgApi.audience.filter((row) =>
|
||||
Object.values(row).some((val) => String(val).toLowerCase().includes(searchValue.toLowerCase()))
|
||||
)
|
||||
: orgApi.audience
|
||||
);
|
||||
|
||||
const totalPages = $derived(Math.ceil(filteredRows.length / pageSize));
|
||||
const startIndex = $derived((currentPage - 1) * pageSize);
|
||||
const endIndex = $derived(startIndex + pageSize);
|
||||
const paginatedRows = $derived(filteredRows.slice(startIndex, endIndex));
|
||||
|
||||
$effect(() => {
|
||||
if (filteredRows.length > 0 && currentPage > totalPages) {
|
||||
currentPage = 1;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if orgApi.audience.length >= $currentOrgMaxAudience}
|
||||
<UpgradeBanner>{$t('audience.upgrade')}</UpgradeBanner>
|
||||
{/if}
|
||||
|
||||
<Page.BodyHeader>
|
||||
<Search placeholder="Search..." bind:value={searchValue} class="" />
|
||||
</Page.BodyHeader>
|
||||
|
||||
{#if orgApi.audience.length || orgApi.isLoading}
|
||||
<div class="w-full space-y-4">
|
||||
<!-- Table -->
|
||||
{#if orgApi.isLoading}
|
||||
<div class="space-y-2">
|
||||
{#each Array(pageSize) as _}
|
||||
<Skeleton class="h-12 w-full" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
{#each headers as header}
|
||||
<Table.Head>{header.value}</Table.Head>
|
||||
{/each}
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each paginatedRows as row}
|
||||
<Table.Row>
|
||||
<Table.Cell>
|
||||
<a
|
||||
href={`${pageStore.url.href}/${row.id}/${$currentOrg.id}`}
|
||||
class="text-primary-700 flex items-center gap-2 hover:underline"
|
||||
>
|
||||
<Avatar src={row.avatar_url} width="w-5" height="h-5" />
|
||||
{row.name}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{row.email}</Table.Cell>
|
||||
<Table.Cell>{row.date_joined}</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Pagination -->
|
||||
<div class="flex items-center justify-between">
|
||||
<Pagination.Root
|
||||
count={filteredRows.length}
|
||||
perPage={pageSize}
|
||||
page={currentPage}
|
||||
onPageChange={(page) => (currentPage = page)}
|
||||
>
|
||||
{#snippet children({ pages, currentPage: activePage })}
|
||||
<Pagination.Content>
|
||||
<Pagination.Item>
|
||||
<Pagination.PrevButton />
|
||||
</Pagination.Item>
|
||||
{#each pages as page (page.key)}
|
||||
{#if page.type === 'ellipsis'}
|
||||
<Pagination.Item>
|
||||
<Pagination.Ellipsis />
|
||||
</Pagination.Item>
|
||||
{:else}
|
||||
<Pagination.Item>
|
||||
<Pagination.Link {page} isActive={activePage === page.value}>
|
||||
{page.value}
|
||||
</Pagination.Link>
|
||||
</Pagination.Item>
|
||||
{/if}
|
||||
{/each}
|
||||
<Pagination.Item>
|
||||
<Pagination.NextButton />
|
||||
</Pagination.Item>
|
||||
</Pagination.Content>
|
||||
{/snippet}
|
||||
</Pagination.Root>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Empty title={$t('audience.no_audience')} description={$t('audience.manage')} icon={UsersIcon} variant="page" />
|
||||
{/if}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as AudiencePage } from './audience.svelte';
|
||||
export { default as UserAnalyticsPage } from './user-analytics.svelte';
|
||||
@@ -0,0 +1,206 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import UnfoldVerticalIcon from '@lucide/svelte/icons/unfold-vertical';
|
||||
|
||||
import BookOpenIcon from '@lucide/svelte/icons/book-open';
|
||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||
import ChartLineIcon from '@lucide/svelte/icons/chart-line';
|
||||
import Progress from '$lib/components/Progress/index.svelte';
|
||||
import { ActivityCard, HeroProfileCard, LoadingPage } from '$lib/components/Analytics';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { getAccessToken } from '$lib/utils/functions/supabase';
|
||||
import type { UserAnalytics } from '$lib/utils/types/analytics';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let userAnalytics: UserAnalytics | undefined = $state();
|
||||
|
||||
let courseFilter = $state('all');
|
||||
function toggleCourseFilter(filter: 'completed' | 'incomplete') {
|
||||
if (courseFilter === filter) {
|
||||
courseFilter = 'all';
|
||||
} else {
|
||||
courseFilter = filter;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserAnalytics() {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await fetch('/api/analytics/user', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: accessToken
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(response);
|
||||
snackbar.error('Failed to fetch analytics data');
|
||||
}
|
||||
|
||||
userAnalytics = (await response.json()) as UserAnalytics;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
fetchUserAnalytics();
|
||||
});
|
||||
|
||||
let userMetrics = $derived([
|
||||
{
|
||||
icon: BookOpenIcon,
|
||||
title: $t('analytics.enrolled_courses'),
|
||||
description: $t('analytics.enrolled_courses_description'),
|
||||
percentage: userAnalytics?.courses?.length || 0,
|
||||
hidePercentage: true
|
||||
},
|
||||
{
|
||||
icon: ChartLineIcon,
|
||||
title: $t('analytics.overall_course_progress'),
|
||||
description: $t('analytics.overall_course_progress_description'),
|
||||
percentage: userAnalytics?.overallCourseProgress || 0
|
||||
},
|
||||
{
|
||||
icon: UnfoldVerticalIcon,
|
||||
title: $t('analytics.total_average_grade'),
|
||||
description: $t('analytics.total_average_grade_description'),
|
||||
percentage: userAnalytics?.overallAverageGrade || 0
|
||||
}
|
||||
]);
|
||||
|
||||
let completedCourses = $derived(
|
||||
userAnalytics?.courses?.filter((course) => course.lessons_count === course.lessons_completed)?.length
|
||||
);
|
||||
let incompleteCourses = $derived(
|
||||
userAnalytics?.courses?.filter((course) => course.lessons_count !== course.lessons_completed)?.length
|
||||
);
|
||||
|
||||
let filteredCourses = $derived(
|
||||
userAnalytics?.courses?.filter((course) => {
|
||||
if (courseFilter === 'all') {
|
||||
return true;
|
||||
}
|
||||
return (course.lessons_count === course.lessons_completed) === (courseFilter === 'completed');
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if userAnalytics}
|
||||
<div class="p-5">
|
||||
<a class="text-md flex items-center text-gray-500 dark:text-white" href={`${$currentOrgPath}/audience`}>
|
||||
<ArrowLeftIcon size={16} />
|
||||
{$t('community.ask.go_back')}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-1">
|
||||
<HeroProfileCard user={userAnalytics!.user} />
|
||||
|
||||
<div class="mt-5 px-0">
|
||||
<div class="mt-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each userMetrics as activity}
|
||||
<ActivityCard {activity} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 rounded-md border p-3 md:p-5 dark:border-neutral-600">
|
||||
<h3 class="text-2xl">
|
||||
{$t('analytics.courses')}
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-200">
|
||||
{$t('analytics.progress')}
|
||||
</p>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-200">
|
||||
{$t('analytics.progress_description', {
|
||||
value: completedCourses,
|
||||
total: userAnalytics!.courses.length
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Progress value={userAnalytics!.overallCourseProgress} />
|
||||
<div class="flex items-center justify-between">
|
||||
<Badge
|
||||
type={courseFilter === 'incomplete' ? 'secondary' : 'outline'}
|
||||
class="text-yellow-700 dark:text-yellow-500"
|
||||
onclick={() => toggleCourseFilter('incomplete')}
|
||||
>
|
||||
{incompleteCourses}
|
||||
{$t('analytics.incomplete')}
|
||||
</Badge>
|
||||
<Badge
|
||||
type={courseFilter === 'completed' ? 'secondary' : 'outline'}
|
||||
class="text-green-700 dark:text-green-500"
|
||||
onclick={() => toggleCourseFilter('completed')}
|
||||
>
|
||||
{completedCourses}
|
||||
{$t('analytics.complete')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each filteredCourses as course, index}
|
||||
{#key index}
|
||||
<div
|
||||
class={`mt-5 w-full rounded-md border border-gray-200 p-5 ${
|
||||
course.lessons_count === course.lessons_completed
|
||||
? 'border-green-200 bg-green-50 dark:bg-green-100'
|
||||
: 'border-yellow-200 bg-yellow-50 dark:bg-yellow-100'
|
||||
}`}
|
||||
transition:fade={{ duration: 300 }}
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex w-4/5 items-center gap-4">
|
||||
<img
|
||||
src={course.logo || '/images/classroomio-course-img-template.jpg'}
|
||||
alt={course.title}
|
||||
class="h-20 w-24 rounded-md"
|
||||
/>
|
||||
<div class="mb-4 gap-4">
|
||||
<a href={`/courses/${course.id}`}>
|
||||
<p class="text-lg font-semibold text-gray-600">
|
||||
{course.title}
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<p class="line-clamp-2 text-sm text-gray-600">
|
||||
{course.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
class={`${
|
||||
course.lessons_count === course.lessons_completed
|
||||
? 'bg-green-200 text-green-700'
|
||||
: 'bg-yellow-200 text-yellow-700'
|
||||
}`}
|
||||
>
|
||||
{course.lessons_count === course.lessons_completed
|
||||
? $t('analytics.completed')
|
||||
: $t('analytics.incomplete')}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full items-center gap-1">
|
||||
<Progress value={course.progress_percentage} />
|
||||
<p>{course.progress_percentage}%</p>
|
||||
</div>
|
||||
</div>
|
||||
{/key}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<LoadingPage />
|
||||
{/if}
|
||||
|
||||
@@ -4,13 +4,12 @@ import { ZChangeEmail, ZProfileUpdateForm } from '@cio/utils/validation/account'
|
||||
|
||||
import type { TProfileUpdateForm as TProfileUpdateFormSchema } from '@cio/utils/validation/account';
|
||||
import { authClient } from '$lib/utils/services/auth/client';
|
||||
import generateUUID from '$lib/utils/functions/generateUUID';
|
||||
import { handleLocaleChange } from '$lib/utils/functions/translations';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { profile as profileStore } from '$lib/utils/store/user';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { uploadImage } from '$lib/utils/services/upload';
|
||||
|
||||
export interface TProfileUpdateForm extends Omit<TProfileUpdateFormSchema, 'avatarUrl'> {
|
||||
avatar?: string | File | undefined;
|
||||
@@ -37,24 +36,6 @@ export class ProfileApi extends BaseApiWithErrors {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async uploadAvatar(avatar: File): Promise<string | undefined> {
|
||||
const filename = `user/${generateUUID()}.webp`;
|
||||
|
||||
const { data: uploadData, error: uploadError } = await supabase.storage.from('avatars').upload(filename, avatar, {
|
||||
cacheControl: '3600',
|
||||
upsert: false
|
||||
});
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
|
||||
if (uploadData) {
|
||||
const { data: urlData } = await supabase.storage.from('avatars').getPublicUrl(filename);
|
||||
return urlData.publicUrl;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async updateUserInfo(
|
||||
fields: TProfileUpdateForm,
|
||||
currentProfile: TProfile,
|
||||
@@ -124,11 +105,11 @@ export class ProfileApi extends BaseApiWithErrors {
|
||||
const message = error instanceof Error ? error.message : `${error}`;
|
||||
|
||||
if (message.includes('profile_username_key')) {
|
||||
this.errors.username = 'snackbar.lms.error.username_exists';
|
||||
this.errors.username = t.get('snackbar.lms.error.username_exists');
|
||||
snackbar.error('snackbar.lms.error.username_exists');
|
||||
} else {
|
||||
this.errors.general = message;
|
||||
snackbar.error(`snackbar.lms.error.update: ${message}`);
|
||||
snackbar.error(`${t.get('snackbar.update_failed')}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,7 +126,7 @@ export class ProfileApi extends BaseApiWithErrors {
|
||||
// Handle avatar upload if provided
|
||||
let avatarUrl: string | undefined;
|
||||
if (fields.avatar instanceof File) {
|
||||
avatarUrl = await this.uploadAvatar(fields.avatar);
|
||||
avatarUrl = await uploadImage(fields.avatar);
|
||||
}
|
||||
|
||||
// Update user info via Better Auth (name and image)
|
||||
@@ -185,7 +166,7 @@ export class ProfileApi extends BaseApiWithErrors {
|
||||
|
||||
const { error } = await authClient.changeEmail({
|
||||
newEmail: fields.newEmail,
|
||||
callbackURL: window.location.origin
|
||||
callbackURL: fields.callbackURL
|
||||
});
|
||||
|
||||
if (error) throw new Error(error.message);
|
||||
@@ -197,7 +178,7 @@ export class ProfileApi extends BaseApiWithErrors {
|
||||
const message = error instanceof Error ? error.message : `${error}`;
|
||||
|
||||
this.errors.email = message;
|
||||
snackbar.error(`${t.get('snackbar.lms.error.update')} ${message}`);
|
||||
snackbar.error(`${t.get('snackbar.update_failed')}: ${message}`);
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { goto } from '$app/navigation';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
|
||||
function askCommunity() {
|
||||
goto(`${$currentOrgPath}/community/ask`);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isMobileStore.current}
|
||||
<Button onclick={askCommunity}>
|
||||
<PlusIcon size={16} />
|
||||
</Button>
|
||||
{:else}
|
||||
<Button onclick={askCommunity}>
|
||||
{$t('community.ask_button')}
|
||||
</Button>
|
||||
{/if}
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
</script>
|
||||
|
||||
<div class="relative flex h-[70px] w-full rounded-md border p-2 transition ease-in-out dark:border-neutral-600">
|
||||
<div class="mr-2 flex w-10 flex-col justify-evenly gap-2 rounded-md px-1">
|
||||
<Skeleton class="h-2/6 w-full rounded-sm" />
|
||||
<Skeleton class="h-2/6 w-full rounded-sm" />
|
||||
<Skeleton class="h-2/6 w-full rounded-sm" />
|
||||
</div>
|
||||
|
||||
<div class="flex w-9/12 flex-col gap-2 rounded-md">
|
||||
<!-- first text -->
|
||||
<Skeleton class="h-4 w-3/6 rounded-sm" />
|
||||
<!-- second text -->
|
||||
<Skeleton class="h-4 w-1/4 rounded-sm" />
|
||||
</div>
|
||||
|
||||
<div class="ml-auto w-10 flex-col justify-evenly rounded-md px-1">
|
||||
<Skeleton class="h-4 w-full rounded-sm" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as CommunityListLoader } from './community-list-loader.svelte';
|
||||
export { default as CommunityDeleteModal } from './delete-modal.svelte';
|
||||
export { default as AskCommunityButton } from './ask-community-button.svelte';
|
||||
+48
-50
@@ -1,23 +1,26 @@
|
||||
<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 { Search } from '@cio/ui/custom/search';
|
||||
import MessageCirclePlusIcon from '@lucide/svelte/icons/message-circle-plus';
|
||||
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { courses } from '$lib/components/Courses/store';
|
||||
import { courses } from '$lib/features/course/utils/store';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { fetchCourses } from '$lib/utils/services/courses';
|
||||
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
|
||||
import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
|
||||
|
||||
import CommunityLoader from './Loader.svelte';
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
import { CommunityListLoader } from '../components';
|
||||
import Vote from '$lib/components/Vote/index.svelte';
|
||||
import CoursesEmptyIcon from '$lib/components/Icons/CoursesEmptyIcon.svelte';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
|
||||
import { AskCommunityButton } from '../components';
|
||||
|
||||
interface Props {
|
||||
isLMS?: boolean;
|
||||
@@ -105,42 +108,34 @@
|
||||
);
|
||||
</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}
|
||||
<Page.BodyHeader>
|
||||
<Search placeholder={$t('community.find_question')} bind:value={searchValue} />
|
||||
|
||||
<Select.Root type="single" bind:value={selectedId}>
|
||||
<Select.Trigger class="w-full bg-gray-100 dark:bg-neutral-800">
|
||||
<p class="truncate">
|
||||
{selectedId ? allCourses.find((course) => course.id === selectedId)?.title : $t('community.all')}
|
||||
</p>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value="">{$t('community.all')}</Select.Item>
|
||||
{#each allCourses as course}
|
||||
<Select.Item value={course.id}>{course.title}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Page.BodyHeader>
|
||||
|
||||
{#if isLoading}
|
||||
<CommunityListLoader />
|
||||
<CommunityListLoader />
|
||||
<CommunityListLoader />
|
||||
<CommunityListLoader />
|
||||
{:else}
|
||||
{#each filteredDiscussions as discussion}
|
||||
<div
|
||||
class="border-c m-auto my-4 flex flex-wrap items-center justify-center rounded bg-gray-100 lg:justify-start dark:bg-neutral-800"
|
||||
>
|
||||
<div class="border-bottom-c flex w-full p-3">
|
||||
<Vote value={discussion.votes} />
|
||||
<div class="flex flex-col gap-y-0.5 text-sm">
|
||||
@@ -164,15 +159,18 @@
|
||||
<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>
|
||||
</div>
|
||||
{:else}
|
||||
<Empty
|
||||
title={$t('community.no_question')}
|
||||
description={$t('community.ask_a_question')}
|
||||
icon={MessageSquareMoreIcon}
|
||||
variant="page"
|
||||
>
|
||||
<AskCommunityButton />
|
||||
</Empty>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
h4 {
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CommunityPage } from './community.svelte';
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
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">
|
||||
<div class="mb-4 mr-4 h-[370px] w-72 rounded-md border p-2 transition ease-in-out">
|
||||
<Skeleton class="h-[45%] w-full" />
|
||||
<div class="flex h-[50%] flex-col justify-between pb-2 pt-4">
|
||||
<!-- Title -->
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
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';
|
||||
import { copyCourseModal, deleteCourseModal } from '$lib/features/course/utils/store';
|
||||
|
||||
interface Props {
|
||||
bannerImage: string | undefined;
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
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 { copyCourseModalInitialState, copyCourseModal } from '$lib/features/course/utils/store';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import generateSlug from '$lib/utils/functions/generateSlug';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
import { Button, type ButtonProps } from '@cio/ui/base/button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import { goto } from '$app/navigation';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
import { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
|
||||
|
||||
let {
|
||||
variant,
|
||||
isResponsive = false
|
||||
}: ButtonProps & {
|
||||
isResponsive?: boolean;
|
||||
} = $props();
|
||||
|
||||
function onClick() {
|
||||
goto(`${$currentOrgPath}/courses?create=true`);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isResponsive && isMobileStore.current}
|
||||
<Button variant="outline" size="icon" disabled={!$isOrgAdmin} onclick={onClick}>
|
||||
<PlusIcon size={16} />
|
||||
</Button>
|
||||
{:else}
|
||||
<Button {variant} onclick={onClick} disabled={!$isOrgAdmin}>{$t('courses.heading_button')}</Button>
|
||||
{/if}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { default as CourseCard } from './card.svelte';
|
||||
export { default as CourseCardLoader } from './card-loader.svelte';
|
||||
export { default as CopyCourseModal } from './copy-course-modal.svelte';
|
||||
export { default as CreateCourseButton } from './create-course-button.svelte';
|
||||
export { default as CourseList } from './list.svelte';
|
||||
export { default as CourseListLoader } from './list-loader.svelte';
|
||||
export { default as NewCourseModal } from './new-course-modal.svelte';
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
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 { isMobileStore } from '@cio/ui/hooks/is-mobile.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
@@ -58,7 +58,7 @@
|
||||
<Table.Cell class="truncate">
|
||||
<p>{description}</p>
|
||||
</Table.Cell>
|
||||
{#if !$isMobile}
|
||||
{#if !isMobileStore.current}
|
||||
<Table.Cell class="truncate">{type}</Table.Cell>
|
||||
<Table.Cell>{totalLessons}</Table.Cell>
|
||||
<Table.Cell>{totalStudents}</Table.Cell>
|
||||
+2
-2
@@ -4,8 +4,8 @@
|
||||
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 { validateForm } from '../utils/functions';
|
||||
import { courses, createCourseModal } from '../utils/store';
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
+82
-25
@@ -1,36 +1,72 @@
|
||||
<script lang="ts">
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { Search } from '@cio/ui/custom/search';
|
||||
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import GridIcon from '@lucide/svelte/icons/grid-2x2';
|
||||
import ListIcon from '@lucide/svelte/icons/list';
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
|
||||
import {
|
||||
CourseCard,
|
||||
CourseCardLoader,
|
||||
CreateCourseButton,
|
||||
CourseList,
|
||||
CopyCourseModal,
|
||||
NewCourseModal
|
||||
} from '$lib/features/course/components';
|
||||
|
||||
import 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';
|
||||
import {
|
||||
deleteCourseModal,
|
||||
deleteCourseModalInitialState,
|
||||
courses as coursesStore,
|
||||
courseMetaDeta
|
||||
} from '../utils/store';
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
interface Props {
|
||||
courses?: Course[];
|
||||
emptyTitle?: any;
|
||||
emptyDescription?: any;
|
||||
isExplore?: boolean;
|
||||
searchValue?: string;
|
||||
selectedId?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
courses = [],
|
||||
emptyTitle = $t('courses.course_card.empty_title'),
|
||||
emptyDescription = $t('courses.course_card.empty_description'),
|
||||
isExplore = false
|
||||
isExplore = false,
|
||||
searchValue = $bindable(''),
|
||||
selectedId = $bindable('0')
|
||||
}: Props = $props();
|
||||
|
||||
const filterOptions = $derived([
|
||||
{ value: '0', label: $t('courses.course_filter.date_created') },
|
||||
{ value: '1', label: $t('courses.course_filter.published') },
|
||||
{ value: '2', label: $t('courses.course_filter.lessons') }
|
||||
]);
|
||||
|
||||
const selectedLabel = $derived(
|
||||
filterOptions.find((opt) => opt.value === selectedId)?.label || filterOptions[0].label
|
||||
);
|
||||
|
||||
const setViewPreference = (preference: 'grid' | 'list') => {
|
||||
$courseMetaDeta.view = preference;
|
||||
if (browser) {
|
||||
localStorage.setItem('courseView', preference);
|
||||
}
|
||||
};
|
||||
|
||||
function calcProgressRate(progressRate?: number, totalLessons?: number): number {
|
||||
if (!progressRate || !totalLessons) {
|
||||
return 0;
|
||||
@@ -65,20 +101,50 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<NewCourseModal />
|
||||
|
||||
<CopyCourseModal />
|
||||
|
||||
<DeleteModal
|
||||
onDelete={handleDeleteCourse}
|
||||
bind:open={$deleteCourseModal.open}
|
||||
isLoading={$deleteCourseModal.isDeleting}
|
||||
/>
|
||||
|
||||
<div class="mx-auto my-4 w-full">
|
||||
<Page.BodyHeader align="right">
|
||||
<Search placeholder={$t('courses.search_placeholder')} bind:value={searchValue} />
|
||||
<Select.Root type="single" bind:value={selectedId}>
|
||||
<Select.Trigger class="min-w-[150px]">
|
||||
<p>{selectedId ? selectedLabel : filterOptions[0].label}</p>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each filterOptions as option}
|
||||
<Select.Item value={option.value}>{option.label}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
{#if $courseMetaDeta.view === 'list'}
|
||||
<IconButton onClick={() => setViewPreference('grid')}>
|
||||
<GridIcon size={16} />
|
||||
</IconButton>
|
||||
{:else}
|
||||
<IconButton onClick={() => setViewPreference('list')}>
|
||||
<ListIcon size={16} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</Page.BodyHeader>
|
||||
|
||||
<div class="mx-auto w-full flex-1">
|
||||
{#if $courseMetaDeta.isLoading}
|
||||
<section class={`${$courseMetaDeta.isLoading || courses ? 'cards-container' : ''} `}>
|
||||
<CardLoader />
|
||||
<CardLoader />
|
||||
<CardLoader />
|
||||
<CourseCardLoader />
|
||||
<CourseCardLoader />
|
||||
<CourseCardLoader />
|
||||
</section>
|
||||
{:else if !courses.length}
|
||||
<Empty title={emptyTitle} description={emptyDescription} icon={LibraryBigIcon} variant="page">
|
||||
<CreateCourseButton isResponsive />
|
||||
</Empty>
|
||||
{:else if $courseMetaDeta.view === 'list'}
|
||||
<div class="w-full overflow-hidden rounded-md border">
|
||||
<Table.Root class="w-full table-fixed">
|
||||
@@ -95,7 +161,7 @@
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each courses as courseData}
|
||||
<List
|
||||
<CourseList
|
||||
id={courseData.id}
|
||||
title={courseData.title}
|
||||
type={$t(`course.navItem.settings.${courseData.type.toLowerCase()}`)}
|
||||
@@ -112,7 +178,7 @@
|
||||
<section class={`relative ${$courseMetaDeta.isLoading || courses ? 'cards-container' : ''} `}>
|
||||
{#each courses as courseData}
|
||||
{#key courseData.id}
|
||||
<Card
|
||||
<CourseCard
|
||||
id={courseData.id}
|
||||
slug={courseData.slug}
|
||||
bannerImage={courseData.logo || '/images/classroomio-course-img-template.jpg'}
|
||||
@@ -132,12 +198,3 @@
|
||||
</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}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CoursesPage } from './courses.svelte';
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { TCancelOrgPlan, TCreateOrgPlan, TUpdateOrgPlan } from '@cio/utils/validation/organization';
|
||||
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
|
||||
|
||||
/**
|
||||
* Server-side API methods for organization plan operations
|
||||
* These methods use API key authentication and should only be used in server-side files
|
||||
* (+server.ts, +layout.server.ts) to prevent API keys from being exposed to the client.
|
||||
*/
|
||||
export class OrgPlanApiServer {
|
||||
/**
|
||||
* Creates a new organization plan (server-side)
|
||||
* @param params Organization plan creation parameters
|
||||
* @returns Response data or null on error
|
||||
*/
|
||||
static async createOrgPlan(params: TCreateOrgPlan) {
|
||||
try {
|
||||
const response = await classroomio.organization.plan.$post(
|
||||
{
|
||||
json: params
|
||||
},
|
||||
getApiKeyHeaders()
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return data.success ? data.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error creating org plan (server):', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an organization plan (server-side)
|
||||
* @param params Update parameters (subscriptionId, payload)
|
||||
* @returns Response data or null on error
|
||||
*/
|
||||
static async updateOrgPlan(params: TUpdateOrgPlan) {
|
||||
try {
|
||||
const response = await classroomio.organization.plan.$put(
|
||||
{
|
||||
json: params
|
||||
},
|
||||
getApiKeyHeaders()
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return data.success ? data.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error updating org plan (server):', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels an organization plan (server-side)
|
||||
* @param params Cancel parameters (subscriptionId, payload)
|
||||
* @returns Response data or null on error
|
||||
*/
|
||||
static async cancelOrgPlan(params: TCancelOrgPlan) {
|
||||
try {
|
||||
const response = await classroomio.organization.plan.cancel.$post(
|
||||
{
|
||||
json: params
|
||||
},
|
||||
getApiKeyHeaders()
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return data.success ? data.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error canceling org plan (server):', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { BaseApi, classroomio } from '$lib/utils/services/api';
|
||||
|
||||
import type { TNewOrganizationPlan } from '@cio/db/types';
|
||||
import type { TCancelOrgPlan, TCreateOrgPlan, TUpdateOrgPlan } from '@cio/utils/validation/organization';
|
||||
|
||||
/**
|
||||
* API class for organization plan operations
|
||||
* API class for organization plan operations (client-side)
|
||||
* For server-side operations, use OrgPlanApiServer from './org-plan.server.ts'
|
||||
*/
|
||||
class OrgPlanApi extends BaseApi {
|
||||
/**
|
||||
@@ -11,7 +11,7 @@ class OrgPlanApi extends BaseApi {
|
||||
* @param params Organization plan creation parameters
|
||||
* @returns Created organization plan
|
||||
*/
|
||||
async createOrgPlan(params: TNewOrganizationPlan) {
|
||||
async createOrgPlan(params: TCreateOrgPlan) {
|
||||
return this.execute({
|
||||
requestFn: () => classroomio.organization.plan.$post({ json: params }),
|
||||
logContext: 'creating organization plan'
|
||||
@@ -23,7 +23,7 @@ class OrgPlanApi extends BaseApi {
|
||||
* @param params Update parameters (subscriptionId, payload)
|
||||
* @returns Updated organization plan
|
||||
*/
|
||||
async updateOrgPlan(params: { subscriptionId: string; payload: Record<string, unknown> }) {
|
||||
async updateOrgPlan(params: TUpdateOrgPlan) {
|
||||
return this.execute({
|
||||
requestFn: () => classroomio.organization.plan.$put({ json: params }),
|
||||
logContext: 'updating organization plan'
|
||||
@@ -35,7 +35,7 @@ class OrgPlanApi extends BaseApi {
|
||||
* @param params Cancel parameters (subscriptionId, payload)
|
||||
* @returns Updated organization plan
|
||||
*/
|
||||
async cancelOrgPlan(params: { subscriptionId: string; payload: Record<string, unknown> }) {
|
||||
async cancelOrgPlan(params: TCancelOrgPlan) {
|
||||
return this.execute({
|
||||
requestFn: () => classroomio.organization.plan.cancel.$post({ json: params }),
|
||||
logContext: 'canceling organization plan'
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
|
||||
|
||||
/**
|
||||
* Server-side API methods for organization operations
|
||||
* These methods use API key authentication and should only be used in server-side files
|
||||
* (+server.ts, +layout.server.ts) to prevent API keys from being exposed to the client.
|
||||
*/
|
||||
export class OrgApiServer {
|
||||
/**
|
||||
* Gets organization by siteName (server-side)
|
||||
* @param siteName Organization site name
|
||||
* @returns Organization or null
|
||||
*/
|
||||
static async getOrgBySiteName(siteName: string) {
|
||||
try {
|
||||
const response = await classroomio.organization.$get(
|
||||
{
|
||||
query: { siteName }
|
||||
},
|
||||
getApiKeyHeaders()
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return data.success && data.data && data.data.length > 0 ? data.data[0] : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching organization by siteName (server):', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets organizations by custom domain (server-side)
|
||||
* @param customDomain Custom domain
|
||||
* @param isCustomDomainVerified Whether the domain is verified
|
||||
* @returns Array of organizations
|
||||
*/
|
||||
static async getOrgsByCustomDomain(customDomain: string, isCustomDomainVerified = true) {
|
||||
try {
|
||||
const response = await classroomio.organization.$get(
|
||||
{
|
||||
query: {
|
||||
customDomain,
|
||||
isCustomDomainVerified
|
||||
}
|
||||
},
|
||||
getApiKeyHeaders()
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return data.success && data.data ? data.data : [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching organizations by custom domain (server):', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,45 @@
|
||||
import { BaseApi, classroomio } from '$lib/utils/services/api';
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
import type {
|
||||
CoursesByOrganizationSiteName,
|
||||
DeleteTeamResponse,
|
||||
GetAudienceResponse,
|
||||
GetCoursesBySiteNameResponse,
|
||||
InviteTeamResponse,
|
||||
OrganizationAudience,
|
||||
OrganizationTeamMembers
|
||||
} from '../utils/types';
|
||||
import type { TCreateOrganization, TGetOrganizations, TUpdateOrganization } from '@cio/utils/validation/organization';
|
||||
import { ZCreateOrganization, ZUpdateOrganization } from '@cio/utils/validation/organization';
|
||||
import { currentOrg, orgs } from '$lib/utils/store/org';
|
||||
|
||||
import type { AccountOrg } from '$lib/features/app/types';
|
||||
import type { GetTeamResponse } from '../utils/types';
|
||||
import type { TGetOrganizations } from '@cio/utils/validation/organization';
|
||||
import { orgs } from '$lib/utils/store/org';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { ROLE_LABEL } from '$lib/utils/constants/roles';
|
||||
import type { UpdateOrganizationRequestType } from './types';
|
||||
import { get } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { uploadImage } from '$lib/utils/services/upload';
|
||||
|
||||
export interface TOrgUpdateForm {
|
||||
name?: string;
|
||||
avatar?: string | File | undefined;
|
||||
theme?: string;
|
||||
landingpage?: AccountOrg['landingpage'];
|
||||
siteName?: string;
|
||||
customDomain?: string | null;
|
||||
isCustomDomainVerified?: boolean;
|
||||
customization?: AccountOrg['customization'];
|
||||
}
|
||||
|
||||
/**
|
||||
* API class for organization operations
|
||||
*/
|
||||
class OrgApi extends BaseApi {
|
||||
class OrgApi extends BaseApiWithErrors {
|
||||
teamMembers = $state<OrganizationTeamMembers>([]);
|
||||
audience = $state<OrganizationAudience>([]);
|
||||
orgSiteCourses = $state<CoursesByOrganizationSiteName>([]);
|
||||
@@ -32,7 +57,16 @@ class OrgApi extends BaseApi {
|
||||
}),
|
||||
logContext: 'fetching organization team',
|
||||
onSuccess: (response) => {
|
||||
this.teamMembers = response.data;
|
||||
// Map API response to include role and isAdmin directly in the API layer
|
||||
const mappedTeam = response.data.map((member) => ({
|
||||
...member,
|
||||
role: ROLE_LABEL[member.roleId] || '',
|
||||
isAdmin: member.roleId === ROLE.ADMIN,
|
||||
verified: member.verified ?? false
|
||||
}));
|
||||
|
||||
// Update both API state and store
|
||||
this.teamMembers = mappedTeam;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -42,7 +76,9 @@ class OrgApi extends BaseApi {
|
||||
* @param orgId Organization ID
|
||||
* @returns Audience array
|
||||
*/
|
||||
async getOrgAudience(orgId: string) {
|
||||
async getOrgAudience(orgId?: string) {
|
||||
if (!orgId) return;
|
||||
|
||||
return this.execute<GetAudienceResponse>({
|
||||
requestFn: () =>
|
||||
classroomio.organization[':orgId'].audience.$get({
|
||||
@@ -103,6 +139,186 @@ class OrgApi extends BaseApi {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new organization with the current user as owner
|
||||
* @param fields Organization creation data (name, siteName)
|
||||
*/
|
||||
async create(fields: TCreateOrganization) {
|
||||
const result = ZCreateOrganization.safeParse(fields);
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'organization');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.execute<typeof classroomio.organization.$post>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.$post({
|
||||
json: result.data
|
||||
}),
|
||||
logContext: 'creating organization',
|
||||
onSuccess: (response) => {
|
||||
if (!response.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { organization, organizations } = response.data;
|
||||
|
||||
// Update stores
|
||||
orgs.set(organizations);
|
||||
const createdOrg = organizations.find((org) => org.id === organization.id);
|
||||
if (createdOrg) {
|
||||
currentOrg.set(createdOrg);
|
||||
}
|
||||
|
||||
snackbar.success('snackbar.success_update');
|
||||
|
||||
goto(resolve(`/org/${organization.siteName}`, {}));
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error(result);
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result) {
|
||||
// Field-specific error automatically mapped to this.errors[field]
|
||||
this.errors[result.field as string] = result.error;
|
||||
} else if ('error' in result) {
|
||||
this.errors.general = result.error;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an organization
|
||||
* @param orgId Organization ID
|
||||
* @param fields Organization update data (name, avatar, theme, landingpage)
|
||||
*/
|
||||
async update(orgId: string, fields: TOrgUpdateForm) {
|
||||
const result = ZUpdateOrganization.safeParse(fields);
|
||||
|
||||
if (!result.success) {
|
||||
this.errors = mapZodErrorsToTranslations(result.error, 'organization');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
// Handle avatar upload if provided
|
||||
let avatarUrl: string | undefined;
|
||||
if (fields.avatar instanceof File) {
|
||||
avatarUrl = await uploadImage(fields.avatar);
|
||||
} else if (typeof fields.avatar === 'string') {
|
||||
avatarUrl = fields.avatar;
|
||||
}
|
||||
|
||||
// Build update payload
|
||||
fields.avatar = undefined;
|
||||
const updates: TUpdateOrganization = {
|
||||
...fields,
|
||||
avatarUrl
|
||||
};
|
||||
|
||||
await this.execute<UpdateOrganizationRequestType>({
|
||||
requestFn: () =>
|
||||
classroomio.organization[':orgId'].$put({
|
||||
param: { orgId },
|
||||
// @ts-expect-error - the json type is not inferred correctly
|
||||
json: updates
|
||||
}),
|
||||
logContext: 'updating organization',
|
||||
onSuccess: (response) => {
|
||||
if (!response.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
orgs.update((_orgs) =>
|
||||
_orgs.map((org) => {
|
||||
if (org.id === orgId) {
|
||||
return { ...org, ...response.data };
|
||||
}
|
||||
|
||||
return org;
|
||||
})
|
||||
);
|
||||
const currentOrgData = get(currentOrg);
|
||||
|
||||
if (currentOrgData.id === orgId) {
|
||||
currentOrg.update((org) => ({
|
||||
...org,
|
||||
...response.data
|
||||
}));
|
||||
}
|
||||
|
||||
snackbar.success('snackbar.course_settings.success.update_successful');
|
||||
|
||||
this.success = true;
|
||||
this.errors = {};
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error updating organization:', error);
|
||||
|
||||
const message = error instanceof Error ? error.message : `${error}`;
|
||||
this.errors.general = message;
|
||||
|
||||
snackbar.error(`${t.get('snackbar.update_failed')}: ${message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invites team members to the organization
|
||||
* @param orgId Organization ID
|
||||
* @param emails Array of email addresses
|
||||
* @param roleId Role ID (ADMIN or TUTOR)
|
||||
*/
|
||||
async inviteTeamMembers(orgId: string, emails: string[], roleId: number) {
|
||||
return this.execute<InviteTeamResponse>({
|
||||
requestFn: () =>
|
||||
classroomio.organization[':orgId'].team.invite.$post({
|
||||
param: { orgId },
|
||||
// @ts-expect-error - the json type is not inferred correctly
|
||||
json: { emails, roleId }
|
||||
}),
|
||||
logContext: 'inviting team members',
|
||||
onSuccess: () => {
|
||||
this.getOrgTeam(orgId);
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error(result);
|
||||
return;
|
||||
}
|
||||
if ('error' in result && 'field' in result) {
|
||||
this.errors[result.field as string] = result.error;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a team member from the organization
|
||||
* @param orgId Organization ID
|
||||
* @param memberId Member ID to remove
|
||||
*/
|
||||
async removeTeamMember(orgId: string, memberId: number) {
|
||||
return this.execute<DeleteTeamResponse>({
|
||||
requestFn: () =>
|
||||
classroomio.organization[':orgId'].team[':memberId'].$delete({
|
||||
param: { orgId, memberId: memberId.toString() }
|
||||
}),
|
||||
logContext: 'removing team member',
|
||||
onSuccess: () => {
|
||||
this.getOrgTeam(orgId);
|
||||
},
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error(result);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const orgApi = new OrgApi();
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
export type UpdateOrganizationRequestType = (typeof classroomio.organization)[':orgId']['$put'];
|
||||
export type UpdateOrganizationResponse = InferResponseType<UpdateOrganizationRequestType>;
|
||||
export type UpdateOrganizationSuccess = Extract<UpdateOrganizationResponse, { success: true }>;
|
||||
@@ -5,7 +5,16 @@ export type GetTeamResponse = (typeof classroomio.organization)[':orgId']['team'
|
||||
export type OrganizationTeamResponse = InferResponseType<GetTeamResponse> | null;
|
||||
export type OrganizationTeamSuccess = Extract<InferResponseType<GetTeamResponse>, { success: true }>;
|
||||
|
||||
export type OrganizationTeamMembers = OrganizationTeamSuccess['data'];
|
||||
export type OrganizationTeamMembersRaw = OrganizationTeamSuccess['data'];
|
||||
|
||||
// Mapped team members with role and isAdmin (mapped in API layer)
|
||||
export type OrganizationTeamMembers = Array<
|
||||
OrganizationTeamMembersRaw[number] & {
|
||||
role: string;
|
||||
isAdmin: boolean;
|
||||
verified: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
// Audience types
|
||||
export type GetAudienceResponse = (typeof classroomio.organization)[':orgId']['audience']['$get'];
|
||||
@@ -23,3 +32,10 @@ export type CoursesByOrganizationSiteNameSuccess = Extract<
|
||||
>;
|
||||
|
||||
export type CoursesByOrganizationSiteName = CoursesByOrganizationSiteNameSuccess['data'];
|
||||
|
||||
export type InviteTeamResponse = (typeof classroomio.organization)[':orgId']['team']['invite']['$post'];
|
||||
export type InviteTeamSuccess = Extract<InferResponseType<InviteTeamResponse>, { success: true }>;
|
||||
export type InviteTeamData = InviteTeamSuccess['data'];
|
||||
|
||||
export type DeleteTeamResponse = (typeof classroomio.organization)[':orgId']['team'][':memberId']['$delete'];
|
||||
export type DeleteTeamSuccess = Extract<InferResponseType<DeleteTeamResponse>, { success: true }>;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
sm?: number;
|
||||
md?: number;
|
||||
lg?: number;
|
||||
class?: string;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { sm = 8, md = 8, lg = 8, class: className = '', children }: Props = $props();
|
||||
|
||||
const getColSpan = (cols: number) => {
|
||||
const spanMap = {
|
||||
1: 'col-span-1',
|
||||
2: 'col-span-2',
|
||||
3: 'col-span-3',
|
||||
4: 'col-span-4',
|
||||
5: 'col-span-5',
|
||||
6: 'col-span-6',
|
||||
7: 'col-span-7',
|
||||
8: 'col-span-8',
|
||||
9: 'col-span-9',
|
||||
10: 'col-span-10',
|
||||
11: 'col-span-11',
|
||||
12: 'col-span-12',
|
||||
13: 'col-span-13',
|
||||
14: 'col-span-14',
|
||||
15: 'col-span-15',
|
||||
16: 'col-span-16'
|
||||
};
|
||||
return spanMap[cols] || 'col-span-full';
|
||||
};
|
||||
|
||||
const smClass = getColSpan(sm);
|
||||
const mdClass = `md:${getColSpan(md)}`;
|
||||
const lgClass = `lg:${getColSpan(lg)}`;
|
||||
</script>
|
||||
|
||||
<div class="{smClass} {mdClass} {lgClass} {className} grid">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
class?: string;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="w-full {className}">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
id?: string;
|
||||
class?: string;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { id, class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div {id} class="lg:grid-cols-16 grid grid-cols-1 gap-5 px-3 {className}">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as Grid } from './Grid.svelte';
|
||||
export { default as Row } from './Row.svelte';
|
||||
export { default as Column } from './Column.svelte';
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as LanguagePicker } from './language-picker.svelte';
|
||||
export * from './Layout';
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
import { LANGUAGES } from '$lib/utils/constants/translation';
|
||||
import { t, initialized } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
value?: TLocale;
|
||||
hasLangChanged?: boolean;
|
||||
change?: () => void;
|
||||
}
|
||||
|
||||
let { className = '', value = $bindable('en'), hasLangChanged = $bindable(false), change }: Props = $props();
|
||||
|
||||
function handleSelect(selectedValue: string) {
|
||||
value = selectedValue as TLocale;
|
||||
hasLangChanged = true;
|
||||
change?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $initialized}
|
||||
<div class={className}>
|
||||
<Label class="mb-2 block">{$t('content.toggle_label')}</Label>
|
||||
<Select.Root type="single" bind:value onValueChange={handleSelect}>
|
||||
<Select.Trigger class="w-full">
|
||||
<p>{value ? LANGUAGES.find((lang) => lang.id === value)?.text : 'Pick a Language'}</p>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each LANGUAGES as language}
|
||||
<Select.Item value={language.id}>{language.text}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { currentOrgPlan, isFreePlan } from '$lib/utils/store/org';
|
||||
import HourglassIcon from '@lucide/svelte/icons/hourglass';
|
||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
||||
import ReceiptIcon from '@lucide/svelte/icons/receipt';
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
|
||||
let isRedirecting = $state(false);
|
||||
|
||||
async function onOpenBilling() {
|
||||
console.log({
|
||||
plan: $currentOrgPlan
|
||||
});
|
||||
|
||||
try {
|
||||
if ($currentOrgPlan?.provider === 'polar') {
|
||||
const url = new URL('/api/polar/portal', window.location.origin);
|
||||
url.searchParams.set('customerId', $currentOrgPlan?.customerId || '');
|
||||
|
||||
window.open(url.toString(), '_blank');
|
||||
}
|
||||
|
||||
if ($currentOrgPlan?.provider === 'lmz') {
|
||||
isRedirecting = true;
|
||||
const url = new URL('/api/lmz/subscription', window.location.origin);
|
||||
url.searchParams.set('subscriptionId', $currentOrgPlan?.subscriptionId);
|
||||
|
||||
const response = await fetch(url.toString());
|
||||
const result = await response.json();
|
||||
|
||||
isRedirecting = false;
|
||||
|
||||
window.open(result.customerPortal, '_blank');
|
||||
}
|
||||
} catch (error) {
|
||||
isRedirecting = false;
|
||||
|
||||
console.error(error);
|
||||
snackbar.error($t('settings.billing.error'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="max-w-lg! flex w-full flex-col gap-4 px-2">
|
||||
<p class="text-md font-medium">{$t('settings.billing.manage')}</p>
|
||||
|
||||
{#if $isFreePlan}
|
||||
<Item.Root variant="outline">
|
||||
<Item.Media variant="icon">
|
||||
<HourglassIcon />
|
||||
</Item.Media>
|
||||
<Item.Content>
|
||||
<Item.Description>{$t('settings.billing.active')}</Item.Description>
|
||||
</Item.Content>
|
||||
<Item.Actions>
|
||||
<Button size="sm" variant="outline" onclick={openUpgradeModal}>Upgrade</Button>
|
||||
</Item.Actions>
|
||||
</Item.Root>
|
||||
{:else}
|
||||
<Item.Root variant="outline">
|
||||
<Item.Media variant="icon">
|
||||
<ReceiptIcon />
|
||||
</Item.Media>
|
||||
<Item.Content>
|
||||
<Item.Description>{$t('settings.billing.provider')}</Item.Description>
|
||||
</Item.Content>
|
||||
<Item.Actions>
|
||||
<Button size="sm" variant="outline" onclick={onOpenBilling} loading={isRedirecting}>
|
||||
<ExternalLinkIcon class="size-4" />
|
||||
{$t('settings.billing.open_billing')}
|
||||
</Button>
|
||||
</Item.Actions>
|
||||
</Item.Root>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { orgApi } from '$lib/features/org/api/org.svelte';
|
||||
import { handleOpenWidget } from '$lib/components/CourseLandingPage/store';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
|
||||
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
|
||||
let widgetKey = $state('');
|
||||
|
||||
function widgetControl(key: string) {
|
||||
widgetKey = key;
|
||||
$handleOpenWidget.open = true;
|
||||
}
|
||||
|
||||
export async function handleSave() {
|
||||
await orgApi.update($currentOrg.id, {
|
||||
customization: $currentOrg.customization
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<Field.Group class="max-w-md! w-full px-2">
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('components.settings.customize_lms.dashboard.title')}</Field.Legend>
|
||||
<Field.Group>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Field.Label>{$t('components.settings.customize_lms.dashboard.community')}</Field.Label>
|
||||
<Switch bind:checked={$currentOrg.customization.dashboard.community} />
|
||||
<Field.Description class="text-sm">
|
||||
{$currentOrg.customization.dashboard.community
|
||||
? $t('components.settings.customize_lms.enabled')
|
||||
: $t('components.settings.customize_lms.disabled')}
|
||||
</Field.Description>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field orientation="horizontal">
|
||||
<Field.Label>{$t('components.settings.customize_lms.dashboard.exercises')}</Field.Label>
|
||||
<Switch bind:checked={$currentOrg.customization.dashboard.exercise} />
|
||||
<Field.Description class="text-sm">
|
||||
{$currentOrg.customization.dashboard.exercise
|
||||
? $t('components.settings.customize_lms.enabled')
|
||||
: $t('components.settings.customize_lms.disabled')}
|
||||
</Field.Description>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('components.settings.customize_lms.dashboard.banner_image')}</Field.Label>
|
||||
<Button variant="outline" onclick={() => widgetControl('banner-image')}>
|
||||
{$t('components.settings.customize_lms.dashboard.banner_image_btn')}
|
||||
</Button>
|
||||
{#if $currentOrg.customization.dashboard.bannerImage}
|
||||
<img alt="Banner" src={$currentOrg.customization.dashboard.bannerImage} class="mt-2 w-full rounded-md" />
|
||||
{/if}
|
||||
{#if $handleOpenWidget.open && widgetKey === 'banner-image'}
|
||||
<UploadWidget bind:imageURL={$currentOrg.customization.dashboard.bannerImage} />
|
||||
{/if}
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('components.settings.customize_lms.dashboard.banner_text')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('components.settings.customize_lms.dashboard.banner_text_placeholder')}
|
||||
bind:value={$currentOrg.customization.dashboard.bannerText}
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('components.settings.customize_lms.course.title')}</Field.Legend>
|
||||
<Field.Group>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Field.Label>{$t('components.settings.customize_lms.course.newsfeed')}</Field.Label>
|
||||
<Switch bind:checked={$currentOrg.customization.course.newsfeed} />
|
||||
<Field.Description class="text-gray-600">
|
||||
{$currentOrg.customization.course.newsfeed
|
||||
? $t('components.settings.customize_lms.enabled')
|
||||
: $t('components.settings.customize_lms.disabled')}
|
||||
</Field.Description>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field orientation="horizontal">
|
||||
<Field.Label>{$t('components.settings.customize_lms.course.grading')}</Field.Label>
|
||||
<Switch bind:checked={$currentOrg.customization.course.grading} />
|
||||
<Field.Description class="text-gray-600">
|
||||
{$currentOrg.customization.course.grading
|
||||
? $t('components.settings.customize_lms.enabled')
|
||||
: $t('components.settings.customize_lms.disabled')}
|
||||
</Field.Description>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('components.settings.customize_lms.apps.title')}</Field.Legend>
|
||||
<Field.Group>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Field.Label>{$t('components.settings.customize_lms.apps.poll')}</Field.Label>
|
||||
<Switch bind:checked={$currentOrg.customization.apps.poll} />
|
||||
<Field.Description class="text-gray-600">
|
||||
{$currentOrg.customization.apps.poll
|
||||
? $t('components.settings.customize_lms.enabled')
|
||||
: $t('components.settings.customize_lms.disabled')}
|
||||
</Field.Description>
|
||||
</Field.Field>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Field.Label>{$t('components.settings.customize_lms.apps.live_comment')}</Field.Label>
|
||||
<Switch bind:checked={$currentOrg.customization.apps.comments} />
|
||||
<Field.Description class="text-gray-600">
|
||||
{$currentOrg.customization.apps.comments
|
||||
? $t('components.settings.customize_lms.enabled')
|
||||
: $t('components.settings.customize_lms.disabled')}
|
||||
</Field.Description>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
@@ -0,0 +1,360 @@
|
||||
<script lang="ts">
|
||||
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
|
||||
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import Copy from '@lucide/svelte/icons/copy';
|
||||
import isValidDomain from 'is-valid-domain';
|
||||
import { goto } from '$app/navigation';
|
||||
import { untrack } from 'svelte';
|
||||
import { parse } from 'tldts';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { orgApi } from '$lib/features/org/api/org.svelte';
|
||||
import { blockedSubdomain } from '$lib/utils/constants/app';
|
||||
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
|
||||
import { copyToClipboard } from '$lib/utils/functions/formatYoutubeVideo';
|
||||
import { updateOrgSiteNameValidation } from '$lib/utils/functions/validator';
|
||||
import { sanitizeDomain, sendDomainRequest } from '$lib/utils/functions/domain';
|
||||
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import { DomainInput } from '@cio/ui/custom/domain-input';
|
||||
import { ComingSoon } from '$lib/features/ui';
|
||||
import { UpgradeBanner } from '$lib/features/ui';
|
||||
import UploadImage from '$lib/components/UploadImage/index.svelte';
|
||||
import VisitOrgSiteButton from '$lib/components/Buttons/VisitOrgSite.svelte';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
|
||||
let siteName = $derived($currentOrg.siteName);
|
||||
let customDomain = $state('');
|
||||
let customCode = $state('');
|
||||
let favicon = $state('');
|
||||
let isLoading = $state(false);
|
||||
let isCustomDomainLoading = $state(false);
|
||||
let isRefreshing = $state(false);
|
||||
|
||||
const isDomainValid = $derived(isValidDomain(sanitizeDomain(customDomain), { subdomain: true }));
|
||||
|
||||
type Error = {
|
||||
siteName: string;
|
||||
customDomain: string;
|
||||
};
|
||||
let errors: Error = $state({
|
||||
siteName: '',
|
||||
customDomain: ''
|
||||
});
|
||||
|
||||
async function handleSaveSiteName() {
|
||||
errors = updateOrgSiteNameValidation(siteName) as Error;
|
||||
|
||||
if (Object.values(errors).length) {
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (blockedSubdomain.includes(siteName || '')) {
|
||||
errors.siteName = 'Sitename already exists.';
|
||||
return;
|
||||
}
|
||||
isLoading = true;
|
||||
|
||||
await orgApi.update($currentOrg.id, {
|
||||
siteName
|
||||
});
|
||||
|
||||
if (orgApi.success) {
|
||||
$currentOrg.siteName = siteName;
|
||||
goto(`/org/${$currentOrg.siteName}/settings/domains`);
|
||||
} else {
|
||||
if (orgApi.errors.general) {
|
||||
errors.siteName = orgApi.errors.general;
|
||||
} else {
|
||||
errors.siteName = $t('add_org.sitename');
|
||||
}
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
async function handleSaveCustomDomain() {
|
||||
if (!isDomainValid) return;
|
||||
|
||||
const sanitizedDomain = sanitizeDomain(customDomain);
|
||||
|
||||
const details = parse(sanitizedDomain);
|
||||
if (!details.subdomain) {
|
||||
errors.customDomain = $t('components.settings.domains.custom_domain_error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (sanitizedDomain.includes('classroomio.com')) {
|
||||
errors.customDomain = $t('components.settings.domains.custom_domain_not_classroomio');
|
||||
return;
|
||||
}
|
||||
|
||||
isCustomDomainLoading = true;
|
||||
|
||||
await orgApi.update($currentOrg.id, {
|
||||
customDomain: sanitizedDomain
|
||||
});
|
||||
|
||||
if (!orgApi.success) {
|
||||
errors.customDomain = orgApi.errors.general || 'Failed to update custom domain';
|
||||
isCustomDomainLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sendDomainRequest('add_domain', sanitizedDomain);
|
||||
const data = await response.json();
|
||||
console.log('added domain to vercel', data);
|
||||
} catch (error) {
|
||||
console.log('Error: adding domain to vercel', error);
|
||||
snackbar.error(error as string);
|
||||
isCustomDomainLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
$currentOrg.customDomain = sanitizedDomain;
|
||||
isCustomDomainLoading = false;
|
||||
}
|
||||
|
||||
async function handleRemoveCustomDomain() {
|
||||
if (!$currentOrg.customDomain) return;
|
||||
|
||||
isCustomDomainLoading = true;
|
||||
|
||||
await orgApi.update($currentOrg.id, {
|
||||
customDomain: null,
|
||||
isCustomDomainVerified: false
|
||||
});
|
||||
|
||||
if (!orgApi.success) {
|
||||
snackbar.error(orgApi.errors.general || 'Failed to remove custom domain');
|
||||
isCustomDomainLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await sendDomainRequest('remove_domain', $currentOrg.customDomain);
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.message);
|
||||
}
|
||||
console.log('removed domain from vercel', data);
|
||||
} catch (error) {
|
||||
console.log('Error: removing domain from vercel', error);
|
||||
snackbar.error(error as string);
|
||||
isCustomDomainLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
$currentOrg.customDomain = '';
|
||||
$currentOrg.isCustomDomainVerified = false;
|
||||
isCustomDomainLoading = false;
|
||||
}
|
||||
|
||||
async function handleRefreshCustomDomain() {
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const response = await sendDomainRequest('verify_domain', $currentOrg.customDomain || '');
|
||||
const data = await response.json();
|
||||
|
||||
console.log('data', data);
|
||||
if (data.verified && !$currentOrg.isCustomDomainVerified) {
|
||||
await orgApi.update($currentOrg.id, {
|
||||
isCustomDomainVerified: true
|
||||
});
|
||||
|
||||
if (orgApi.success) {
|
||||
$currentOrg.isCustomDomainVerified = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Error: refreshing domain', error);
|
||||
snackbar.error(error as string);
|
||||
}
|
||||
|
||||
isRefreshing = false;
|
||||
}
|
||||
|
||||
function resetErrors(_siteName: string, _customDomain: string) {
|
||||
untrack(() => {
|
||||
if (errors.siteName) {
|
||||
errors.siteName = '';
|
||||
}
|
||||
if (errors.customDomain) {
|
||||
errors.customDomain = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getSubdomain() {
|
||||
const details = parse($currentOrg.customDomain || '');
|
||||
return details.subdomain;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
resetErrors(siteName, customDomain);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Field.Group class="max-w-md! w-full px-2">
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('components.settings.domains.add')}</Field.Legend>
|
||||
<Field.Description class="">{$t('settings.organization.organization_profile.custom_domain.body')}</Field.Description
|
||||
>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>URL</Field.Label>
|
||||
<DomainInput bind:value={siteName} placeholder="myschool" prefix="https://" suffix=".classroomio.com" />
|
||||
{#if errors.siteName}
|
||||
<Field.Error>{errors.siteName}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Button variant="outline" onclick={handleSaveSiteName} disabled={isLoading}>
|
||||
{$t('components.settings.domains.update')}
|
||||
</Button>
|
||||
<VisitOrgSiteButton />
|
||||
</Field.Field>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('components.settings.domains.custom')}</Field.Legend>
|
||||
<UpgradeBanner>{$t('upgrade.domain')}</UpgradeBanner>
|
||||
<Field.Group>
|
||||
{#if $currentOrg.customDomain}
|
||||
<Field.Field>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-md flex items-center gap-2 font-medium">
|
||||
{$currentOrg.customDomain}
|
||||
<Button variant="outline" size="icon-sm" onclick={() => goto(`https://${$currentOrg.customDomain}`)}>
|
||||
<ArrowUpRightIcon size={16} />
|
||||
</Button>
|
||||
</p>
|
||||
<div
|
||||
class="mt-1 h-2 w-2 rounded-full bg-{$currentOrg.isCustomDomainVerified ? 'green' : 'yellow'}-400"
|
||||
></div>
|
||||
</div>
|
||||
{#if $currentOrg.isCustomDomainVerified}
|
||||
<Badge variant="default" class="bg-green-500 px-3 text-xs text-white">Verified</Badge>
|
||||
{:else}
|
||||
<Badge variant="default" class="bg-yellow-500 px-3 text-xs text-white">Pending verification</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Description>{$t('components.settings.domains.dns_description')}</Field.Description>
|
||||
|
||||
<Field.Field>
|
||||
<div class="flex items-center gap-10 rounded-md border px-4 py-2">
|
||||
<div class="flex h-[72px] flex-col justify-evenly">
|
||||
<Field.Label class="font-light">{$t('components.settings.domains.dns_type')}</Field.Label>
|
||||
<p class="flex h-[40px] items-center">CNAME</p>
|
||||
</div>
|
||||
<div class="flex h-[72px] flex-col justify-evenly">
|
||||
<Field.Label class="font-light">{$t('components.settings.domains.dns_name')}</Field.Label>
|
||||
<p class="flex h-[40px] items-center">
|
||||
{getSubdomain()}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex h-[72px] flex-col justify-evenly">
|
||||
<Field.Label class="font-light">{$t('components.settings.domains.dns_value')}</Field.Label>
|
||||
<p class=" flex items-center gap-1">
|
||||
cname.vercel-dns.com
|
||||
<Button variant="ghost" size="icon-sm" onclick={() => copyToClipboard('cname.vercel-dns.com')}>
|
||||
<p>cname.vercel-dns.com</p>
|
||||
<Copy />
|
||||
</Button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field orientation="horizontal">
|
||||
<Button
|
||||
variant="outline"
|
||||
class="flex items-center gap-2 py-2"
|
||||
onclick={handleRefreshCustomDomain}
|
||||
loading={isRefreshing}
|
||||
>
|
||||
{#if !isRefreshing}
|
||||
<RotateCcwIcon size={16} />
|
||||
{/if}
|
||||
{$t('components.settings.domains.refresh')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
class="flex items-center gap-2 py-2"
|
||||
onclick={handleRemoveCustomDomain}
|
||||
loading={isCustomDomainLoading}
|
||||
>
|
||||
{#if !isCustomDomainLoading}
|
||||
<TrashIcon size={16} />
|
||||
{/if}
|
||||
{$t('components.settings.domains.remove')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
{:else}
|
||||
<Field.Field>
|
||||
<Field.Label class="font-bold">{$t('components.settings.domains.your_domain')}</Field.Label>
|
||||
<DomainInput bind:value={customDomain} placeholder="courses.yourwebsite" disabled={$isFreePlan} />
|
||||
{#if errors.customDomain}
|
||||
<Field.Error>{errors.customDomain}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field orientation="horizontal">
|
||||
<Button
|
||||
onclick={$isFreePlan ? () => {} : handleSaveCustomDomain}
|
||||
loading={isCustomDomainLoading}
|
||||
disabled={isLoading || !isDomainValid}
|
||||
>
|
||||
{$t('components.settings.domains.save')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
{/if}
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('components.settings.domains.custom_favicon')}</Field.Legend>
|
||||
<ComingSoon />
|
||||
<Field.Field>
|
||||
<UploadImage
|
||||
shape="rounded-md"
|
||||
bind:avatar={favicon}
|
||||
src={$currentOrg.favicon ?? '/logo-512.png'}
|
||||
widthHeight="w-16 h-16 lg:w-24 lg:h-24"
|
||||
flexDirection="flex-row"
|
||||
isDisabled={true}
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('components.settings.domains.custom_code')}</Field.Legend>
|
||||
<ComingSoon />
|
||||
<Field.Field>
|
||||
<Textarea
|
||||
bind:value={customCode}
|
||||
placeholder="e.g <link rel='stylesheet' href='https://example.com/style.css' />"
|
||||
class="w-4/5"
|
||||
rows={7}
|
||||
disabled={true}
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
@@ -0,0 +1,8 @@
|
||||
export { default as ProfilePage } from './profile.svelte';
|
||||
export { default as DomainsPage } from './domains.svelte';
|
||||
export { default as TeamsPage } from './teams.svelte';
|
||||
export { default as CustomizeLmsPage } from './customize-lms.svelte';
|
||||
export { default as OrgPage } from './org.svelte';
|
||||
export { default as LandingpagePage } from './landingpage.svelte';
|
||||
export { default as BillingPage } from './billing.svelte';
|
||||
export { default as IntegrationsPage } from './integrations.svelte';
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
// import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import ArrowUpRightIcon from '$lib/components/Icons/ArrowTopRight.svelte';
|
||||
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
|
||||
let chatId: number | null = $state(null);
|
||||
|
||||
async function addChatId() {
|
||||
if (!chatId || `${chatId}`.length < 5) return;
|
||||
|
||||
// TODO: Update profile with telegram chat id
|
||||
|
||||
// if (error) {
|
||||
// console.log(error);
|
||||
// snackbar.error('snackbar.course_settings.error.failed_integration');
|
||||
// } else {
|
||||
// $profile.telegramChatId = chatId;
|
||||
// chatId = null;
|
||||
// snackbar.success('snackbar.course_settings.success.successful_integration');
|
||||
// }
|
||||
}
|
||||
|
||||
async function deleteChatId() {
|
||||
// const { error } = await supabase.from('profile').update({ telegram_chat_id: null }).match({ id: $profile.id });
|
||||
// if (error) {
|
||||
// snackbar.error('snackbar.course_settings.error.failed_deletion');
|
||||
// console.log(error);
|
||||
// } else {
|
||||
// $profile.telegramChatId = null;
|
||||
// snackbar.success('snackbar.course_settings.success.successful_deletion');
|
||||
// }
|
||||
}
|
||||
</script>
|
||||
|
||||
<Field.Group class="max-w-md! w-full px-2">
|
||||
<Field.Set>
|
||||
<Field.Legend>
|
||||
<img src="/telegram-svg.svg" alt="" class="mr-2 w-10" />
|
||||
{$t('settings.integrations.heading')}
|
||||
</Field.Legend>
|
||||
{#if $profile.telegramChatId !== null}
|
||||
<Field.Field>
|
||||
<div class="flex items-center">
|
||||
<CircleCheckIcon size={16} filled />
|
||||
<Field.Description>{$t('settings.integrations.success_message')}</Field.Description>
|
||||
</div>
|
||||
<Button variant="destructive" onclick={deleteChatId}>
|
||||
{$t('settings.integrations.disconnect')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
{:else}
|
||||
<Field.Group>
|
||||
<Field.Description>{$t('settings.integrations.sub_heading')}</Field.Description>
|
||||
<Field.Description>{$t('settings.integrations.step_authenticate')}</Field.Description>
|
||||
<Button variant="outline" onclick={() => window.open('https://t.me/classroomio_bot', '_blank')}>
|
||||
<span class="mr-2">{$t('settings.integrations.open_bot_button')}</span>
|
||||
<ArrowUpRightIcon />
|
||||
</Button>
|
||||
<Field.Description>{$t('settings.integrations.step_chatId')}</Field.Description>
|
||||
<Field.Field>
|
||||
<Input bind:value={chatId} placeholder="360111" class="w-full lg:w-60" type="number" required />
|
||||
</Field.Field>
|
||||
<Button variant="default" onclick={addChatId}>{$t('settings.integrations.connect_button')}</Button>
|
||||
</Field.Group>
|
||||
{/if}
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
@@ -0,0 +1,762 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash';
|
||||
import * as RadioGroup from '@cio/ui/base/radio-group';
|
||||
|
||||
import { landingPageSettings } from '../utils/store';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { orgApi } from '$lib/features/org/api/org.svelte';
|
||||
import { handleOpenWidget } from '$lib/components/CourseLandingPage/store';
|
||||
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
|
||||
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import type { OrgLandingPageJson } from '$lib/utils/types/org';
|
||||
import type { AccountOrg } from '$lib/features/app/types';
|
||||
|
||||
let creatingNewQuestion = $state(false);
|
||||
let creatingNewCustomLink = $state(false);
|
||||
let hasUnsavedChanges = $state(false);
|
||||
let widgetKey = $state('');
|
||||
const banner = [
|
||||
{ value: 'video', label: `${$t('settings.landing_page.actions.banner_type.video')}` },
|
||||
{ value: 'image', label: `${$t('settings.landing_page.actions.banner_type.image')}` }
|
||||
];
|
||||
|
||||
let newQuestion = $state({
|
||||
title: '',
|
||||
content: ''
|
||||
});
|
||||
|
||||
let newCustomLink = $state({
|
||||
label: '',
|
||||
url: '',
|
||||
openInNewTab: false
|
||||
});
|
||||
|
||||
function widgetControl(key: string) {
|
||||
widgetKey = key;
|
||||
$handleOpenWidget.open = true;
|
||||
}
|
||||
|
||||
function createNewFaq() {
|
||||
newQuestion = {
|
||||
title: '',
|
||||
content: ''
|
||||
};
|
||||
creatingNewQuestion = true;
|
||||
}
|
||||
|
||||
function saveNewFAQ() {
|
||||
if (newQuestion.title !== '' && newQuestion.content !== '') {
|
||||
$landingPageSettings.faq.questions = [
|
||||
...$landingPageSettings.faq.questions,
|
||||
{
|
||||
id: new Date().getTime(),
|
||||
title: newQuestion.title,
|
||||
content: newQuestion.content
|
||||
}
|
||||
];
|
||||
creatingNewQuestion = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelNewFAQ() {
|
||||
creatingNewQuestion = false;
|
||||
}
|
||||
|
||||
function deleteFaq(id: number) {
|
||||
let faqs = $landingPageSettings.faq.questions;
|
||||
const filteredFaq = faqs.filter((faq) => faq.id !== id);
|
||||
$landingPageSettings.faq.questions = filteredFaq;
|
||||
}
|
||||
|
||||
function createNewCustomLink() {
|
||||
newCustomLink = {
|
||||
label: '',
|
||||
url: '',
|
||||
openInNewTab: false
|
||||
};
|
||||
creatingNewCustomLink = true;
|
||||
}
|
||||
|
||||
function saveNewCustomLink() {
|
||||
if (newCustomLink.label !== '' && newCustomLink.url !== '') {
|
||||
$landingPageSettings.customLinks.links = [
|
||||
...$landingPageSettings.customLinks.links,
|
||||
{
|
||||
id: new Date().getTime(),
|
||||
label: newCustomLink.label,
|
||||
url: newCustomLink.url,
|
||||
openInNewTab: newCustomLink.openInNewTab
|
||||
}
|
||||
];
|
||||
creatingNewCustomLink = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelNewCustomLink() {
|
||||
creatingNewCustomLink = false;
|
||||
}
|
||||
|
||||
function deleteCustomLink(id: number) {
|
||||
let customLinks = $landingPageSettings.customLinks.links;
|
||||
const filteredCustomLinks = customLinks.filter((link) => link.id !== id);
|
||||
$landingPageSettings.customLinks.links = filteredCustomLinks;
|
||||
}
|
||||
|
||||
const checkPrefix = (inputValue: string) => {
|
||||
if (!inputValue) return;
|
||||
|
||||
if (inputValue.trim() !== '') {
|
||||
if (!inputValue.startsWith('https://')) {
|
||||
inputValue = 'https://' + inputValue;
|
||||
}
|
||||
}
|
||||
return inputValue;
|
||||
};
|
||||
|
||||
export async function handleSave() {
|
||||
$landingPageSettings.footer.twitter = checkPrefix($landingPageSettings.footer.twitter) || '';
|
||||
$landingPageSettings.footer.linkedin = checkPrefix($landingPageSettings.footer.linkedin) || '';
|
||||
$landingPageSettings.footer.facebook = checkPrefix($landingPageSettings.footer.facebook) || '';
|
||||
|
||||
await orgApi.update($currentOrg.id, {
|
||||
landingpage: $landingPageSettings as AccountOrg['landingpage']
|
||||
});
|
||||
|
||||
if (orgApi.success) {
|
||||
$currentOrg.landingpage = $landingPageSettings as AccountOrg['landingpage'];
|
||||
hasUnsavedChanges = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Set default from store
|
||||
currentOrg.subscribe((cOrg) => {
|
||||
if (cOrg.landingpage && Object.keys(cOrg.landingpage).length) {
|
||||
const landingpage = { ...(cOrg.landingpage as unknown as OrgLandingPageJson) };
|
||||
|
||||
// Fallbacks for new keys we added into JSON, in case a user already saved the old JSON
|
||||
if (!landingpage?.header?.banner) {
|
||||
landingpage.header.banner = $landingPageSettings.header.banner;
|
||||
}
|
||||
|
||||
if (!landingpage?.header?.background) {
|
||||
landingpage.header.background = $landingPageSettings.header.background;
|
||||
}
|
||||
|
||||
if (!landingpage.customLinks) {
|
||||
landingpage.customLinks = $landingPageSettings.customLinks;
|
||||
}
|
||||
$landingPageSettings = {
|
||||
...landingpage
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<UnsavedChanges bind:hasUnsavedChanges />
|
||||
|
||||
<Field.Group class="max-w-md! w-full px-2">
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.landing_page.heading')}</Field.Legend>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={$landingPageSettings.header.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
|
||||
<Field.Label class="text-gray-600">
|
||||
{$landingPageSettings.header.show
|
||||
? $t('settings.landing_page.show_section')
|
||||
: $t('settings.landing_page.hide_section')}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.title')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
|
||||
bind:value={$landingPageSettings.header.title}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
class="w-full"
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.title_highlight')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.highlight')}
|
||||
class="w-full"
|
||||
bind:value={$landingPageSettings.header.titleHighlight}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.subtitle')}</Field.Label>
|
||||
<Textarea
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
|
||||
class="w-full"
|
||||
bind:value={$landingPageSettings.header.subtitle}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.actions.heading')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.label')}
|
||||
class="w-full"
|
||||
bind:value={$landingPageSettings.header.action.label}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.actions.link')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.link')}
|
||||
bind:value={$landingPageSettings.header.action.link}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch
|
||||
bind:checked={$landingPageSettings.header.action.redirect}
|
||||
onCheckedChange={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
<Field.Label class="text-gray-600">
|
||||
{$landingPageSettings.header.action.redirect
|
||||
? $t('settings.landing_page.actions.redirect')
|
||||
: $t('settings.landing_page.actions.no_redirect')}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.actions.banner_type.heading')}</Field.Label>
|
||||
<RadioGroup.Root
|
||||
bind:value={$landingPageSettings.header.banner.type}
|
||||
onValueChange={() => {
|
||||
if (hasUnsavedChanges) return;
|
||||
hasUnsavedChanges = true;
|
||||
}}
|
||||
>
|
||||
{#each banner as item}
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value={item.value} id={item.value} />
|
||||
<Label for={item.value} class="cursor-pointer">{item.label}</Label>
|
||||
</div>
|
||||
{/each}
|
||||
</RadioGroup.Root>
|
||||
</Field.Field>
|
||||
|
||||
{#if $landingPageSettings.header.banner.type === 'video'}
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.actions.link')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.video')}
|
||||
bind:value={$landingPageSettings.header.banner.video}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
{:else}
|
||||
<Field.Field>
|
||||
<Button variant="outline" onclick={() => widgetControl('header.banner.image')}>
|
||||
{$t('settings.landing_page.about.select_image')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
{/if}
|
||||
{#if $landingPageSettings.header.banner.image && $landingPageSettings.header.banner.type === 'image'}
|
||||
<img alt="bannerImage" src={$landingPageSettings.header.banner.image} class="mt-2 w-full rounded-md" />
|
||||
{/if}
|
||||
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch
|
||||
bind:checked={$landingPageSettings.header.banner.show}
|
||||
onCheckedChange={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
<Field.Label class="text-gray-600">
|
||||
{$landingPageSettings.header.banner.show
|
||||
? $t('settings.landing_page.actions.show_banner')
|
||||
: $t('settings.landing_page.actions.hide_banner')}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
{#if $handleOpenWidget.open && widgetKey === 'banner'}
|
||||
<UploadWidget
|
||||
bind:imageURL={$landingPageSettings.header.banner.image}
|
||||
onchange={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.background.title')}</Field.Label>
|
||||
<Button variant="outline" onclick={() => widgetControl('background')}>
|
||||
{$t('settings.landing_page.about.select_image')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
{#if $landingPageSettings.header.background?.image}
|
||||
<img alt="backgroundImage" src={$landingPageSettings.header.background.image} class="mt-2 w-full rounded-md" />
|
||||
{/if}
|
||||
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch
|
||||
checked={$landingPageSettings.header.background?.show}
|
||||
onCheckedChange={() => {
|
||||
hasUnsavedChanges = true;
|
||||
if (!$landingPageSettings.header.background) {
|
||||
$landingPageSettings.header.background = {
|
||||
image: '',
|
||||
show: true
|
||||
};
|
||||
} else {
|
||||
$landingPageSettings.header.background.show = !$landingPageSettings.header.background.show;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Field.Label class="text-gray-600">
|
||||
{$landingPageSettings.header.background?.show
|
||||
? $t('settings.landing_page.background.show_background')
|
||||
: $t('settings.landing_page.background.hide_background')}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
{#if $handleOpenWidget.open && widgetKey === 'background'}
|
||||
<UploadWidget
|
||||
imageURL={$landingPageSettings.header.background?.image}
|
||||
onchange={(imgUrl) => {
|
||||
hasUnsavedChanges = true;
|
||||
|
||||
if (!$landingPageSettings.header.background) {
|
||||
$landingPageSettings.header.background = {
|
||||
image: imgUrl,
|
||||
show: true
|
||||
};
|
||||
} else {
|
||||
$landingPageSettings.header.background.image = imgUrl;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.landing_page.about.heading')}</Field.Legend>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={$landingPageSettings.aboutUs.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
|
||||
<Field.Label class="text-gray-600">
|
||||
{$landingPageSettings.aboutUs.show
|
||||
? $t('settings.landing_page.show_section')
|
||||
: $t('settings.landing_page.hide_section')}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.about.title')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
|
||||
bind:value={$landingPageSettings.aboutUs.title}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.about.content')}</Field.Label>
|
||||
<Textarea
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.content')}
|
||||
bind:value={$landingPageSettings.aboutUs.content}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label class="font-bold">{$t('settings.landing_page.about.upload_an_image')}</Field.Label>
|
||||
<Button variant="outline" onclick={() => widgetControl('about-us')}>
|
||||
{$t('settings.landing_page.about.select_image')}
|
||||
</Button>
|
||||
{#if $landingPageSettings.aboutUs.imageUrl}
|
||||
<img alt="About us" src={$landingPageSettings.aboutUs.imageUrl} class="mt-2 w-full rounded-md" />
|
||||
{/if}
|
||||
{#if $handleOpenWidget.open && widgetKey === 'about-us'}
|
||||
<UploadWidget bind:imageURL={$landingPageSettings.aboutUs.imageUrl} />
|
||||
{/if}
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.landing_page.courses.heading')}</Field.Legend>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={$landingPageSettings.courses.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
|
||||
<Field.Label class="text-gray-600">
|
||||
{$landingPageSettings.courses.show
|
||||
? $t('settings.landing_page.courses.show_section')
|
||||
: $t('settings.landing_page.courses.hide_section')}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.courses.title')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
|
||||
bind:value={$landingPageSettings.courses.title}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.courses.title_highlight')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.highlight')}
|
||||
bind:value={$landingPageSettings.courses.titleHighlight}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.courses.subtitle')}</Field.Label>
|
||||
<Textarea
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
|
||||
bind:value={$landingPageSettings.courses.subtitle}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.landing_page.faq.heading')}</Field.Legend>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={$landingPageSettings.faq.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
|
||||
<Field.Label class="text-gray-600">
|
||||
{$landingPageSettings.faq.show
|
||||
? $t('settings.landing_page.faq.show_section')
|
||||
: $t('settings.landing_page.faq.hide_section')}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.faq.title')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
|
||||
bind:value={$landingPageSettings.faq.title}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
|
||||
{#each $landingPageSettings.faq.questions as item (item.id)}
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.faq.question')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_question_here')}
|
||||
bind:value={item.title}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.faq.answer')}</Field.Label>
|
||||
<Textarea
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_answer_here')}
|
||||
bind:value={item.content}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Button variant="ghost" size="icon" onclick={() => deleteFaq(item.id)}>
|
||||
<TrashIcon size={16} />
|
||||
</Button>
|
||||
{/each}
|
||||
|
||||
{#if creatingNewQuestion}
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.faq.question')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_question_here')}
|
||||
bind:value={newQuestion.title}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.faq.answer')}</Field.Label>
|
||||
<Textarea
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_answer_here')}
|
||||
bind:value={newQuestion.content}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Button variant="outline" onclick={saveNewFAQ}>
|
||||
{$t('settings.landing_page.faq.save')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={cancelNewFAQ}>
|
||||
{$t('settings.landing_page.faq.cancel')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
{:else}
|
||||
<Field.Field orientation="horizontal">
|
||||
<Button variant="outline" onclick={createNewFaq}>
|
||||
{$t('settings.landing_page.faq.button')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
{/if}
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.landing_page.contact_us.heading')}</Field.Legend>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={$landingPageSettings.contact.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
|
||||
<Field.Label class="text-gray-600">
|
||||
{$landingPageSettings.contact.show
|
||||
? $t('settings.landing_page.contact_us.show_section')
|
||||
: $t('settings.landing_page.contact_us.hide_section')}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.contact_us.title')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
|
||||
bind:value={$landingPageSettings.contact.title}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.contact_us.title_highlight')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.highlight')}
|
||||
bind:value={$landingPageSettings.contact.titleHighlight}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.contact_us.subtitle')}</Field.Label>
|
||||
<Textarea
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
|
||||
bind:value={$landingPageSettings.contact.subtitle}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.contact_us.address')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.address')}
|
||||
bind:value={$landingPageSettings.contact.address}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.contact_us.phone_number')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.number')}
|
||||
bind:value={$landingPageSettings.contact.phone}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.contact_us.email')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.email')}
|
||||
bind:value={$landingPageSettings.contact.email}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.landing_page.mailing_list.heading')}</Field.Legend>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={$landingPageSettings.mailinglist.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
|
||||
<Field.Label class="text-gray-600">
|
||||
{$landingPageSettings.mailinglist.show
|
||||
? $t('settings.landing_page.mailing_list.show_section')
|
||||
: $t('settings.landing_page.mailing_list.hide_section')}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.mailing_list.title')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
|
||||
bind:value={$landingPageSettings.mailinglist.title}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.mailing_list.subtitle')}</Field.Label>
|
||||
<Textarea
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
|
||||
bind:value={$landingPageSettings.mailinglist.subtitle}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.mailing_list.button_label')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.label')}
|
||||
bind:value={$landingPageSettings.mailinglist.buttonLabel}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.landing_page.custom_links.heading')}</Field.Legend>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={$landingPageSettings.customLinks.show} />
|
||||
<Field.Label class="text-gray-600">
|
||||
{$landingPageSettings.customLinks.show
|
||||
? $t('settings.landing_page.custom_links.show_links')
|
||||
: $t('settings.landing_page.custom_links.hide_links')}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Description>{$t('settings.landing_page.custom_links.description')}</Field.Description>
|
||||
|
||||
<Field.Group>
|
||||
{#each $landingPageSettings.customLinks.links as link (link.id)}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.custom_links.label')}</Field.Label>
|
||||
<Input placeholder={$t('settings.landing_page.custom_links.label_placeholder')} bind:value={link.label} />
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.custom_links.url')}</Field.Label>
|
||||
<Input placeholder={$t('settings.landing_page.custom_links.url_placeholder')} bind:value={link.url} />
|
||||
</Field.Field>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Field.Field orientation="horizontal">
|
||||
<Checkbox id="new-tab-{link.id}" bind:checked={link.openInNewTab} />
|
||||
<Field.Label class="text-sm">{$t('settings.landing_page.custom_links.new_tab')}</Field.Label>
|
||||
</Field.Field>
|
||||
<Button variant="ghost" size="icon" onclick={() => deleteCustomLink(link.id)}>
|
||||
<TrashIcon size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Field.Separator />
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if creatingNewCustomLink}
|
||||
<div class="mb-4 rounded-lg border border-gray-200 bg-gray-50 p-4">
|
||||
<div class="mb-3 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.custom_links.label')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('settings.landing_page.custom_links.label_placeholder')}
|
||||
bind:value={newCustomLink.label}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.custom_links.url')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('settings.landing_page.custom_links.url_placeholder')}
|
||||
bind:value={newCustomLink.url}
|
||||
/>
|
||||
</Field.Field>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Field.Field orientation="horizontal">
|
||||
<input type="checkbox" bind:checked={newCustomLink.openInNewTab} class="mr-2" />
|
||||
<Field.Label class="text-sm">{$t('settings.landing_page.custom_links.new_tab')}</Field.Label>
|
||||
</Field.Field>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" onclick={saveNewCustomLink} class="px-3 py-1 text-sm">
|
||||
{$t('settings.landing_page.custom_links.save')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={cancelNewCustomLink} class="px-3 py-1 text-sm">
|
||||
{$t('settings.landing_page.custom_links.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Field.Field orientation="horizontal">
|
||||
<Button variant="outline" onclick={createNewCustomLink}>
|
||||
{$t('settings.landing_page.custom_links.add')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
{/if}
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.landing_page.footer.heading')}</Field.Legend>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={$landingPageSettings.footer.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
|
||||
<Field.Label class="text-gray-600">
|
||||
{$landingPageSettings.footer.show
|
||||
? $t('settings.landing_page.footer.show_section')
|
||||
: $t('settings.landing_page.footer.hide_section')}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.footer.facebook')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.facebook')}
|
||||
bind:value={$landingPageSettings.footer.facebook}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<!-- <TextField
|
||||
label="Instagram"
|
||||
placeholder="Write your Instagram link here"
|
||||
className="mb-5"
|
||||
bind:value={$landingPageSettings.footer.instagram}
|
||||
/> -->
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.footer.twitter')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.twitter')}
|
||||
bind:value={$landingPageSettings.footer.twitter}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.landing_page.footer.linkedin')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.lessons.exercises.all_exercises.linkedin')}
|
||||
bind:value={$landingPageSettings.footer.linkedin}
|
||||
oninput={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
|
||||
<style>
|
||||
:global(.dark) {
|
||||
--cp-text-color: #fff;
|
||||
--cp-border-color: white;
|
||||
--cp-text-color: white;
|
||||
--cp-input-color: #555;
|
||||
--cp-button-hover-color: #777;
|
||||
}
|
||||
|
||||
:global(.dark .alpha) {
|
||||
background: #333 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,256 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import ZapIcon from '@lucide/svelte/icons/zap';
|
||||
import debounce from 'lodash/debounce';
|
||||
import ColorPicker from 'svelte-awesome-color-picker';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
|
||||
import { injectCustomTheme, setCustomTheme, setTheme } from '$lib/utils/functions/theme';
|
||||
import { orgApi } from '$lib/features/org/api/org.svelte';
|
||||
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import UploadImage from '$lib/components/UploadImage/index.svelte';
|
||||
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
|
||||
let avatar = $state<string | File | undefined>();
|
||||
let hasUnsavedChanges = $state(false);
|
||||
let hex = $state('');
|
||||
|
||||
$effect(() => {
|
||||
const theme = $currentOrg.theme;
|
||||
|
||||
// if not a custom theme, don't update hex
|
||||
if (!theme || theme.includes('theme-')) return;
|
||||
|
||||
// only update hex with value of `theme` when theme changes not when HEX changes
|
||||
untrack(() => {
|
||||
hex = theme;
|
||||
});
|
||||
});
|
||||
|
||||
const themes = {
|
||||
rose: 'theme-rose',
|
||||
green: 'theme-green',
|
||||
orange: 'theme-orange',
|
||||
violet: 'theme-violet',
|
||||
default: ''
|
||||
};
|
||||
|
||||
const saveTheme = debounce(async (theme: string) => {
|
||||
await orgApi.update($currentOrg.id, {
|
||||
theme
|
||||
});
|
||||
}, 700);
|
||||
|
||||
function handleChangeTheme(t = '') {
|
||||
return async () => {
|
||||
document.body.className = document.body.className
|
||||
.split(' ')
|
||||
.filter((c) => !c.includes('theme'))
|
||||
.join(' ')
|
||||
.concat(t ? ' ' : '', t);
|
||||
$currentOrg.theme = t;
|
||||
|
||||
hex = '';
|
||||
|
||||
setTheme(t);
|
||||
|
||||
saveTheme(t);
|
||||
};
|
||||
}
|
||||
|
||||
async function handleCustomTheme() {
|
||||
if (!hex) return;
|
||||
|
||||
injectCustomTheme(hex);
|
||||
setCustomTheme('theme-custom');
|
||||
|
||||
$currentOrg.theme = hex;
|
||||
|
||||
saveTheme(hex);
|
||||
}
|
||||
|
||||
export async function handleUpdate() {
|
||||
await orgApi.update($currentOrg.id, {
|
||||
name: $currentOrg.name,
|
||||
avatar
|
||||
});
|
||||
|
||||
if (orgApi.success) {
|
||||
hasUnsavedChanges = false;
|
||||
avatar = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function gotoSettings(pathname: string) {
|
||||
goto(`${$currentOrgPath}/settings${pathname}`);
|
||||
}
|
||||
|
||||
let isCustomTheme = $derived(hex && !hex.includes('theme-'));
|
||||
</script>
|
||||
|
||||
<UnsavedChanges bind:hasUnsavedChanges />
|
||||
|
||||
<Field.Group class="max-w-md! w-full px-2">
|
||||
<Field.Set>
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.organization.organization_profile.organization_name')}</Field.Label>
|
||||
<Input bind:value={$currentOrg.name} oninput={() => (hasUnsavedChanges = true)} class="w-full lg:w-60" />
|
||||
{#if orgApi.errors.name}
|
||||
<Field.Error>{$t(orgApi.errors.name)}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<UploadImage
|
||||
bind:avatar
|
||||
src={$currentOrg.avatarUrl}
|
||||
shape="rounded-md"
|
||||
widthHeight="w-24 h-24"
|
||||
change={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.organization.organization_profile.theme.heading')}</Field.Legend>
|
||||
<Field.Description>{$t('settings.organization.organization_profile.theme.sub_heading')}</Field.Description>
|
||||
<Field.Field>
|
||||
<div class="flex items-center gap-5">
|
||||
<button
|
||||
class="rounded-full border-2 {$currentOrg.theme === themes.default &&
|
||||
'border-[#1d4ee2]'} flex h-fit items-center justify-center"
|
||||
onclick={handleChangeTheme(themes.default)}
|
||||
aria-label="Default blue theme"
|
||||
>
|
||||
<div class="m-1 h-6 w-6 rounded-full bg-[#1d4ee2] md:h-6 md:w-6"></div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="rounded-full border-2 {$currentOrg.theme === themes.rose &&
|
||||
'border-[#be1241]'} flex h-fit items-center justify-center"
|
||||
onclick={handleChangeTheme(themes.rose)}
|
||||
aria-label="Rose theme"
|
||||
>
|
||||
<div class="m-1 h-6 w-6 rounded-full bg-[#be1241] md:h-6 md:w-6"></div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="rounded-full border-2 {$currentOrg.theme === themes.green &&
|
||||
'border-[#0c891b]'} flex h-fit items-center justify-center"
|
||||
onclick={handleChangeTheme(themes.green)}
|
||||
aria-label="Green theme"
|
||||
>
|
||||
<div class="m-1 h-6 w-6 rounded-full bg-[#0c891b] md:h-6 md:w-6"></div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="rounded-full border-2 {$currentOrg.theme === themes.orange &&
|
||||
'border-[#cc4902]'} flex h-fit items-center justify-center"
|
||||
onclick={handleChangeTheme(themes.orange)}
|
||||
aria-label="Orange theme"
|
||||
>
|
||||
<div class="m-1 h-6 w-6 rounded-full bg-[#cc4902] md:h-6 md:w-6"></div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="rounded-full border-2 {$currentOrg.theme === themes.violet &&
|
||||
'border-[#cf00ce]'} flex h-fit items-center justify-center"
|
||||
onclick={handleChangeTheme(themes.violet)}
|
||||
aria-label="Violet theme"
|
||||
>
|
||||
<div class="m-1 h-6 w-6 rounded-full bg-[#cf00ce] md:h-6 md:w-6"></div>
|
||||
</button>
|
||||
|
||||
<div
|
||||
class="h-auto w-fit border-2 {isCustomTheme
|
||||
? 'border-primary-700'
|
||||
: 'dark:border-neutral-700'} group relative rounded-full"
|
||||
>
|
||||
<!-- plus icon positioned over the color picker -->
|
||||
<div
|
||||
class="pointer-events-none absolute inset-0 flex items-center justify-center transition-opacity duration-200"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 text-{isCustomTheme ? 'white' : 'black'} z-10 opacity-100 dark:text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<ColorPicker position="responsive" label="" bind:hex on:input={handleCustomTheme} />
|
||||
</div>
|
||||
</div>
|
||||
</Field.Field>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.organization.organization_profile.customize_lms.heading')}</Field.Legend>
|
||||
<Field.Description>{$t('settings.organization.organization_profile.customize_lms.sub_heading')}</Field.Description>
|
||||
<Field.Description>{$t('settings.organization.organization_profile.customize_lms.body')}</Field.Description>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Button variant="outline" onclick={() => gotoSettings('/customize-lms')}>
|
||||
{$t('settings.organization.organization_profile.customize_lms.button')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.organization.organization_profile.custom_domain.heading')}</Field.Legend>
|
||||
<Field.Description>{$t('settings.organization.organization_profile.custom_domain.sub_heading')}</Field.Description>
|
||||
<Field.Description>{$t('settings.organization.organization_profile.custom_domain.body')}</Field.Description>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Button variant="outline" onclick={() => gotoSettings('/domains')}>
|
||||
{#if $isFreePlan}
|
||||
<ZapIcon size={16} class="filled" />
|
||||
{/if}
|
||||
{$t('settings.organization.organization_profile.custom_domain.button')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.organization.organization_profile.team.heading')}</Field.Legend>
|
||||
<Field.Description>{$t('settings.organization.organization_profile.team.sub_heading')}</Field.Description>
|
||||
<Field.Description>{$t('settings.organization.organization_profile.team.body')}</Field.Description>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Button variant="outline" onclick={() => gotoSettings('/teams')}>
|
||||
{#if $isFreePlan}
|
||||
<ZapIcon size={16} class="filled" />
|
||||
{/if}
|
||||
{$t('settings.organization.organization_profile.team.button')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
|
||||
<style>
|
||||
:global(.dark) {
|
||||
--cp-text-color: #fff;
|
||||
--cp-border-color: white;
|
||||
--cp-text-color: white;
|
||||
--cp-input-color: #555;
|
||||
--cp-button-hover-color: #777;
|
||||
}
|
||||
|
||||
:global(.dark .alpha) {
|
||||
background: #333 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,168 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { CircleCheckBig } from '@lucide/svelte';
|
||||
import { profileApi } from '$lib/features/auth/api/profile.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import LanguagePicker from '../components/language-picker.svelte';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import UploadImage from '$lib/components/UploadImage/index.svelte';
|
||||
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
|
||||
let avatar = $state<string | File | undefined>();
|
||||
let hasLangChanged = $state(false);
|
||||
let locale = $derived<TLocale | undefined>($profile.locale || undefined);
|
||||
let hasUnsavedChanges = $state(false);
|
||||
let email = $derived($profile.email || '');
|
||||
let isChangingEmail = $state(false);
|
||||
let emailChangeInitiated = $state(false);
|
||||
|
||||
export async function handleUpdate() {
|
||||
await profileApi.submit(
|
||||
{
|
||||
fullname: $profile.fullname,
|
||||
username: $profile.username,
|
||||
locale,
|
||||
avatar
|
||||
},
|
||||
$profile,
|
||||
hasLangChanged,
|
||||
locale
|
||||
);
|
||||
|
||||
if (profileApi.success) {
|
||||
hasUnsavedChanges = false;
|
||||
avatar = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEmailChange() {
|
||||
if (email === $profile.email) {
|
||||
return;
|
||||
}
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('trigger', 'app');
|
||||
|
||||
await profileApi.changeEmail({
|
||||
newEmail: email,
|
||||
callbackURL: url.toString()
|
||||
});
|
||||
|
||||
if (profileApi.success) {
|
||||
isChangingEmail = false;
|
||||
emailChangeInitiated = true;
|
||||
// Reset email field to current email after successful change request
|
||||
email = $profile.email || '';
|
||||
}
|
||||
}
|
||||
|
||||
const isVerificationSent = $derived(page.url.searchParams.get('trigger') === 'app');
|
||||
</script>
|
||||
|
||||
<UnsavedChanges bind:hasUnsavedChanges />
|
||||
|
||||
<Field.Group class="max-w-md! w-full px-2">
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.profile.profile_picture.heading')}</Field.Legend>
|
||||
<Field.Field>
|
||||
<UploadImage
|
||||
bind:avatar
|
||||
src={$profile.avatarUrl}
|
||||
widthHeight="w-16 h-16 lg:w-24 lg:h-24"
|
||||
isDisabled={profileApi.isLoading}
|
||||
change={() => (hasUnsavedChanges = true)}
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('settings.profile.personal_information.heading')}</Field.Legend>
|
||||
|
||||
{#if isVerificationSent}
|
||||
<div class="flex items-center gap-2 rounded-md border border-gray-200 p-2 text-green-500">
|
||||
<CircleCheckBig class="size-8" />
|
||||
<p class="text-sm">{$t('settings.profile.personal_information.email_change_verification_sent')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.profile.personal_information.full_name')}</Field.Label>
|
||||
<Input bind:value={$profile.fullname} class="" oninput={() => (hasUnsavedChanges = true)} />
|
||||
{#if profileApi.errors.fullname}
|
||||
<Field.Error>{$t(profileApi.errors.fullname)}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.profile.personal_information.username')}</Field.Label>
|
||||
<Input bind:value={$profile.username} oninput={() => (hasUnsavedChanges = true)} />
|
||||
{#if profileApi.errors.username}
|
||||
<Field.Error>{$t(profileApi.errors.username)}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('settings.profile.personal_information.email')}</Field.Label>
|
||||
<Input
|
||||
bind:value={email}
|
||||
class="w-full"
|
||||
type="email"
|
||||
disabled={profileApi.isLoading}
|
||||
oninput={() => {
|
||||
if (email !== $profile.email) {
|
||||
isChangingEmail = true;
|
||||
emailChangeInitiated = false;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{#if profileApi.errors.newEmail}
|
||||
<Field.Error>{$t(profileApi.errors.newEmail)}</Field.Error>
|
||||
{/if}
|
||||
{#if isChangingEmail && email !== $profile.email}
|
||||
<div class="mt-2 flex gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
class="text-sm"
|
||||
loading={profileApi.isLoading}
|
||||
disabled={profileApi.isLoading}
|
||||
onclick={handleEmailChange}
|
||||
>
|
||||
{$t('settings.profile.personal_information.confirm')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-primary-700 text-sm"
|
||||
disabled={profileApi.isLoading}
|
||||
onclick={() => {
|
||||
email = $profile.email || '';
|
||||
isChangingEmail = false;
|
||||
emailChangeInitiated = false;
|
||||
profileApi.errors.newEmail = '';
|
||||
}}
|
||||
>
|
||||
{$t('settings.profile.personal_information.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if emailChangeInitiated}
|
||||
<div class="mt-2 flex items-center gap-2 rounded-md border border-gray-200 p-2 text-amber-500">
|
||||
<CircleCheckBig class="size-8" />
|
||||
<p class="text-sm">{$t('settings.profile.personal_information.email_change_verification_note')}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<LanguagePicker
|
||||
change={() => (hasUnsavedChanges = true)}
|
||||
bind:hasLangChanged
|
||||
bind:value={locale}
|
||||
className=""
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script lang="ts">
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { isFreePlan, currentOrg } from '$lib/utils/store/org';
|
||||
import { orgApi } from '$lib/features/org/api/org.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import type { OrgTeamMember } from '$lib/utils/types/org';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { ROLE_LABEL } from '$lib/utils/constants/roles';
|
||||
import { validateEmailInString } from '$lib/utils/functions/validator';
|
||||
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { ComingSoon, UpgradeBanner } from '$lib/features/ui';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
|
||||
let emailsStr = $state('');
|
||||
let errorMessage = $state('');
|
||||
let role = $state(ROLE.TUTOR.toString());
|
||||
let isRemoving: number | null = $state(null);
|
||||
|
||||
async function onSendInvite() {
|
||||
const { hasError, error: _error, emails } = validateEmailInString(emailsStr);
|
||||
|
||||
if (hasError) {
|
||||
errorMessage = _error;
|
||||
return;
|
||||
}
|
||||
|
||||
errorMessage = '';
|
||||
await orgApi.inviteTeamMembers($currentOrg.id, emails, parseInt(role));
|
||||
|
||||
if (orgApi.success) {
|
||||
snackbar.success('snackbar.team_members.invite_sent');
|
||||
emailsStr = '';
|
||||
} else {
|
||||
// Errors are handled by orgApi, but check for field-specific errors
|
||||
if (orgApi.errors.emails) {
|
||||
errorMessage = orgApi.errors.emails;
|
||||
} else if (orgApi.errors.general) {
|
||||
snackbar.error(orgApi.errors.general);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onRemove(id: number) {
|
||||
isRemoving = id;
|
||||
await orgApi.removeTeamMember($currentOrg.id, id);
|
||||
|
||||
if (orgApi.success) {
|
||||
snackbar.success('snackbar.team_members.remove_success');
|
||||
} else {
|
||||
snackbar.error('snackbar.team_members.remove_fail');
|
||||
}
|
||||
|
||||
isRemoving = null;
|
||||
}
|
||||
|
||||
const isTeamMemberAdmin = (members: OrgTeamMember[], profileId: string | undefined) => {
|
||||
return members.some((member) => member.profileId === profileId && member.isAdmin);
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
orgApi.getOrgTeam($currentOrg.id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<UpgradeBanner>{$t('upgrade.team')}</UpgradeBanner>
|
||||
|
||||
<Field.Group class="max-w-md! w-full px-2">
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.navItem.people.teams.add')}</Field.Legend>
|
||||
<Field.Description class="mb-5">{$t('course.navItem.people.teams.add_team')}</Field.Description>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('course.navItem.people.teams.invite')}</Field.Label>
|
||||
<Input
|
||||
placeholder={$t('course.navItem.people.teams.placeholder')}
|
||||
bind:value={emailsStr}
|
||||
class="w-full"
|
||||
disabled={$isFreePlan}
|
||||
/>
|
||||
{#if errorMessage}
|
||||
<Field.Error>{errorMessage}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('course.navItem.people.teams.role')}</Field.Label>
|
||||
<Select.Root type="single" bind:value={role} disabled={$isFreePlan}>
|
||||
<Select.Trigger id="invite-role" class="w-40">
|
||||
<p>{role ? $t(ROLE_LABEL[role]) : $t('course.navItem.people.teams.select_role')}</p>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Item value={ROLE.ADMIN.toString()}>{$t(ROLE_LABEL[ROLE.ADMIN])}</Select.Item>
|
||||
<Select.Item value={ROLE.TUTOR.toString()}>{$t(ROLE_LABEL[ROLE.TUTOR])}</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Field.Field>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
onclick={onSendInvite}
|
||||
loading={orgApi.isLoading}
|
||||
disabled={orgApi.isLoading || $isFreePlan}
|
||||
>
|
||||
{$t('course.navItem.people.teams.send_invite')}
|
||||
</Button>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.navItem.people.teams.members')}</Field.Legend>
|
||||
<Field.Group>
|
||||
{#if orgApi.isLoading && orgApi.teamMembers.length === 0}
|
||||
<Spinner class="size-10! text-blue-700!" />
|
||||
{:else if orgApi.teamMembers.length > 0}
|
||||
{#each orgApi.teamMembers as teamMember}
|
||||
<Field.Field>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<p class="mr-3 text-sm text-gray-500 dark:text-white">
|
||||
{teamMember.email}
|
||||
</p>
|
||||
<Badge variant="secondary" class="mr-3 text-xs">{$t(teamMember.role)}</Badge>
|
||||
{#if !teamMember.verified}
|
||||
<Badge variant="outline" class="bg-yellow-200 text-xs text-yellow-700">
|
||||
{$t('course.navItem.people.teams.invite_sent')}
|
||||
</Badge>
|
||||
{:else if teamMember.profileId === $profile.id}
|
||||
<ComingSoon label={$t('course.navItem.people.teams.you')} />
|
||||
{/if}
|
||||
</div>
|
||||
{#if teamMember.profileId !== $profile.id && isTeamMemberAdmin(orgApi.teamMembers, $profile.id)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onclick={() => onRemove(teamMember.id)}
|
||||
loading={isRemoving === teamMember.id}
|
||||
disabled={isRemoving === teamMember.id}
|
||||
class="text-red-500 hover:text-red-700"
|
||||
>
|
||||
{$t('course.navItem.people.teams.remove')}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</Field.Field>
|
||||
{/each}
|
||||
{/if}
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { OrgLandingPageJson } from '$lib/utils/types/org';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const landingPageSettings: Writable<OrgLandingPageJson> = writable({
|
||||
header: {
|
||||
title: 'Web Development Courses for',
|
||||
titleHighlight: 'Everyone',
|
||||
subtitle: 'Practical project-based courses that are easy to understand and straight to the point',
|
||||
action: {
|
||||
label: 'Start learning Today',
|
||||
link: '#contact',
|
||||
redirect: false
|
||||
},
|
||||
banner: {
|
||||
video: 'https://www.youtube.com/watch?v=qqAYW7uxErI',
|
||||
image: '',
|
||||
type: 'video',
|
||||
show: true
|
||||
},
|
||||
background: {
|
||||
image: '',
|
||||
show: true
|
||||
},
|
||||
show: true
|
||||
},
|
||||
aboutUs: {
|
||||
title: 'Our Story',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.',
|
||||
imageUrl: 'https://pgrest.classroomio.com/storage/v1/object/public/avatars/landingpage/riverside.jpeg',
|
||||
show: true
|
||||
},
|
||||
courses: {
|
||||
title: 'Explore our',
|
||||
titleHighlight: 'Courses',
|
||||
subtitle: 'Find courses you will love from best teachers all over the world🌎.',
|
||||
show: true
|
||||
},
|
||||
faq: {
|
||||
title: 'Frequently Asked Questions.',
|
||||
questions: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Lorem ipsum dolor sit, amet con',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Lorem ipsum dolor sit, amet con',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Lorem ipsum dolor sit, amet con',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
|
||||
}
|
||||
],
|
||||
show: true
|
||||
},
|
||||
contact: {
|
||||
title: 'Have you got any',
|
||||
titleHighlight: 'Question?',
|
||||
subtitle: 'Reach out to us with any question you might have.',
|
||||
address: 'Ashwoodfield House Farm, Kidderminster Rd, Stourbridge, Kingswinford DY6 0AA',
|
||||
phone: '+4407339904995',
|
||||
email: 'help@lms.com',
|
||||
show: true
|
||||
},
|
||||
mailinglist: {
|
||||
title: 'Join our mailing list',
|
||||
subtitle:
|
||||
'We are constantly releasing new courses and sharing them with our email list. Subscribe to get notified once we release a new course',
|
||||
buttonLabel: 'Subscribe',
|
||||
show: true
|
||||
},
|
||||
customLinks: {
|
||||
show: true,
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
label: 'About Us',
|
||||
url: '#aboutus',
|
||||
openInNewTab: false
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: 'Courses',
|
||||
url: '#courses',
|
||||
openInNewTab: false
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: 'FAQ',
|
||||
url: '#faq',
|
||||
openInNewTab: false
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
label: 'Contact Us',
|
||||
url: '#contact',
|
||||
openInNewTab: false
|
||||
}
|
||||
]
|
||||
},
|
||||
footer: {
|
||||
facebook: '#',
|
||||
instagram: '#',
|
||||
twitter: '#',
|
||||
linkedin: '#',
|
||||
show: true
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
|
||||
|
||||
export interface SetupItem {
|
||||
id: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
is_completed: boolean;
|
||||
button_label: string;
|
||||
}
|
||||
|
||||
export interface SetupProgressData {
|
||||
setup: SetupItem[];
|
||||
courses: Array<{ id: string; [key: string]: unknown }>;
|
||||
lessons: Array<{ id: string; [key: string]: unknown }>;
|
||||
orgSiteName: string;
|
||||
}
|
||||
|
||||
type TCreateSetupData = {
|
||||
orgHasAvatarUrl?: boolean;
|
||||
isCourseCreated?: boolean;
|
||||
isLessonCreated?: boolean;
|
||||
isExerciseCreated?: boolean;
|
||||
isCoursePublished?: boolean;
|
||||
};
|
||||
|
||||
// Base setup data structure - created once
|
||||
const createSetupData = ({
|
||||
orgHasAvatarUrl = false,
|
||||
isCourseCreated = false,
|
||||
isLessonCreated = false,
|
||||
isExerciseCreated = false,
|
||||
isCoursePublished = false
|
||||
}: TCreateSetupData = {}): SetupItem[] => [
|
||||
{
|
||||
id: 'profile',
|
||||
title: 'setup.personal_profile.title',
|
||||
desc: 'setup.personal_profile.desc',
|
||||
is_completed: false,
|
||||
button_label: 'setup.personal_profile.button_label'
|
||||
},
|
||||
{
|
||||
id: 'organization',
|
||||
title: 'setup.organization_profile.title',
|
||||
desc: 'setup.organization_profile.desc',
|
||||
is_completed: orgHasAvatarUrl,
|
||||
button_label: 'setup.organization_profile.button_label'
|
||||
},
|
||||
{
|
||||
id: 'course',
|
||||
title: 'setup.course.title',
|
||||
desc: 'setup.course.desc',
|
||||
is_completed: isCourseCreated,
|
||||
button_label: 'setup.course.button_label'
|
||||
},
|
||||
{
|
||||
id: 'lesson',
|
||||
title: 'setup.lesson.title',
|
||||
desc: 'setup.lesson.desc',
|
||||
is_completed: isLessonCreated,
|
||||
button_label: 'setup.lesson.button_label'
|
||||
},
|
||||
{
|
||||
id: 'exercise',
|
||||
title: 'setup.exercise.title',
|
||||
desc: 'setup.exercise.desc',
|
||||
is_completed: isExerciseCreated,
|
||||
button_label: 'setup.exercise.button_label'
|
||||
},
|
||||
{
|
||||
id: 'publish',
|
||||
title: 'setup.publish_course.title',
|
||||
desc: 'setup.publish_course.desc',
|
||||
is_completed: isCoursePublished,
|
||||
button_label: 'setup.publish_course.button_label'
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* API class for setup progress operations
|
||||
*/
|
||||
class SetupProgressApi extends BaseApiWithErrors {
|
||||
progress = $state<SetupProgressData>({
|
||||
orgSiteName: '',
|
||||
setup: [],
|
||||
courses: [],
|
||||
lessons: []
|
||||
});
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.isLoading = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed setup list with profile completion check
|
||||
* This computation is shared across components to avoid duplication
|
||||
* Note: Profile completion check should be done in components using $profile
|
||||
* This returns the base setup list, and components can derive the final list
|
||||
*/
|
||||
get setupList(): SetupItem[] {
|
||||
return this.progress.setup || [];
|
||||
}
|
||||
/**
|
||||
* Fetches setup progress data for an organization
|
||||
* @param siteName Organization site name
|
||||
*/
|
||||
async fetchSetupProgress(siteName: string) {
|
||||
await this.execute<typeof classroomio.organization.setup.$get>({
|
||||
requestFn: () =>
|
||||
classroomio.organization.setup.$get({
|
||||
query: { siteName }
|
||||
}),
|
||||
logContext: 'fetching setup progress',
|
||||
onSuccess: (response) => {
|
||||
if (!response.data) {
|
||||
// Set default empty state if no data
|
||||
const setup = createSetupData();
|
||||
this.progress = {
|
||||
orgSiteName: siteName,
|
||||
setup,
|
||||
courses: [],
|
||||
lessons: []
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
isCourseCreated,
|
||||
isCoursePublished,
|
||||
orgHasAvatarUrl,
|
||||
courseData,
|
||||
lessonData,
|
||||
isLessonCreated,
|
||||
isExerciseCreated
|
||||
} = response.data;
|
||||
|
||||
const setup = createSetupData({
|
||||
orgHasAvatarUrl,
|
||||
isCourseCreated,
|
||||
isLessonCreated,
|
||||
isExerciseCreated,
|
||||
isCoursePublished
|
||||
});
|
||||
|
||||
this.progress = {
|
||||
orgSiteName: siteName,
|
||||
setup,
|
||||
courses: courseData || [],
|
||||
lessons: lessonData || []
|
||||
};
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error fetching setup progress:', error);
|
||||
// Set default empty state on error
|
||||
const setup = createSetupData();
|
||||
this.progress = {
|
||||
orgSiteName: siteName,
|
||||
setup,
|
||||
courses: [],
|
||||
lessons: []
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const setupProgressApi = new SetupProgressApi();
|
||||
|
||||
/**
|
||||
* Calculates progress percentage from setup items
|
||||
*/
|
||||
export function calculateProgress(setupItems: SetupItem[]): number {
|
||||
if (!setupItems || setupItems.length === 0) return 0;
|
||||
const completed = setupItems.filter((item) => item.is_completed).length;
|
||||
return Math.round((completed / setupItems.length) * 100);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { calculateProgress, setupProgressApi } from '../api/setup-progress.svelte';
|
||||
import type { SetupItem } from '../api/setup-progress.svelte';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
|
||||
let { setupItems, size = 'default' } = $props<{
|
||||
setupItems?: SetupItem[];
|
||||
size?: 'small' | 'default';
|
||||
}>();
|
||||
|
||||
const effectiveSetupItems = $derived(setupItems ?? setupProgressApi.progress.setup ?? []);
|
||||
const progressPercentage = $derived(calculateProgress(effectiveSetupItems));
|
||||
const isLoading = $derived(setupProgressApi.isLoading);
|
||||
const sizeClass = $derived(size === 'small' ? 'size-10' : 'size-14');
|
||||
</script>
|
||||
|
||||
<div class="relative shrink-0">
|
||||
{#if isLoading}
|
||||
<!-- Loading skeleton - round circle -->
|
||||
<Skeleton class="{sizeClass} rounded-full" />
|
||||
{:else}
|
||||
<svg class="{sizeClass} -rotate-90" viewBox="0 0 100 100">
|
||||
<!-- Background circle -->
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="8"
|
||||
class="text-gray-200 dark:text-gray-700"
|
||||
/>
|
||||
<!-- Progress circle -->
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="8"
|
||||
stroke-linecap="round"
|
||||
class="text-green-600"
|
||||
stroke-dasharray={2 * Math.PI * 45}
|
||||
stroke-dashoffset={2 * Math.PI * 45 * (1 - progressPercentage / 100)}
|
||||
style="transition: stroke-dashoffset 0.5s ease-in-out;"
|
||||
/>
|
||||
</svg>
|
||||
<!-- Center text -->
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<p class="{size === 'small' ? 'text-[10px]' : 'text-sm'} font-bold">{progressPercentage}%</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import GoogleIconColored from '$lib/components/Icons/GoogleIconColored.svelte';
|
||||
import { authClient } from '$lib/utils/services/auth/client';
|
||||
import * as Card from '@cio/ui/base/card';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
import { preventDefault } from '$lib/utils/functions/svelte';
|
||||
import { ROUTE } from '$lib/utils/constants/routes';
|
||||
|
||||
interface Props {
|
||||
isLogin?: boolean;
|
||||
showOnlyContent?: boolean;
|
||||
isLoading?: boolean;
|
||||
showLogo?: boolean;
|
||||
hideGoogleAuth?: boolean;
|
||||
redirectPathname?: string;
|
||||
handleSubmit?: () => void;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
isLogin = true,
|
||||
showOnlyContent = false,
|
||||
isLoading = false,
|
||||
showLogo = false,
|
||||
hideGoogleAuth = false,
|
||||
redirectPathname = '',
|
||||
handleSubmit = () => {},
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
async function signInWithGoogle() {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
console.log({ params });
|
||||
// const redirectTo = `https://app.classroomio.com?forwardTo=${
|
||||
// window.location.origin + params.get('redirect')
|
||||
// }`;
|
||||
const pathname = redirectPathname || params.get('redirect') || '';
|
||||
const redirectTo = `${window.location.origin + pathname}`;
|
||||
const errorCallbackURL = `${window.location.origin + ROUTE.AUTH_FAILED}`;
|
||||
|
||||
console.log({ redirectTo });
|
||||
|
||||
try {
|
||||
console.log('signInWithGoogle');
|
||||
const data = await authClient.signIn.social({
|
||||
provider: 'google',
|
||||
callbackURL: redirectTo,
|
||||
errorCallbackURL: errorCallbackURL
|
||||
});
|
||||
|
||||
console.log('data', data);
|
||||
} catch (error) {
|
||||
console.log('catch error', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen w-full items-center justify-center p-4">
|
||||
<Card.Root class="ui:w-full max-w-[400px]">
|
||||
{#if !showOnlyContent || showLogo}
|
||||
<Card.Header class="ui:flex ui:flex-col ui:items-center ui:gap-4">
|
||||
<Avatar
|
||||
src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : '/logo-192.png'}
|
||||
name={$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}
|
||||
shape="rounded-md"
|
||||
width="w-10"
|
||||
height="max-h-10"
|
||||
className="mr-2"
|
||||
/>
|
||||
<a href="/">
|
||||
<Card.Title class="ui:text-2xl ui:font-bold">
|
||||
{isLogin ? $t('login.welcome') : $t('login.create_account')}
|
||||
</Card.Title>
|
||||
</a>
|
||||
{#if isLogin}
|
||||
<Card.Description class="ui:text-center">Sign in to continue</Card.Description>
|
||||
{/if}
|
||||
</Card.Header>
|
||||
{/if}
|
||||
|
||||
<Card.Content>
|
||||
<form onsubmit={preventDefault(handleSubmit)}>
|
||||
{@render children?.()}
|
||||
</form>
|
||||
|
||||
{#if !showOnlyContent && !hideGoogleAuth}
|
||||
<div class="mt-6 flex flex-col gap-6">
|
||||
<div class="relative flex items-center justify-center">
|
||||
<Separator />
|
||||
<span class="ui:bg-card ui:text-muted-foreground absolute px-2 text-sm"> Or continue With </span>
|
||||
</div>
|
||||
<Button variant="outline" onclick={signInWithGoogle} disabled={isLoading} class="w-full">
|
||||
<GoogleIconColored />
|
||||
<span>
|
||||
{isLogin ? $t('login.login_with_google') : $t('login.signup_with_google')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
{#if !showOnlyContent}
|
||||
<Card.Footer class="flex-col gap-2 border-t pt-6">
|
||||
<p class="text-muted-foreground text-center text-sm">
|
||||
{#if isLogin}
|
||||
{$t('login.not_registered_yet')}
|
||||
<a class="text-primary hover:underline" href="/signup{page.url.search}">{$t('login.signup')}</a>
|
||||
{:else}
|
||||
{$t('login.already_have_account')}
|
||||
<a class="text-primary hover:underline" href="/login{page.url.search}">{$t('login.login')}</a>
|
||||
{/if}
|
||||
</p>
|
||||
</Card.Footer>
|
||||
{/if}
|
||||
</Card.Root>
|
||||
</div>
|
||||
@@ -5,6 +5,6 @@
|
||||
let { label = $t('course.navItem.lessons.exercises.new_exercise_modal.coming_soon') } = $props();
|
||||
</script>
|
||||
|
||||
<Badge variant="outline" class="ui:text-white bg-blue-500 dark:bg-blue-600 dark:text-black">
|
||||
<Badge variant="outline" class="ui:text-white border-none! bg-blue-600 dark:bg-blue-700 dark:text-black">
|
||||
{label}
|
||||
</Badge>
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
export { default as AppHeader } from './navigation/app-header.svelte';
|
||||
export { default as AuthUI } from './auth-ui.svelte';
|
||||
export { default as ComingSoon } from './coming-soon.svelte';
|
||||
export { default as PageLoadProgress } from './page-load-progress.svelte';
|
||||
export { default as AppHeader } from './navigation/app-header.svelte';
|
||||
export { default as PoweredBy } from './upgrade-powered-by.svelte';
|
||||
export { default as RoleBasedSecurity } from './role-based-security.svelte';
|
||||
export { default as UpgradeBanner } from './upgrade-banner.svelte';
|
||||
export { default as UpgradeModal } from './upgrade-modal.svelte';
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
</script>
|
||||
|
||||
<Breadcrumb.Root>
|
||||
<Breadcrumb.List>
|
||||
<Breadcrumb.List class="flex-nowrap!">
|
||||
<Breadcrumb.Item>
|
||||
<OrgSwitcher variant="breadcrumb" />
|
||||
</Breadcrumb.Item>
|
||||
|
||||
@@ -6,10 +6,21 @@
|
||||
import * as Popover from '@cio/ui/base/popover';
|
||||
import Search from '../search.svelte';
|
||||
import AppBreadcrumbs from './app-breadcrumbs.svelte';
|
||||
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
|
||||
import * as Empty from '@cio/ui/base/empty';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { setupProgressApi } from '$lib/features/setup/api/setup-progress.svelte';
|
||||
import AppSetup from './app-setup.svelte';
|
||||
|
||||
$effect(() => {
|
||||
if ($currentOrg.siteName) {
|
||||
setupProgressApi.fetchSetupProgress($currentOrg.siteName);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="group-has-data-[collapsible=icon]/sidebar-wrapper:h-8 border-border flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear"
|
||||
class="group-has-data-[collapsible=icon]/sidebar-wrapper:h-8 border-border ui:bg-background bg-background sticky top-0 z-50 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear"
|
||||
>
|
||||
<div class="flex w-full items-center gap-2 px-4">
|
||||
<Sidebar.Trigger />
|
||||
@@ -22,6 +33,8 @@
|
||||
|
||||
<span class="grow"></span>
|
||||
|
||||
<AppSetup />
|
||||
|
||||
<Search />
|
||||
|
||||
<Popover.Root>
|
||||
@@ -31,11 +44,21 @@
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<p class="text-sm">Your notifications will show here</p>
|
||||
</div>
|
||||
</div>
|
||||
<Empty.Root class="ui:from-muted/50 ui:to-background ui:h-full ui:bg-gradient-to-b ui:from-30%">
|
||||
<Empty.Header>
|
||||
<Empty.Media variant="icon">
|
||||
<BellIcon />
|
||||
</Empty.Media>
|
||||
<Empty.Title>No Notifications</Empty.Title>
|
||||
<Empty.Description>You're all caught up. New notifications will appear here.</Empty.Description>
|
||||
</Empty.Header>
|
||||
<Empty.Content>
|
||||
<Button variant="outline" size="sm">
|
||||
<RefreshCcwIcon />
|
||||
Refresh
|
||||
</Button>
|
||||
</Empty.Content>
|
||||
</Empty.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import * as HoverCard from '@cio/ui/base/hover-card';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
|
||||
import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
import SetupProgress from '$lib/features/setup/components/setup-progress.svelte';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import BadgeCheckIcon from '@lucide/svelte/icons/badge-check';
|
||||
import { calculateProgress, setupProgressApi } from '$lib/features/setup/api/setup-progress.svelte';
|
||||
|
||||
function handleRefresh() {
|
||||
if ($currentOrg.siteName) {
|
||||
setupProgressApi.fetchSetupProgress($currentOrg.siteName);
|
||||
}
|
||||
}
|
||||
|
||||
const setupList = $derived(
|
||||
setupProgressApi.setupList.map((item) => {
|
||||
if (item.id === 'profile') {
|
||||
return {
|
||||
...item,
|
||||
is_completed: !$profile.avatarUrl?.includes('avatars/avatar.png')
|
||||
};
|
||||
}
|
||||
return item;
|
||||
})
|
||||
);
|
||||
const progressPercentage = $derived(calculateProgress(setupList));
|
||||
</script>
|
||||
|
||||
{#if progressPercentage < 100}
|
||||
<HoverCard.Root openDelay={200}>
|
||||
<HoverCard.Trigger class="hidden md:block">
|
||||
<SetupProgress size="small" setupItems={setupList} />
|
||||
</HoverCard.Trigger>
|
||||
<HoverCard.Content class="hidden w-80 md:block">
|
||||
{#if $currentOrg.id}
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold">{$t('setup.your_checklist')}</h4>
|
||||
|
||||
<Button variant="secondary" size="icon" onclick={handleRefresh} disabled={setupProgressApi.isLoading}>
|
||||
<RefreshCcwIcon class="size-3 {setupProgressApi.isLoading ? 'animate-spin' : ''}" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#each setupList as item}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
{#if item.is_completed}
|
||||
<BadgeCheckIcon class="custom text-green-600! size-4 shrink-0 opacity-50" />
|
||||
<span class="text-muted-foreground line-through opacity-50">{$t(item.title)}</span>
|
||||
{:else}
|
||||
<CircleIcon class="size-4 shrink-0 text-gray-400" />
|
||||
<span>{$t(item.title)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</HoverCard.Content>
|
||||
</HoverCard.Root>
|
||||
{/if}
|
||||
@@ -64,7 +64,7 @@ export function generateBreadcrumbs(
|
||||
|
||||
// Handle items with query params (like settings tabs)
|
||||
if (matchedNavItem?.items && searchParams) {
|
||||
const subItem = matchedNavItem.items.find((sub) => isActive(pathWithQuery, sub.url, true));
|
||||
const subItem = matchedNavItem.items.find((sub) => isActive(pathWithQuery, sub.url, undefined, true));
|
||||
if (subItem) {
|
||||
breadcrumbs.push({
|
||||
label: subItem.title,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user