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

* feat: new components, deploy storybook

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

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

* chore: add field, card, password components

* feat: improve page component & update AuthUI path

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

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

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

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

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

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

* chore: update organization API and UI components

- Added new API methods for organization plan operations and team management.
- Introduced middleware for API key authentication and organization admin checks.
- Refactored organization-related components to utilize the new API structure.
- Updated various UI components to improve consistency and maintainability.
- Enhanced error handling and validation for organization-related actions.
- Removed deprecated services and streamlined the codebase.
This commit is contained in:
Rotimi Best
2025-12-02 09:54:38 +01:00
committed by GitHub
parent 531516a8f7
commit c7854efae3
350 changed files with 13928 additions and 3895 deletions
@@ -0,0 +1,42 @@
<script lang="ts">
import { page } from '$app/state';
import { AuthUI } from '$lib/features/ui';
import { Button } from '@cio/ui/base/button';
import * as Card from '@cio/ui/base/card';
import { currentOrg } from '$lib/utils/store/org';
import Avatar from '$lib/components/Avatar/index.svelte';
const errorCode = $derived(new URLSearchParams(page.url.search).get('error'));
</script>
<svelte:head>
<title>Reset Password - ClassroomIO</title>
</svelte:head>
<AuthUI isLogin={false} showOnlyContent={true}>
<div class="flex flex-col items-center 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"
/>
<p class="text-xl font-semibold">Something went wrong</p>
{#if errorCode}
<Card.Description class="border px-2 py-1">
CODE: {errorCode}
</Card.Description>
{/if}
<Card.Description class="text-center">
We encountered an unexpected error. Please try again or return to the home page. If you're a developer, you can
find more information about the error here.
</Card.Description>
<Button href="/" class="">Go Home</Button>
</div>
</AuthUI>
@@ -0,0 +1,70 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ROUTE } from '$lib/utils/constants/routes';
import { AuthUI } from '$lib/features/ui';
import EmailSentIcon from '$lib/components/Icons/EmailSentIcon.svelte';
import { forgotApi } from '$lib/features/auth/api/forgot.svelte';
import type { TForgotPasswordForm } from '$lib/features/auth/utils/types';
import * as Field from '@cio/ui/base/field';
import { Input } from '@cio/ui/base/input';
import { Button } from '@cio/ui/base/button';
import * as Card from '@cio/ui/base/card';
let fields: TForgotPasswordForm = $state({ email: '' });
</script>
<svelte:head>
<title>Forgot Password - ClassroomIO</title>
</svelte:head>
<AuthUI handleSubmit={() => forgotApi.submit(fields)} showOnlyContent={true} showLogo={!forgotApi.success}>
{#if forgotApi.success}
<div class="ui:flex ui:flex-col ui:items-center ui:justify-center ui:gap-4">
<EmailSentIcon />
<Card.Title class="ui:text-xl">Email Sent!</Card.Title>
<p class="ui:text-center ui:text-sm ui:text-muted-foreground">
We have sent a confirmation email to <span class="ui:text-primary">{fields.email}</span>. Kindly check your
inbox to reset password or spam to reset your password.
</p>
</div>
<div class="ui:mt-6 ui:flex ui:w-full ui:items-center ui:justify-end">
<Button type="button" class="ui:w-full" onclick={() => goto(ROUTE.LOGIN)}>Okay</Button>
</div>
{:else}
<div class="ui:flex ui:flex-col ui:gap-6">
<div>
<Card.Title class="ui:text-xl">Forgot Password</Card.Title>
<Card.Description class="ui:mt-2">
We will send you a reset link to your email
</Card.Description>
</div>
<Field.Field>
<Field.Label for="email">Your email</Field.Label>
<Field.Content>
<Input
id="email"
type="email"
bind:value={fields.email}
placeholder="you@domain.com"
disabled={forgotApi.isLoading}
autofocus
aria-invalid={forgotApi.errors.email ? 'true' : undefined}
/>
{#if forgotApi.errors.email}
<Field.Error>{forgotApi.errors.email}</Field.Error>
{/if}
</Field.Content>
</Field.Field>
<div class="ui:flex ui:w-full ui:flex-col ui:gap-2">
<Button type="submit" disabled={forgotApi.isLoading} loading={forgotApi.isLoading} class="ui:w-full">
Reset Password
</Button>
<Button type="button" variant="ghost" class="ui:w-full" onclick={() => goto(ROUTE.LOGIN)}>
Cancel
</Button>
</div>
</div>
{/if}
</AuthUI>
@@ -1,18 +1,17 @@
<script lang="ts">
import AuthUI from '$lib/components/AuthUI/index.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { AuthUI } from '$lib/features/ui';
import { LOGIN_FIELDS } from '$lib/utils/constants/authentication';
import { getSupabase } from '$lib/utils/functions/supabase';
import { t } from '$lib/utils/functions/translations';
import { authValidation } from '$lib/utils/functions/validator';
import { currentOrg } from '$lib/utils/store/org';
import { authClient } from '$lib/utils/services/auth/client';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { globalStore } from '$lib/utils/store/app';
import * as Field from '@cio/ui/base/field';
import { Input } from '@cio/ui/base/input';
import { Password } from '@cio/ui/custom/password';
import { Button } from '@cio/ui/base/button';
let formRef: HTMLFormElement | undefined = $state();
let supabase = getSupabase();
let fields = $state(Object.assign({}, LOGIN_FIELDS));
let submitError: string | undefined = $state();
let loading = $state(false);
@@ -34,7 +33,6 @@
{
email: fields.email,
password: fields.password
// callbackURL: '/' // the root page should shows a spinner until the account is loaded and redirect happens in +layout.svelte (accountManager.init())
},
{
onSuccess: () => {
@@ -67,46 +65,55 @@
<title>Welcome back to {$currentOrg.name || 'ClassroomIO'}</title>
</svelte:head>
<AuthUI {supabase} isLogin={true} {handleSubmit} isLoading={loading} bind:formRef>
<div class="mt-4 w-full">
<p class="mb-6 text-lg font-semibold dark:text-white">{$t('login.welcome')}</p>
<TextField
label={$t('login.email')}
bind:value={fields.email}
type="email"
autoFocus={true}
placeholder="you@domain.com"
className="mb-6"
inputClassName="w-full"
isDisabled={loading}
errorMessage={$t(errors.email)}
/>
<TextField
label={$t('login.password')}
bind:value={fields.password}
type="password"
placeholder="************"
className="mb-6"
inputClassName="w-full"
isDisabled={loading}
errorMessage={$t(errors.password)}
/>
{#if submitError}
<p class="text-sm text-red-500">{submitError}</p>
{/if}
<div class="w-full text-right">
<a class="text-md text-primary-700" href="/forgot"> {$t('login.forgot')} </a>
</div>
</div>
<AuthUI isLogin={true} {handleSubmit} isLoading={loading}>
<div class="ui:flex ui:flex-col ui:gap-6">
<Field.Field>
<Field.Label for="email">{$t('login.email')}</Field.Label>
<Field.Content>
<Input
id="email"
type="email"
bind:value={fields.email}
placeholder="you@domain.com"
disabled={loading}
autofocus
aria-invalid={errors.email ? 'true' : undefined}
autocomplete="username"
/>
{#if errors.email}
<Field.Error>{$t(errors.email)}</Field.Error>
{/if}
</Field.Content>
</Field.Field>
<div class="my-4 flex w-full items-center justify-end">
<!-- <a href="/login" class="text-primary-700 text-sm">Create an account</a> -->
<PrimaryButton
label={$t('login.login')}
type="submit"
className="sm:w-full w-full"
isDisabled={loading}
isLoading={loading}
/>
<Field.Field>
<div class="ui:flex ui:items-center ui:justify-between">
<Field.Label for="password">{$t('login.password')}</Field.Label>
<a class="ui:text-sm ui:text-primary ui:hover:underline" href="/forgot">
{$t('login.forgot')}
</a>
</div>
<Field.Content>
<Password
id="password"
bind:value={fields.password}
placeholder="************"
disabled={loading}
aria-invalid={errors.password ? 'true' : undefined}
autocomplete="current-password"
/>
{#if errors.password}
<Field.Error>{$t(errors.password)}</Field.Error>
{/if}
</Field.Content>
</Field.Field>
{#if submitError}
<p class="ui:text-sm ui:text-destructive">{submitError}</p>
{/if}
<Button type="submit" disabled={loading} {loading} class="ui:w-full">
{$t('login.login')}
</Button>
</div>
</AuthUI>
@@ -7,6 +7,6 @@
});
</script>
<div class="app-background flex min-h-screen w-full items-center justify-center">
<div class="flex min-h-screen w-full items-center justify-center">
<div>Logging you out...</div>
</div>
@@ -1,9 +1,10 @@
<script lang="ts">
import * as Select from '@cio/ui/base/select';
import TextField from '$lib/components/Form/TextField.svelte';
import { Input } from '@cio/ui/base/input';
import { DomainInput } from '@cio/ui/custom/domain-input';
import * as Field from '@cio/ui/base/field';
import { Button } from '@cio/ui/base/button';
import UserProfileIcon from '$lib/components/Icons/UserProfileIcon.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { profile } from '$lib/utils/store/user';
import { onboardingApi } from '$lib/features/onboarding/api/onboarding.svelte';
import { generateSitename } from '$lib/utils/functions/org';
@@ -72,50 +73,42 @@
<div class="form-container w-full overflow-y-auto px-2">
{#if onboardingApi.step === ONBOARDING_STEPS.ORG_SETUP}
<!-- Name/Organization Question -->
<div id="role-question" class="mb-6 flex flex-col items-start">
<Field.Group class="mb-6">
<!-- Full name -->
<TextField
label={$t('onboarding.fullname')}
bind:value={fields.fullname}
name="fullname"
type="text"
placeholder="e.g Joke Silva"
className="mb-5 w-full"
labelClassName="text-lg font-normal"
errorMessage={onboardingApi.errors.fullname}
isRequired
/>
<Field.Field>
<Field.Label>{$t('onboarding.fullname')}</Field.Label>
<Input bind:value={fields.fullname} name="fullname" type="text" placeholder="e.g Joke Silva" />
{#if onboardingApi.errors.fullname}
<Field.Error>{onboardingApi.errors.fullname}</Field.Error>
{/if}
</Field.Field>
<!-- Org name -->
<TextField
label={$t('onboarding.name')}
bind:value={fields.orgName}
name="orgname"
type="text"
placeholder="e.g Education For All"
className="mb-5 w-full"
labelClassName="text-lg font-normal"
errorMessage={onboardingApi.errors.orgName}
isRequired
/>
<Field.Field>
<Field.Label>{$t('onboarding.name')}</Field.Label>
<Input bind:value={fields.orgName} name="orgname" type="text" placeholder="e.g My School Name" />
{#if onboardingApi.errors.orgName}
<Field.Error>{onboardingApi.errors.orgName}</Field.Error>
{/if}
</Field.Field>
<!-- Org Site Name -->
<TextField
label={$t('onboarding.organisation_sitename')}
helperMessage={`https://${fields.siteName || ''}.classroomio.com`}
bind:value={fields.siteName}
name="sitename"
type="text"
placeholder="e.g edforall"
className="mb-5 w-full"
labelClassName="text-lg font-normal"
errorMessage={onboardingApi.errors.siteName}
onChange={() => {
isSiteNameTouched = true;
}}
isRequired
/>
</div>
<Field.Field>
<Field.Label>{$t('onboarding.organisation_sitename')}</Field.Label>
<DomainInput
bind:value={fields.siteName}
placeholder="myschool"
prefix="https://"
suffix=".classroomio.com"
oninput={() => {
isSiteNameTouched = true;
}}
/>
{#if onboardingApi.errors.siteName}
<Field.Error>{onboardingApi.errors.siteName}</Field.Error>
{/if}
</Field.Field>
</Field.Group>
{:else}
<!-- Goal/Source Question -->
<div id="goal-question" class="mb-6 flex flex-col items-center">
@@ -189,21 +182,13 @@
<div class="flex">
{#if onboardingApi.step > ONBOARDING_STEPS.ORG_SETUP}
<PrimaryButton
label={$t('onboarding.back')}
variant={VARIANTS.NONE}
className="py-3 px-6 mr-2 text-primary-700"
onClick={() => (onboardingApi.step = ONBOARDING_STEPS.ORG_SETUP)}
/>
<Button variant="ghost" onclick={() => (onboardingApi.step = ONBOARDING_STEPS.ORG_SETUP)}>
{$t('onboarding.back')}
</Button>
{/if}
<PrimaryButton
label={$t('onboarding.continue')}
variant={VARIANTS.CONTAINED}
type="button"
className="px-5 py-3"
isLoading={onboardingApi.isLoading}
onClick={() => onboardingApi.submit(fields)}
/>
<Button loading={onboardingApi.isLoading} onclick={() => onboardingApi.submit(fields)}>
{$t('onboarding.continue')}
</Button>
</div>
</div>
</div>
@@ -0,0 +1,110 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { getConfirmPasswordError } from '$lib/utils/functions/validator';
import { AuthUI } from '$lib/features/ui';
import { resetApi } from '$lib/features/auth/api/reset.svelte';
import type { TResetPasswordForm } from '$lib/features/auth/utils/types';
import { snackbar } from '$lib/components/Snackbar/store';
import * as Field from '@cio/ui/base/field';
import { Password } from '@cio/ui/custom/password';
import { Button } from '@cio/ui/base/button';
import * as Card from '@cio/ui/base/card';
let fields: TResetPasswordForm = $state({
password: '',
confirmPassword: '',
token: ''
});
const isSubmitDisabled = $derived!!(
fields.password && fields.confirmPassword && fields.password !== fields.confirmPassword
);
const token = $derived(new URLSearchParams(page.url.search).get('token'));
onMount(() => {
if (token) return;
snackbar.error('Invalid Token');
setTimeout(() => {
goto(resolve('/login', {}));
}, 2000);
});
function setConfirmPasswordError(fields: TResetPasswordForm) {
untrack(() => {
const errors = { ...resetApi.errors };
errors.confirmPassword = getConfirmPasswordError(fields) ?? '';
resetApi.setError(errors);
});
}
$effect(() => {
setConfirmPasswordError(fields);
});
</script>
<svelte:head>
<title>Reset Password - ClassroomIO</title>
</svelte:head>
<AuthUI
isLogin={false}
handleSubmit={() => resetApi.submit({ ...fields, token: token ?? '' })}
showOnlyContent={true}
isLoading={resetApi.isLoading}
showLogo={true}
>
<div class="ui:flex ui:flex-col ui:gap-6">
<div>
<Card.Title class="ui:text-xl">New Password</Card.Title>
<Card.Description class="ui:mt-2">Enter your new password details</Card.Description>
</div>
<Field.Field>
<Field.Label for="password">Your Password</Field.Label>
<Field.Content>
<Password
id="password"
bind:value={fields.password}
placeholder="************"
disabled={resetApi.isLoading}
aria-invalid={resetApi.errors.password ? 'true' : undefined}
autocomplete="new-password"
/>
{#if resetApi.errors.password}
<Field.Error>{resetApi.errors.password}</Field.Error>
{/if}
<Field.Description>Password must be more than 8 characters</Field.Description>
</Field.Content>
</Field.Field>
<Field.Field>
<Field.Label for="confirmPassword">Confirm Password</Field.Label>
<Field.Content>
<Password
id="confirmPassword"
bind:value={fields.confirmPassword}
placeholder="************"
disabled={resetApi.isLoading}
aria-invalid={resetApi.errors.confirmPassword ? 'true' : undefined}
autocomplete="new-password"
/>
{#if resetApi.errors.confirmPassword}
<Field.Error>{resetApi.errors.confirmPassword}</Field.Error>
{/if}
</Field.Content>
</Field.Field>
<Button
type="submit"
disabled={isSubmitDisabled || resetApi.isLoading}
loading={resetApi.isLoading}
class="ui:w-full"
>
Reset Password
</Button>
</div>
</AuthUI>
@@ -1,18 +1,18 @@
<script lang="ts">
import { untrack } from 'svelte';
import AuthUI from '$lib/components/AuthUI/index.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { AuthUI } from '$lib/features/ui';
import SenjaEmbed from '$lib/components/Senja/Embed.svelte';
import { SIGNUP_FIELDS } from '$lib/utils/constants/authentication';
import { getSupabase } from '$lib/utils/functions/supabase';
import { t } from '$lib/utils/functions/translations';
import { authValidation, getConfirmPasswordError, getDisableSubmit } from '$lib/utils/functions/validator';
import { capturePosthogEvent } from '$lib/utils/services/posthog';
import { globalStore } from '$lib/utils/store/app';
import { authClient } from '$lib/utils/services/auth/client';
import * as Field from '@cio/ui/base/field';
import { Input } from '@cio/ui/base/input';
import { Password } from '@cio/ui/custom/password';
import { Button } from '@cio/ui/base/button';
let supabase = getSupabase();
let fields = $state(Object.assign({}, SIGNUP_FIELDS));
let loading = $state(false);
let errors: {
@@ -21,7 +21,6 @@
confirmPassword?: string;
} = $state({});
let submitError: string = $state('');
let formRef: HTMLFormElement | undefined = $state();
const disableSubmit = $derived(getDisableSubmit(fields));
@@ -90,67 +89,68 @@
<SenjaEmbed id="aa054658-1e15-4d00-8920-91f424326c4e" />
<AuthUI {supabase} isLogin={false} {handleSubmit} isLoading={loading} bind:formRef>
<div class="mt-4 w-full">
<p class="mb-6 text-lg font-semibold dark:text-white">Create a free account</p>
<!-- <TextField
label="Full Name"
bind:value={fields.name}
type="text"
autoFocus={true}
placeholder="e.g Joke Silva"
className="mb-6"
inputClassName="w-full"
isDisabled={loading}
errorMessage={errors.name}
isRequired
/> -->
<TextField
label={$t('login.fields.email')}
bind:value={fields.email}
type="email"
placeholder="you@domain.com"
className="mb-6"
inputClassName="w-full"
isDisabled={loading}
errorMessage={$t(errors.email ?? '')}
isRequired
/>
<TextField
label={$t('login.fields.password')}
bind:value={fields.password}
type="password"
placeholder="************"
className="mb-6"
inputClassName="w-full"
isDisabled={loading}
errorMessage={$t(errors.password ?? '')}
helperMessage={$t('login.fields.password_helper_message')}
isRequired
/>
<TextField
label={$t('login.fields.confirm_password')}
bind:value={fields.confirmPassword}
type="password"
placeholder="************"
className="mb-6"
inputClassName="w-full"
isDisabled={loading}
errorMessage={errors.confirmPassword}
isRequired
/>
{#if submitError}
<p class="text-sm text-red-500">{submitError}</p>
{/if}
</div>
<AuthUI isLogin={false} {handleSubmit} isLoading={loading}>
<div class="ui:flex ui:flex-col ui:gap-6">
<Field.Field>
<Field.Label for="email">{$t('login.fields.email')}</Field.Label>
<Field.Content>
<Input
id="email"
type="email"
bind:value={fields.email}
placeholder="you@domain.com"
disabled={loading}
autofocus
aria-invalid={errors.email ? 'true' : undefined}
autocomplete="email"
/>
{#if errors.email}
<Field.Error>{$t(errors.email)}</Field.Error>
{/if}
</Field.Content>
</Field.Field>
<div class="my-4 flex w-full items-center justify-end">
<PrimaryButton
label={$t('login.create_account')}
type="submit"
className="sm:w-full w-full"
isDisabled={disableSubmit || loading}
isLoading={loading}
/>
<Field.Field>
<Field.Label for="password">{$t('login.fields.password')}</Field.Label>
<Field.Content>
<Password
id="password"
bind:value={fields.password}
placeholder="************"
disabled={loading}
aria-invalid={errors.password ? 'true' : undefined}
autocomplete="new-password"
/>
{#if errors.password}
<Field.Error>{$t(errors.password)}</Field.Error>
{/if}
<Field.Description>{$t('login.fields.password_helper_message')}</Field.Description>
</Field.Content>
</Field.Field>
<Field.Field>
<Field.Label for="confirmPassword">{$t('login.fields.confirm_password')}</Field.Label>
<Field.Content>
<Password
id="confirmPassword"
bind:value={fields.confirmPassword}
placeholder="************"
disabled={loading}
aria-invalid={errors.confirmPassword ? 'true' : undefined}
autocomplete="new-password"
/>
{#if errors.confirmPassword}
<Field.Error>{errors.confirmPassword}</Field.Error>
{/if}
</Field.Content>
</Field.Field>
{#if submitError}
<p class="ui:text-sm ui:text-destructive">{submitError}</p>
{/if}
<Button type="submit" disabled={disableSubmit || loading} {loading} class="ui:w-full">
{$t('login.create_account')}
</Button>
</div>
</AuthUI>
+2 -2
View File
@@ -1,6 +1,6 @@
import { getSupabase, supabase } from '$lib/utils/functions/supabase';
import type { CurrentOrg } from '$lib/utils/types/org';
import type { AccountOrg } from '$lib/features/app/types';
import type { MetaTagsProps } from 'svelte-meta-tags';
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
import { dev } from '$app/environment';
@@ -20,7 +20,7 @@ interface LoadOutput {
orgSiteName: string;
isOrgSite: boolean;
skipAuth: boolean;
org: CurrentOrg | null;
org: AccountOrg | null;
baseMetaTags: MetaTagsProps;
serverLang: string;
locals: App.Locals;
+3 -13
View File
@@ -5,7 +5,7 @@
import PlayQuiz from '$lib/components/Org/Quiz/Play/index.svelte';
import { PageRestricted } from '$lib/components/Page';
import Snackbar from '$lib/components/Snackbar/index.svelte';
import UpgradeModal from '$lib/components/Upgrade/Modal.svelte';
import { UpgradeModal } from '$lib/features/ui';
import { user } from '$lib/utils/store/user';
import { setupAnalytics } from '$lib/utils/functions/appSetup';
import { setTheme } from '$lib/utils/functions/theme';
@@ -101,11 +101,11 @@
<Snackbar />
{#if data.org?.is_restricted || $currentOrg.isRestricted}
{#if data.org?.isRestricted || $currentOrg.isRestricted}
<PageRestricted />
{:else if data.skipAuth}
<PlayQuiz />
{:else if data.isOrgSite && !path}
{:else if data.isOrgSite && data.org && !path}
<OrgLandingPage orgSiteName={data.orgSiteName} org={data.org} />
{:else}
<PageLoadProgress zIndex={10000} />
@@ -117,16 +117,6 @@
:global(:root) {
--main-primary-color: rgba(29, 78, 216, 1);
--border-color: #eaecef;
--app-background-color: #fafbfc;
--app-background: radial-gradient(circle at 10% 20%, rgb(239, 246, 249) 0%, rgb(206, 239, 253) 90%);
--dark-app-background: radial-gradient(circle at 10% 20%, rgb(0 0 0) 0%, rgb(27 60 74) 90%);
}
:global(.app-background) {
background: var(--app-background);
}
:global(.dark .app-background) {
background: var(--dark-app-background);
}
:global(.dark svg.dark) {
+3 -3
View File
@@ -1,15 +1,15 @@
<script>
import { appInitApi } from '$lib/features/app/init.svelte';
import { Circle } from 'svelte-loading-spinners';
import { Spinner } from '@cio/ui/base/spinner';
</script>
<svelte:head>
<title>ClassroomIO - Beautiful and Robust Course Platform</title>
</svelte:head>
<div class="m-2 flex w-screen flex-col items-center justify-center font-sans sm:m-0 md:min-h-[93vh] dark:bg-black">
<div class="m-2 flex h-screen w-screen flex-col items-center justify-center font-sans sm:m-0">
{#if appInitApi.loading || appInitApi.data}
<Circle size="50" color="#1d4ee2" unit="px" duration="1s" />
<Spinner class="size-14! text-blue-700!" />
{:else if appInitApi.error}
<p class="text-red-500">{appInitApi.error}</p>
{/if}
@@ -1,10 +1,8 @@
import { cancelOrgPlan, createOrgPlan, updateOrgPlan } from '$lib/utils/services/org';
import { OrgPlanApiServer } from '$lib/features/org/api/org-plan.server';
import { PLAN } from '@cio/utils/plans';
import type { PolarWebhookPayload } from '$lib/utils/types/polar';
import { Webhooks } from '@polar-sh/sveltekit';
import { env } from '$env/dynamic/private';
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
export const POST = Webhooks({
webhookSecret: env.POLAR_WEBHOOK_SECRET!,
@@ -12,8 +10,6 @@ export const POST = Webhooks({
});
async function onPayload(payload: PolarWebhookPayload) {
const supabase = getServerSupabase();
const metadata = payload.data.metadata;
const orgId = metadata.orgId;
const triggeredBy = metadata.triggeredBy;
@@ -39,45 +35,45 @@ async function onPayload(payload: PolarWebhookPayload) {
break;
case 'subscription.created':
if (isSubscriptionActive) {
const { data, error } = await createOrgPlan({
supabase,
subscriptionId,
try {
const planData = {
orgId,
triggeredBy: parseInt(triggeredBy),
planName: PLAN.EARLY_ADOPTER,
data: payload.data
});
planName: PLAN.EARLY_ADOPTER as 'EARLY_ADOPTER' | 'ENTERPRISE' | 'BASIC',
subscriptionId,
payload: payload.data as unknown as Record<string, unknown>
};
if (error) {
const result = await OrgPlanApiServer.createOrgPlan(planData);
console.log('Subscription created', result);
} catch (error) {
console.error('Error creating org plan', error);
}
console.log('Subscription created', data);
}
break;
case 'subscription.updated':
if (!isSubscriptionActive) {
// Handle Cancel Subscription
const { data, error } = await cancelOrgPlan({
try {
const result = await OrgPlanApiServer.cancelOrgPlan({
subscriptionId,
data: payload.data
payload: payload.data as unknown as Record<string, unknown>
});
if (error) {
console.log('Subscription canceled', result);
} catch (error) {
console.error('Error canceling org plan', error);
}
console.log('Subscription canceled', data);
} else {
// Handle Update Subscription
const { data, error } = await updateOrgPlan({
supabase,
try {
const result = await OrgPlanApiServer.updateOrgPlan({
subscriptionId,
data: payload.data
payload: payload.data as unknown as Record<string, unknown>
});
if (error) {
console.log('Subscription updated', result);
} catch (error) {
console.error('Error updating org plan', error);
}
console.log('Subscription updated', data);
}
break;
case 'subscription.active':
@@ -10,7 +10,7 @@
import { CourseContainer } from '$lib/components/CourseContainer';
import { PageBody, PageNav } from '$lib/components/Page';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import { snackbar } from '$lib/components/Snackbar/store';
import { supabase } from '$lib/utils/functions/supabase';
import { t } from '$lib/utils/functions/translations';
@@ -27,9 +27,9 @@
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import type { Feed } from '$lib/utils/types/feed';
import type { CurrentOrg } from '$lib/utils/types/org';
import PinIcon from '@lucide/svelte/icons/pin';
import { onMount } from 'svelte';
import type { AccountOrg } from '$lib/features/app/types';
let { data = $bindable() } = $props();
@@ -214,7 +214,7 @@
}
};
function getPageRoles(org: CurrentOrg) {
function getPageRoles(org: AccountOrg) {
const roles: number[] = [1, 2];
if (org.customization.course.newsfeed) {
@@ -2,7 +2,7 @@
import IssueCertificateModal from '$lib/components/Course/components/Ceritficate/IssueCertificateModal.svelte';
import { CourseContainer } from '$lib/components/CourseContainer';
import { PageBody, PageNav } from '$lib/components/Page';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
// import DeleteConfirmation from '$lib/components/Course/components/People/DeleteConfirmation.svelte';
import Design from '$lib/components/Course/components/Ceritficate/Design.svelte';
// import Reports from '$lib/components/Course/components/Ceritficate/Reports.svelte';
@@ -14,7 +14,7 @@
import { PageBody, PageNav } from '$lib/components/Page';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import { t } from '$lib/utils/functions/translations';
import { profile } from '$lib/utils/store/user';
import type { Lesson } from '$lib/utils/types';
@@ -29,7 +29,7 @@
import { IconButton } from '$lib/components/IconButton';
import { PageBody, PageNav } from '$lib/components/Page';
import { CourseContainer } from '$lib/components/CourseContainer';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import Exercises from '$lib/components/Course/components/Lesson/Exercises/index.svelte';
import Materials from '$lib/components/Course/components/Lesson/Materials/index.svelte';
@@ -13,7 +13,7 @@
import type { GroupPerson } from '$lib/utils/types';
import { t } from '$lib/utils/functions/translations';
import { fetchMarks } from '$lib/utils/services/marks';
import type { CurrentOrg } from '$lib/utils/types/org';
import type { AccountOrg } from '$lib/features/app/types';
import { snackbar } from '$lib/components/Snackbar/store';
import { getLectureNo } from '$lib/components/Course/function.js';
import { fetchExercisesByMarks } from '$lib/utils/services/courses';
@@ -22,7 +22,7 @@
import { course } from '$lib/components/Course/store';
import { PageBody, PageNav } from '$lib/components/Page';
import { CourseContainer } from '$lib/components/CourseContainer';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import { lessons } from '$lib/components/Course/components/Lesson/store/lessons';
let { data } = $props();
@@ -90,10 +90,10 @@
}
// TODO WRITE A REDIRECT FUNCTION FOR THIS THIS PAGE
function getPageRoles(org: CurrentOrg) {
function getPageRoles(org: AccountOrg) {
const roles = [1, 2];
if (org.customization.course.grading) {
if (org.customization?.course?.grading) {
roles.push(3);
}
@@ -4,7 +4,7 @@
import { IconButton } from '$lib/components/IconButton';
import { PageBody, PageNav } from '$lib/components/Page';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import { t } from '$lib/utils/functions/translations';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import { CourseContainer } from '$lib/components/CourseContainer';
@@ -13,7 +13,7 @@
import Avatar from '$lib/components/Avatar/index.svelte';
import { ComingSoon } from '$lib/features/ui';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import InvitationModal from '$lib/components/Course/components/People/InvitationModal.svelte';
import DeleteConfirmation from '$lib/components/Course/components/People/DeleteConfirmation.svelte';
@@ -24,7 +24,7 @@
import Chip from '$lib/components/Chip/index.svelte';
import { PageBody, PageNav } from '$lib/components/Page';
import { CourseContainer } from '$lib/components/CourseContainer';
import RoleBasedSecurity from '$lib/components/RoleBasedSecurity/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui';
import MarkExerciseModal from '$lib/components/Course/components/Lesson/Exercise/MarkExerciseModal.svelte';
let { data = $bindable() } = $props();
@@ -1,66 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { ROUTE } from '$lib/utils/constants/routes';
import AuthUI from '$lib/components/AuthUI/index.svelte';
import EmailSentIcon from '$lib/components/Icons/EmailSentIcon.svelte';
import { forgotApi } from '$lib/features/auth/api/forgot.svelte';
import type { TForgotPasswordForm } from '$lib/features/auth/utils/types';
let fields: TForgotPasswordForm = $state({ email: '' });
</script>
<svelte:head>
<title>Forgot Password - ClassroomIO</title>
</svelte:head>
<AuthUI handleSubmit={() => forgotApi.submit(fields)} showOnlyContent={true} showLogo={!forgotApi.success}>
{#if forgotApi.success}
<div class="mt-4 flex w-full flex-col items-center justify-center">
<EmailSentIcon />
<h3 class="my-3 text-xl font-semibold dark:text-white">Email Sent!</h3>
<p class="text-md mb-6 text-center dark:text-white">
We have sent a confirmation email to <span class="text-primary-700">{fields.email}</span>. Kindly check your
inbox to reset password or spam to reset your password.
</p>
</div>
<div class="my-4 flex w-full flex-col items-center justify-end">
<PrimaryButton label="Okay" type="button" className="sm:w-full w-full mb-4" onClick={() => goto(ROUTE.LOGIN)} />
</div>
{:else}
<div class="mt-4 w-full">
<h3 class="my-3 text-xl font-semibold dark:text-white">Forgot Password</h3>
<p class="mb-6 text-sm dark:text-white">We will send you a reset link to your email</p>
<TextField
label="Your email"
bind:value={fields.email}
type="email"
autoFocus={true}
placeholder="you@domain.com"
className="mb-6"
inputClassName="w-full"
isDisabled={forgotApi.isLoading}
errorMessage={forgotApi.errors.email}
/>
</div>
<div class="my-4 flex w-full flex-col items-center justify-end">
<PrimaryButton
label="Reset Password"
type="submit"
className="sm:w-full w-full mb-4"
isLoading={forgotApi.isLoading}
/>
<PrimaryButton
label="Cancel"
type="button"
className="sm:w-full w-full text-primary-700"
variant={VARIANTS.NONE}
onClick={() => goto(ROUTE.LOGIN)}
/>
</div>
{/if}
</AuthUI>
@@ -1,10 +1,5 @@
import { OrgApiServer } from '$lib/features/org/api/org.server';
import { redirect } from '@sveltejs/kit';
import { getSupabase, supabase } from '$lib/utils/functions/supabase';
import { getCurrentOrg } from '$lib/utils/services/org';
if (!supabase) {
getSupabase();
}
export const load = async ({ params = { hash: '' } }) => {
try {
@@ -16,7 +11,8 @@ export const load = async ({ params = { hash: '' } }) => {
if (!id || !name || !description || !orgSiteName) {
throw 'Validation failed';
}
const currentOrg = await getCurrentOrg(orgSiteName, true);
const currentOrg = await OrgApiServer.getOrgBySiteName(orgSiteName);
return {
id,
@@ -3,11 +3,10 @@
import { onMount } from 'svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { getSupabase } from '$lib/utils/functions/supabase';
import AuthUI from '$lib/components/AuthUI/index.svelte';
import { AuthUI } from '$lib/features/ui';
import { currentOrg } from '$lib/utils/store/org';
import { setTheme } from '$lib/utils/functions/theme';
import { addGroupMember } from '$lib/utils/services/courses';
import type { CurrentOrg } from '$lib/utils/types/org.js';
import { ROLE } from '@cio/utils/constants';
import { profile } from '$lib/utils/store/user';
import { triggerSendEmail, NOTIFICATION_NAME } from '$lib/utils/services/notification/notification';
@@ -21,7 +20,6 @@
let loading = $state(false);
let disableSubmit = false;
let formRef: HTMLFormElement | undefined = $state();
async function handleSubmit() {
loading = true;
@@ -104,11 +102,6 @@
});
}
function setCurOrg(cOrg: CurrentOrg) {
if (!cOrg) return;
currentOrg.set(cOrg);
}
onMount(async () => {
// check if user has session, if not redirect to sign up with redirect back to this page
const {
@@ -118,8 +111,11 @@
return goto(`/login?redirect=${page.url?.pathname || ''}`);
}
if (!data.currentOrg) return;
setTheme(data.currentOrg?.theme || '');
setCurOrg(data.currentOrg as CurrentOrg);
currentOrg.set(data.currentOrg);
});
</script>
@@ -127,15 +123,7 @@
<title>Join {data.name} on ClassroomIO</title>
</svelte:head>
<AuthUI
{supabase}
isLogin={false}
{handleSubmit}
isLoading={loading || !$profile.id}
showOnlyContent={true}
showLogo={true}
bind:formRef
>
<AuthUI isLogin={false} {handleSubmit} isLoading={loading || !$profile.id} showOnlyContent={true} showLogo={true}>
<div class="mt-0 w-full">
<h3 class="mb-4 mt-0 text-center text-lg font-medium dark:text-white">{data.name}</h3>
<p class="text-center text-sm font-light dark:text-white">{data.description}</p>
@@ -1,11 +1,6 @@
import { redirect } from '@sveltejs/kit';
import { getCurrentOrg } from '$lib/utils/services/org';
import { getSupabase, supabase } from '$lib/utils/functions/supabase';
import { OrgApiServer } from '$lib/features/org/api/org.server';
import { getProfile } from '$lib/utils/functions/user';
if (!supabase) {
getSupabase();
}
import { redirect } from '@sveltejs/kit';
// we need to know if the email exists or not.
// with this we can only ask the user to accept
@@ -16,7 +11,7 @@ export const load = async ({ params = { hash: '' } }) => {
const { orgId, email, orgSiteName } = JSON.parse(hashData);
const currentOrg = await getCurrentOrg(orgSiteName, true);
const currentOrg = await OrgApiServer.getOrgBySiteName(orgSiteName);
const profile = await getProfile(email);
console.log('profile data', profile);
@@ -1,6 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import AuthUI from '$lib/components/AuthUI/index.svelte';
import { AuthUI } from '$lib/features/ui';
import type { Profile } from '$lib/components/Course/components/People/types';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
@@ -13,7 +13,6 @@
import { profile, user } from '$lib/utils/store/user';
import { authValidation, getConfirmPasswordError, getDisableSubmit } from '$lib/utils/functions/validator';
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import type { CurrentOrg } from '$lib/utils/types/org';
import { onMount, untrack } from 'svelte';
import { snackbar } from '$lib/components/Snackbar/store.js';
@@ -32,12 +31,11 @@
} = $state({});
let submitError: string = $state('');
let formRef: HTMLFormElement | undefined = $state();
const confirmPasswordError = $derived(getConfirmPasswordError(fields));
const disableSubmit = $derived(getDisableSubmit(fields));
async function joinOrg(profileId: string, email: string) {
async function joinOrg(profileId: string, email: string | null) {
if (!profileId || !email || !data.invite.currentOrg?.id) return;
// Update member response
@@ -51,8 +49,6 @@
console.log('Update member response', updateMemberRes);
formRef?.reset();
window.location.href = $currentOrgPath;
}
@@ -146,17 +142,12 @@
}
}
function setCurOrg(cOrg: CurrentOrg) {
if (!cOrg) return;
console.log(cOrg);
currentOrg.set(cOrg);
}
onMount(async () => {
if (!data.invite.currentOrg) return;
setTheme(data.invite.currentOrg?.theme || '');
setCurOrg(data.invite.currentOrg as CurrentOrg);
currentOrg.set(data.invite.currentOrg);
});
const isLoading = $derived(loading || $user.fetchingUser);
@@ -179,15 +170,7 @@
<title>Join ClassroomIO</title>
</svelte:head>
<AuthUI
{supabase}
redirectPathname={page.url.pathname}
isLogin={false}
{handleSubmit}
{isLoading}
showLogo={true}
bind:formRef
>
<AuthUI redirectPathname={page.url.pathname} isLogin={false} {handleSubmit} {isLoading} showLogo={true}>
<div class="mt-4 w-full {shouldLogout ? 'hidden' : ''}">
<p class="mb-6 text-lg font-semibold dark:text-white">
{#if data.invite.profile}
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import VisitOrgSiteButton from '$lib/components/Buttons/VisitOrgSite.svelte';
import { courseMetaDeta, courses } from '$lib/components/Courses/store';
import { courseMetaDeta, courses } from '$lib/features/course/utils/store';
import Learning from '$lib/components/LMS/components/Learning.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
@@ -1,6 +1,6 @@
<script>
import { goto } from '$app/navigation';
import Community from '$lib/components/Org/Community/index.svelte';
import { CommunityPage } from '$lib/features/community/pages';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { t } from '$lib/utils/functions/translations';
@@ -20,6 +20,6 @@
<PrimaryButton label={$t('community.ask_button')} onClick={askCommunity} />
</div>
<Community isLMS={true} />
<CommunityPage isLMS={true} />
</div>
</section>
@@ -10,7 +10,7 @@
import type { Course } from '$lib/utils/types';
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 { snackbar } from '$lib/components/Snackbar/store';
@@ -25,8 +25,8 @@
import TextField from '$lib/components/Form/TextField.svelte';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import DeleteModal from '$lib/components/Org/Community/DeleteModal.svelte';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import { CommunityDeleteModal } from '$lib/features/community/components';
interface Comment {
id: string;
@@ -203,7 +203,7 @@
id: _c.id,
authorId: $profile.id || '',
name: $profile?.fullname || '',
avatar: $profile?.avatar_url || '',
avatar: $profile?.avatarUrl || '',
votes: 0,
comment: _c.body,
createdAt: calDateDiff(_c.created_at)
@@ -370,7 +370,7 @@
<title>{question?.title || 'Question'}</title>
</svelte:head>
<DeleteModal
<CommunityDeleteModal
bind:open={deleteQuestion.shouldDelete}
isDeleting={deleteQuestion.isDeleting}
onCancel={() => {
@@ -381,15 +381,6 @@
isQuestion={true}
/>
<DeleteModal
bind:open={deleteComment.shouldDelete}
isDeleting={deleteComment.isDeleting}
onCancel={() => {
deleteComment.shouldDelete = false;
deleteComment.commentId = '';
}}
onDelete={() => handleDelete(false)}
/>
<section class="mx-auto max-w-3xl md:mx-10 lg:mb-20">
{#if !question}
<div class="mb-3 px-5 py-10">
@@ -479,7 +470,7 @@
/>
</div>
{:else}
<section class="prose prose-sm p-2 sm:prose">
<section class="prose prose-sm sm:prose p-2">
{@html question.body}
</section>
{/if}
@@ -522,7 +513,7 @@
</IconButton>
{/if}
</header>
<article class="prose prose-sm p-2 sm:prose">
<article class="prose prose-sm sm:prose p-2">
{@html comment.comment}
</article>
</div>
@@ -7,7 +7,7 @@
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { courses } from '$lib/components/Courses/store';
import { courses } from '$lib/features/course/utils/store';
import { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$lib/components/Snackbar/store';
import { fetchCourses } from '$lib/utils/services/courses';
@@ -10,8 +10,8 @@
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import Courses from '$lib/components/Courses/index.svelte';
import { courseMetaDeta } from '$lib/components/Courses/store';
import { CoursesPage } from '$lib/features/course/pages';
import { courseMetaDeta } from '$lib/features/course/utils/store';
import { fetchExploreCourses } from '$lib/utils/services/courses';
import { IconButton } from '$lib/components/IconButton';
@@ -125,7 +125,7 @@
{/if}
</div>
</div>
<Courses
<CoursesPage
courses={filteredExploreCourses}
emptyTitle={$t('explore.empty_heading')}
emptyDescription={$t('explore.empty_description')}
@@ -4,14 +4,14 @@
import SearchIcon from '@lucide/svelte/icons/search';
import Tabs from '$lib/components/Tabs/index.svelte';
import Courses from '$lib/components/Courses/index.svelte';
import { CoursesPage } from '$lib/features/course/pages';
import { fetchCourses } from '$lib/utils/services/courses';
import TabContent from '$lib/components/TabContent/index.svelte';
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { courses, courseMetaDeta, coursesComplete, coursesInProgress } from '$lib/components/Courses/store';
import { courses, courseMetaDeta, coursesComplete, coursesInProgress } from '$lib/features/course/utils/store';
let hasFetched = false;
let searchValue = $state('');
@@ -73,14 +73,14 @@
{#snippet content()}
<slot:fragment>
<TabContent value={tabs[0].value} index={currentTab}>
<Courses
<CoursesPage
courses={$coursesInProgress}
emptyTitle={$t('my_learning.not_in_progress')}
emptyDescription={$t('my_learning.any_progress')}
/>
</TabContent>
<TabContent value={tabs[1].value} index={currentTab}>
<Courses
<CoursesPage
courses={$coursesComplete}
emptyTitle={$t('my_learning.not_completed')}
emptyDescription={$t('my_learning.any_course')}
@@ -35,7 +35,7 @@
<Sidebar.Inset>
<AppHeader />
<div class="container mx-auto flex max-w-6xl flex-1 flex-col gap-4 p-4 pt-0">
<div class="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-4 overflow-y-auto px-4">
{#if data.orgName === '*'}
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
<Skeleton class="aspect-video rounded-xl" />
+127 -152
View File
@@ -4,14 +4,10 @@
import { Skeleton } from '@cio/ui/base/skeleton';
import UserIcon from '@lucide/svelte/icons/user';
import UsersIcon from '@lucide/svelte/icons/users';
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
import DollarSignIcon from '@lucide/svelte/icons/dollar-sign';
import { profile } from '$lib/utils/store/user';
import PlusIcon from '@lucide/svelte/icons/plus';
import { isOrgAdmin } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { isMobile } from '$lib/utils/store/useMobile';
import { calDateDiff } from '$lib/utils/functions/date';
import { getGreeting } from '$lib/utils/functions/date';
import { snackbar } from '$lib/components/Snackbar/store';
@@ -19,22 +15,19 @@
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import type { OrganisationAnalytics } from '$lib/utils/types/analytics';
import { CreateCourseButton } from '$lib/features/course/components';
import Avatar from '$lib/components/Avatar/index.svelte';
import { ActivityCard } from '$lib/components/Analytics';
import Progress from '$lib/components/Progress/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { Grid } from '$lib/components/Org/Settings/Layout';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { Button } from '@cio/ui/base/button';
import { WelcomeModal } from '$lib/features/onboarding/components';
import VisitOrgSiteButton from '$lib/components/Buttons/VisitOrgSite.svelte';
import * as Page from '@cio/ui/base/page';
import { Empty } from '@cio/ui/custom/empty';
let dashAnalytics: OrganisationAnalytics | undefined = $state();
let hasFetched = $state(false);
function createCourse() {
goto(`${$currentOrgPath}/courses?create=true`);
}
async function fetchDashAnalytics(orgId: string) {
if (!orgId || hasFetched) return;
@@ -98,153 +91,135 @@
<WelcomeModal />
<div class="w-full">
<div class="mb-5 flex items-center justify-between">
<h1 class="m-0 text-xl font-medium dark:text-white">
{$t(getGreeting())}
{$profile.fullname}!
</h1>
<div class="flex items-center">
<PrimaryButton
variant={VARIANTS.OUTLINED}
onClick={createCourse}
isDisabled={!$isOrgAdmin}
className="min-h-[36px]"
>
{#if $isMobile}
<PlusIcon size={16} />
{:else}
{$t('dashboard.create_course')}
{/if}
</PrimaryButton>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{$t(getGreeting())}
{$profile.fullname}!
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<CreateCourseButton variant="outline" isResponsive />
<VisitOrgSiteButton />
</div>
</div>
<div class="mb-10 flex flex-wrap items-start">
<Grid class="px-0">
<div class="mt-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each cards as card}
{#if !dashAnalytics}
<Skeleton style="width: 100%; min-width: 300px; height: 10rem;" class="rounded-md" />
{:else}
<ActivityCard activity={card} />
{/if}
{/each}
</div>
</Grid>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:border-neutral-600 dark:text-white">
<h3 class="mt-0 text-2xl">
{$t('dashboard.top_courses')}
</h3>
<div class="h-full space-y-6">
{#if !dashAnalytics}
{#each Array(5) as _}
<Skeleton class="h-10 w-full rounded-md" />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<div class="mb-10 flex flex-wrap items-start">
<div class="grid w-full grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each cards as card}
{#if !dashAnalytics}
<Skeleton style="width: 100%; height: 10rem;" class="rounded-md" />
{:else}
<ActivityCard activity={card} />
{/if}
{/each}
{:else}
{#each dashAnalytics.topCourses as course}
<div class="flex items-center gap-2">
<div class="w-4/6 space-y-1">
<a href={`/courses/${course.id}`}>
<p class="line-clamp-2 pb-[0.1rem] text-sm font-medium leading-none">
{course.title}
</p>
</a>
<p class="text-sm">
{course.enrollments}
{$t(course.enrollments === 1 ? 'dashboard.student' : 'dashboard.students')}
</p>
</div>
<div class="ml-auto w-2/6">
<Progress value={course.completion} />
<div class="text-sm font-medium">
{course.completion}%
{$t('dashboard.completion')}
</div>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div
class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:border-neutral-600 dark:text-white"
>
<h3 class="mt-0 text-lg">
{$t('dashboard.top_courses')}
</h3>
<div class="h-full space-y-6">
{#if !dashAnalytics}
{#each Array(5) as _}
<Skeleton class="h-10 w-full rounded-md" />
{/each}
{:else}
{#each dashAnalytics.topCourses as course}
<div class="flex items-center gap-2">
<div class="w-4/6 space-y-1">
<a href={`/courses/${course.id}`}>
<p class="line-clamp-2 pb-[0.1rem] text-sm font-medium leading-none">
{course.title}
</p>
</a>
<p class="text-sm">
{course.enrollments}
{$t(course.enrollments === 1 ? 'dashboard.student' : 'dashboard.students')}
</p>
</div>
<div class="ml-auto w-2/6">
<Progress value={course.completion} />
<div class="text-sm font-medium">
{course.completion}%
{$t('dashboard.completion')}
</div>
</div>
</div>
</div>
</div>
{:else}
<div class="flex flex-col h-full items-center justify-center p-3">
<div class="bg-primary-200 w-fit rounded-full p-4 text-black">
<LibraryBigIcon size={30} />
</div>
<div class="my-4 text-center">
<p class=" text-xl font-semibold">
{$t('dashboard.create_first_course')}
</p>
<p class="text-sm text-gray-500 dark:text-gray-300">
{$t('dashboard.create_first_course_description')}
</p>
</div>
<PrimaryButton variant={VARIANTS.OUTLINED} onClick={createCourse} label={$t('dashboard.create_course')} />
</div>
{/each}
{/if}
</div>
</div>
{:else}
<Empty
title={$t('dashboard.create_first_course')}
description={$t('dashboard.create_first_course_description')}
icon={BookIcon}
class="h-full"
>
<CreateCourseButton variant="outline" />
</Empty>
{/each}
{/if}
</div>
</div>
<div class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:border-neutral-600 dark:text-white">
<h3 class="mt-0 text-2xl">
{$t('dashboard.recent_enrollments')}
</h3>
<div
class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:border-neutral-600 dark:text-white"
>
<h3 class="mt-0 text-lg">
{$t('dashboard.recent_enrollments')}
</h3>
<div class="h-full space-y-6">
{#if !dashAnalytics}
{#each Array(5) as _}
<Skeleton class="h-10 w-full rounded-md" />
{/each}
{:else}
{#each dashAnalytics.enrollments as enrollment}
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Avatar src={enrollment.avatarUrl} name={enrollment.name} width="w-6" height="h-6" />
<div class="h-full space-y-6">
{#if !dashAnalytics}
{#each Array(5) as _}
<Skeleton class="h-10 w-full rounded-md" />
{/each}
{:else}
{#each dashAnalytics.enrollments as enrollment}
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Avatar src={enrollment.avatarUrl} name={enrollment.name} width="w-6" height="h-6" />
<div class="min-h-[45px] space-y-1">
<p class="text-sm font-medium capitalize leading-none">{enrollment.name}</p>
<p class="text-muted-foreground text-sm">
<span class="italic">
{calDateDiff(enrollment.date)}
</span>
</p>
<div class="min-h-[45px] space-y-1">
<p class="text-sm font-medium capitalize leading-none">{enrollment.name}</p>
<p class="text-muted-foreground text-sm">
<span class="italic">
{calDateDiff(enrollment.date)}
</span>
</p>
</div>
</div>
<div class="w-2/4">
<a href={`/courses/${enrollment.courseId}`}>
<p class="line-clamp-2 pb-[0.1rem] text-sm font-medium leading-none">
{enrollment.course}
</p>
</a>
</div>
</div>
</div>
<div class="w-2/4">
<a href={`/courses/${enrollment.courseId}`}>
<p class="line-clamp-2 pb-[0.1rem] text-sm font-medium leading-none">
{enrollment.course}
</p>
</a>
</div>
</div>
{:else}
<div class="flex flex-col h-full items-center justify-center p-3">
<div class="bg-primary-200 w-fit rounded-full p-4 text-black">
<UserIcon size={16} />
</div>
<div class="my-4 text-center">
<p class=" text-xl font-semibold">
{$t('dashboard.publish_first_course')}
</p>
<p class="text-sm text-gray-500 dark:text-gray-300">
{$t('dashboard.publish_first_course_description')}
</p>
</div>
<PrimaryButton
variant={VARIANTS.OUTLINED}
onClick={() => goto(`${$currentOrgPath}/courses`)}
label={$t('dashboard.publish_course')}
/>
</div>
{/each}
{/if}
{:else}
<Empty
title={$t('dashboard.publish_first_course')}
description={$t('dashboard.publish_first_course_description')}
icon={UserIcon}
class="h-full"
>
<Button variant="outline" onclick={() => goto(`${$currentOrgPath}/courses`)}>
{$t('dashboard.publish_course')}
</Button>
</Empty>
{/each}
{/if}
</div>
</div>
</div>
</div>
</div>
</div>
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,10 +1,11 @@
<script>
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import Audience from '$lib/components/Org/Audience/index.svelte';
import { AudiencePage } from '$lib/features/audience/pages';
import { t } from '$lib/utils/functions/translations';
import { orgAudience, currentOrgPlan, currentOrgMaxAudience } from '$lib/utils/store/org';
import { currentOrgPlan, currentOrgMaxAudience } from '$lib/utils/store/org';
import { PLAN } from '@cio/utils/plans';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import * as Page from '@cio/ui/base/page';
import { orgApi } from '$lib/features/org/api/org.svelte';
let isLoading = $state(false);
@@ -19,23 +20,25 @@
<title>Audience - ClassroomIO</title>
</svelte:head>
<section class="mx-auto w-full max-w-6xl">
<div class="px-5 py-10">
<div class="mb-10 flex items-center justify-between">
<div class="flex items-center">
<h1 class="m-0 text-2xl md:text-3xl dark:text-white">{$t('audience.title')}</h1>
<Page.Root class="mx-auto w-full max-w-6xl">
<Page.Header>
<Page.HeaderContent>
<Page.Title>
{$t('audience.title')}
{#if $currentOrgPlan?.planName !== PLAN.ENTERPRISE}
<span class="ml-2">
({$orgAudience.length} / {$currentOrgMaxAudience})
<span class="ml-2 text-sm">
({orgApi.audience.length} / {$currentOrgMaxAudience})
</span>
{/if}
</div>
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<PrimaryButton label={$t('audience.export')} onClick={exportAudience} isDisabled={isLoading} {isLoading} />
</div>
{#if $orgAudience.length >= $currentOrgMaxAudience}
<UpgradeBanner>{$t('audience.upgrade')}</UpgradeBanner>
{/if}
<Audience />
</div>
</section>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<AudiencePage />
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,208 +1,18 @@
<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 { Grid } from '$lib/components/Org/Settings/Layout';
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';
import { UserAnalyticsPage } from '$lib/features/audience/pages';
import * as Page from '@cio/ui/base/page';
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}
<section class="w-full md:mx-auto md:max-w-5xl">
<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>
<svelte:head>
<title>User Analytics - ClassroomIO</title>
</svelte:head>
<div class="px-5 py-1">
<HeroProfileCard user={userAnalytics.user} />
<Grid 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>
</Grid>
<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>
</section>
{:else}
<LoadingPage />
{/if}
<Page.Root class="w-full md:max-w-5xl lg:mx-auto">
<Page.Body>
{#snippet child()}
<UserAnalyticsPage {data} />
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,32 +1,26 @@
<script>
import { goto } from '$app/navigation';
import Community from '$lib/components/Org/Community/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { currentOrgPath } from '$lib/utils/store/org';
import { isMobile } from '$lib/utils/store/useMobile';
import PlusIcon from '@lucide/svelte/icons/plus';
import { CommunityPage } from '$lib/features/community/pages';
import { AskCommunityButton } from '$lib/features/community/components';
import { t } from '$lib/utils/functions/translations';
function askCommunity() {
goto(`${$currentOrgPath}/community/ask`);
}
import * as Page from '@cio/ui/base/page';
</script>
<svelte:head>
<title>Community - ClassroomIO</title>
</svelte:head>
<section class="w-full">
<div class="mb-10 flex w-full items-center justify-between">
<h1 class="text-2xl md:text-3xl dark:text-white">{$t('community.title')}</h1>
{#if $isMobile}
<PrimaryButton onClick={askCommunity}>
<PlusIcon size={16} />
</PrimaryButton>
{:else}
<PrimaryButton label={$t('community.ask_button')} variant={VARIANTS.CONTAINED_DARK} onClick={askCommunity} />
{/if}
</div>
<Community />
</section>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('community.title')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<AskCommunityButton />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<CommunityPage />
{/snippet}
</Page.Body>
</Page.Root>
@@ -8,7 +8,7 @@
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
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 { snackbar } from '$lib/components/Snackbar/store';
@@ -25,8 +25,9 @@
import TextField from '$lib/components/Form/TextField.svelte';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import DeleteModal from '$lib/components/Org/Community/DeleteModal.svelte';
import { CommunityDeleteModal } from '$lib/features/community/components';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import * as Page from '@cio/ui/base/page';
interface Comment {
id: string;
@@ -377,18 +378,7 @@
<title>{question?.title || $t('community.ask.question')}</title>
</svelte:head>
<DeleteModal
bind:open={deleteQuestion.shouldDelete}
isDeleting={deleteQuestion.isDeleting}
onCancel={() => {
deleteQuestion.shouldDelete = false;
deleteQuestion.questionId = '';
}}
onDelete={() => handleDelete(true)}
isQuestion={true}
/>
<DeleteModal
<CommunityDeleteModal
bind:open={deleteComment.shouldDelete}
isDeleting={deleteComment.isDeleting}
onCancel={() => {
@@ -397,44 +387,50 @@
}}
onDelete={() => handleDelete(false)}
/>
<section class="mx-auto max-w-3xl md:mx-10 lg:mb-20">
{#if !question}
<div class="mb-3 px-5 py-10">
<Skeleton class="h-4 w-[25%]" />
<Skeleton class="mb-2 h-4 w-full" />
<Skeleton class="h-80 w-full" />
</div>
{:else}
{#if !question}
<div class="mb-3 px-5 py-10">
<Skeleton class="h-4 w-[25%]" />
<Skeleton class="mb-2 h-4 w-full" />
<Skeleton class="h-80 w-full" />
</div>
{:else}
<Page.Root class="mx-auto max-w-3xl md:mx-10 lg:mb-20">
<div class="px-5 py-10">
<a class="text-md flex items-center text-gray-500 dark:text-white" href={`${$currentOrgPath}/community`}>
<ArrowLeftIcon size={16} />
{$t('community.ask.go_back')}
</a>
<div class="my-5 flex items-center justify-between">
</div>
<Page.Header>
<Page.HeaderContent>
{#if isEditMode}
<TextField bind:value={editContent.title} className="w-full mr-2" errorMessage={errors.title} />
<Select.Root type="single" bind:value={editContent.courseId}>
<Select.Trigger class="h-full w-[25%]">
<p>
{editContent.courseId
? fetchedCourses.find((course) => course.id === editContent.courseId)?.title
: $t('community.ask.select_course')}
</p>
</Select.Trigger>
<Select.Content>
{#each fetchedCourses as course}
<Select.Item value={course.id || ''}>{course.title}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<div class="flex w-full items-center gap-2">
<TextField bind:value={editContent.title} className="flex-1" errorMessage={errors.title} />
<Select.Root type="single" bind:value={editContent.courseId}>
<Select.Trigger class="h-full w-[25%]">
<p>
{editContent.courseId
? fetchedCourses.find((course) => course.id === editContent.courseId)?.title
: $t('community.ask.select_course')}
</p>
</Select.Trigger>
<Select.Content>
{#each fetchedCourses as course}
<Select.Item value={course.id || ''}>{course.title}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
{:else}
<div class="flex items-center">
<div class="flex items-center gap-2">
<Vote value={question.votes} upVote={() => upvoteQuestion('question')} disabled={voted.question} />
<h2 class="text-3xl">{question.title}</h2>
<Page.Title>{question.title}</Page.Title>
</div>
{/if}
{#if question.author.id === $profile.id}
</Page.HeaderContent>
{#if question.author.id === $profile.id}
<Page.Action>
<PrimaryButton
label={isEditMode ? $t('community.ask.save') : $t('community.ask.edit')}
variant={VARIANTS.OUTLINED}
@@ -450,101 +446,105 @@
disablePadding={true}
/>
{/if}
{/if}
</div>
<div class="border-gray my-1 rounded-lg border px-1">
<header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black no-underline hover:underline">
<Avatar src={question.author.avatar} name={question.author.name} width="w-7" height="h-7" />
<p class="ml-2 text-sm dark:text-white">{question.author.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white">
{question.createdAt}
</p>
</div>
{#if question.author.id === $profile.id || $isOrgAdmin}
<IconButton
value="delete-question"
onClick={() => {
if (!question) return;
</Page.Action>
{/if}
</Page.Header>
<Page.Body>
{#snippet child()}
<div class="border-gray my-1 rounded-lg border px-1">
<header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black no-underline hover:underline">
<Avatar src={question.author.avatar} name={question.author.name} width="w-7" height="h-7" />
<p class="ml-2 text-sm dark:text-white">{question.author.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white">
{question.createdAt}
</p>
</div>
{#if question.author.id === $profile.id || $isOrgAdmin}
<IconButton
value="delete-question"
onClick={() => {
if (!question) return;
deleteQuestion.shouldDelete = true;
deleteQuestion.questionId = question.id;
}}
>
<TrashIcon size={16} />
</IconButton>
deleteQuestion.shouldDelete = true;
deleteQuestion.questionId = question.id;
}}
>
<TrashIcon size={16} />
</IconButton>
{/if}
</header>
{#if isEditMode && editorInstance}
<div class="my-2">
<TextEditor
placeholder="Give an answer"
content={editContent.body}
onChange={(content) => (editContent.body = content)}
/>
</div>
{:else}
<section class="prose prose-sm sm:prose p-2">
{@html question.body}
</section>
{/if}
</header>
{#if isEditMode && editorInstance}
<div class="my-2">
<TextEditor
placeholder="Give an answer"
content={editContent.body}
onChange={(content) => (editContent.body = content)}
</div>
<div class="my-8">
{pluralize($t('community.answers'), question.totalComments, true)}
</div>
{#each question.comments as comment}
<div class="my-5 flex items-start px-1">
<Vote
value={comment.votes}
upVote={() => upvoteQuestion('comment', comment.id)}
disabled={voted.comment[comment.id]}
/>
<div class="border-gray w-full rounded-lg border">
<header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black">
<Avatar src={comment.avatar} name={comment.name} width="w-7" height="h-7" />
<p class="ml-2 text-sm dark:text-white">{comment.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white">
{comment.createdAt}
</p>
</div>
{#if isValidAnswer}
<CircleCheckIcon size={16} />
{/if}
{#if comment.authorId === $profile.id || $isOrgAdmin}
<IconButton
value="delete-comment"
onClick={() => {
deleteComment.shouldDelete = true;
deleteComment.commentId = comment.id;
}}
>
<TrashIcon size={16} />
</IconButton>
{/if}
</header>
<article class="prose prose-sm sm:prose p-2">
{@html comment.comment}
</article>
</div>
</div>
{:else}
<section class="prose prose-sm p-2 sm:prose">
{@html question.body}
</section>
{/if}
</div>
{/each}
<div class="my-8">
{pluralize($t('community.answers'), question.totalComments, true)}
</div>
<hr />
{#each question.comments as comment}
<div class="my-5 flex items-start px-1">
<Vote
value={comment.votes}
upVote={() => upvoteQuestion('comment', comment.id)}
disabled={voted.comment[comment.id]}
/>
<div class="border-gray w-full rounded-lg border">
<header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black">
<Avatar src={comment.avatar} name={comment.name} width="w-7" height="h-7" />
<p class="ml-2 text-sm dark:text-white">{comment.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white">
{comment.createdAt}
</p>
</div>
<div class="mt-4">
{#if !editorInstance}
<TextEditor placeholder="Give an answer" content={comment} onChange={(content) => (comment = content)} />
{/if}
{#if isValidAnswer}
<CircleCheckIcon size={16} />
{/if}
{#if comment.authorId === $profile.id || $isOrgAdmin}
<IconButton
value="delete-comment"
onClick={() => {
deleteComment.shouldDelete = true;
deleteComment.commentId = comment.id;
}}
>
<TrashIcon size={16} />
</IconButton>
{/if}
</header>
<article class="prose prose-sm p-2 sm:prose">
{@html comment.comment}
</article>
<div class="mt-2 flex justify-end">
<PrimaryButton label={$t('community.ask.comment')} onClick={submitComment} />
</div>
</div>
{/each}
<hr />
<div class="mt-4">
{#if !editorInstance}
<TextEditor placeholder="Give an answer" content={comment} onChange={(content) => (comment = content)} />
{/if}
<div class="mt-2 flex justify-end">
<PrimaryButton label={$t('community.ask.comment')} onClick={submitComment} />
</div>
</div>
</div>
{/if}
</section>
{/snippet}
</Page.Body>
</Page.Root>
{/if}
@@ -1,12 +1,11 @@
<script lang="ts">
import { goto } from '$app/navigation';
import * as Select from '@cio/ui/base/select';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import type { Course } from '$lib/utils/types';
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 { supabase } from '$lib/utils/functions/supabase';
import { snackbar } from '$lib/components/Snackbar/store';
import { fetchCourses } from '$lib/utils/services/courses';
@@ -18,6 +17,7 @@
import TextField from '$lib/components/Form/TextField.svelte';
import TextEditor from '$lib/components/TextEditor/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import * as Page from '@cio/ui/base/page';
let errors: {
title?: string;
@@ -93,52 +93,59 @@
<title>Ask the Community - ClassroomIO</title>
</svelte:head>
<section class="w-full md:mx-auto md:max-w-3xl">
<div class="p-5">
<a class="text-md flex items-center text-gray-500 dark:text-white" href={`${$currentOrgPath}/community`}>
<ArrowLeftIcon size={16} />
{$t('community.ask.go_back')}
</a>
<div class="flex items-center justify-between gap-12">
<h1 class="text-2xl md:text-3xl dark:text-white">{$t('community.ask.ask_the')}</h1>
<Page.Root>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('community.ask.ask_the')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<PrimaryButton label={$t('community.ask.publish')} variant={VARIANTS.CONTAINED_DARK} onClick={handleSave} />
</div>
</div>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<div class="mb-3 flex justify-between gap-x-5">
<TextField
bind:value={fields.title}
placeholder={$t('community.ask.title')}
errorMessage={errors.title}
className="w-[75%]"
/>
<div class="w-[25%]">
<Select.Root type="single" bind:value={fields.courseId}>
<Select.Trigger class="h-full w-full">
<p>
{fields.courseId
? fetchedCourses.find((course) => course.id === fields.courseId)?.title
: $t('community.ask.select_course')}
</p>
</Select.Trigger>
<Select.Content>
{#each fetchedCourses as course}
{#if course.id}
<Select.Item value={course.id}>{course.title}</Select.Item>
{/if}
{/each}
</Select.Content>
</Select.Root>
{#if errors.courseId}
<p class="mt-1 text-sm text-red-500">
{errors.courseId}
</p>
{/if}
</div>
</div>
<div class="px-2">
<TextEditor
placeholder="Give an answer"
content={fields.body}
onChange={(content) => (fields.body = content)}
/>
<div class="mb-3 flex justify-between gap-x-5 p-2">
<TextField
bind:value={fields.title}
placeholder={$t('community.ask.title')}
errorMessage={errors.title}
className="w-[75%]"
/>
<div class="w-[25%]">
<Select.Root type="single" bind:value={fields.courseId}>
<Select.Trigger class="h-full w-full">
<p>
{fields.courseId
? fetchedCourses.find((course) => course.id === fields.courseId)?.title
: $t('community.ask.select_course')}
</p>
</Select.Trigger>
<Select.Content>
{#each fetchedCourses as course}
<Select.Item value={course.id}>{course.title}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{#if errors.courseId}
<p class="mt-1 text-sm text-red-500">
{errors.courseId}
</p>
{/if}
</div>
</div>
<div class="px-2">
<TextEditor placeholder="Give an answer" content={fields.body} onChange={(content) => (fields.body = content)} />
{#if errors.body}
<p class="mt-2 text-sm text-red-500">{errors.body}</p>
{/if}
</div>
</section>
{#if errors.body}
<p class="mt-2 text-sm text-red-500">{errors.body}</p>
{/if}
</div>
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,27 +1,15 @@
<script lang="ts">
import { Input } from '@cio/ui/base/input';
import * as Select from '@cio/ui/base/select';
import SearchIcon from '@lucide/svelte/icons/search';
import { profile } from '$lib/utils/store/user';
import { fetchCourses } from '$lib/utils/services/courses';
import Courses from '$lib/components/Courses/index.svelte';
import NewCourseModal from '$lib/components/Courses/components/NewCourseModal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { courses, courseMetaDeta } from '$lib/components/Courses/store';
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import PlusIcon from '@lucide/svelte/icons/plus';
import { isMobile } from '$lib/utils/store/useMobile';
import { isOrgAdmin } from '$lib/utils/store/org';
import { CoursesPage } from '$lib/features/course/pages';
import { CreateCourseButton } from '$lib/features/course/components';
import { courses, courseMetaDeta } from '$lib/features/course/utils/store';
import { currentOrg } from '$lib/utils/store/org';
import type { Course } from '$lib/utils/types';
import { browser } from '$app/environment';
import { t } from '$lib/utils/functions/translations';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import { IconButton } from '$lib/components/IconButton';
import GridIcon from '@lucide/svelte/icons/grid-2x2';
import ListIcon from '@lucide/svelte/icons/list';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import * as Page from '@cio/ui/base/page';
let { data } = $props();
@@ -30,16 +18,6 @@
let selectedId: string = $state('0');
let hasFetched = false;
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 filteredCourses: Course[] = $derived(filterCourses(searchValue, selectedId, $courses));
async function getCourses(userId: string | undefined, orgId: string) {
@@ -89,15 +67,6 @@
return filteredCourses;
}
const setViewPreference = (preference: 'grid' | 'list') => {
$courseMetaDeta.view = preference;
localStorage.setItem('courseView', preference);
};
const openNewCourseModal = () => {
goto($currentOrgPath + '/courses?create=true');
};
onMount(() => {
const courseView = localStorage.getItem('courseView') as 'grid' | 'list' | null;
@@ -115,51 +84,18 @@
<title>Courses - ClassroomIO</title>
</svelte:head>
<section class="w-full">
<div class="mb-5 flex items-center justify-between">
<h1 class="text-2xl md:text-3xl dark:text-white">{$t('courses.heading')}</h1>
{#if $isMobile}
<PrimaryButton isDisabled={!$isOrgAdmin} onClick={openNewCourseModal}>
<PlusIcon size={16} />
</PrimaryButton>
{:else}
<PrimaryButton
label={$t('courses.heading_button')}
variant={VARIANTS.CONTAINED_DARK}
isDisabled={!$isOrgAdmin}
onClick={openNewCourseModal}
/>
{/if}
</div>
<div class="mb-5 flex flex-row-reverse">
<div class="filter-containter flex items-end justify-start gap-2">
<div class="relative">
<Input type="text" placeholder={$t('courses.search_placeholder')} bind:value={searchValue} class="pl-9" />
<SearchIcon class="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2" />
</div>
<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}
</div>
</div>
<NewCourseModal />
<Courses courses={filteredCourses} />
</section>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('courses.heading')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<CreateCourseButton isResponsive />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<CoursesPage courses={filteredCourses} bind:searchValue bind:selectedId />
{/snippet}
</Page.Body>
</Page.Root>
@@ -0,0 +1,12 @@
<script lang="ts">
interface Props {
children?: import('svelte').Snippet;
}
let { children }: Props = $props();
import * as Page from '@cio/ui/base/page';
</script>
<Page.Root class="w-full pb-10 md:max-w-3xl lg:mx-auto">
{@render children?.()}
</Page.Root>
@@ -1,21 +1,40 @@
<script>
import OrgSettings from '$lib/components/Org/Settings/index.svelte';
<script lang="ts">
import { ProfilePage } from '$lib/features/settings/pages';
import ViewSiteBtn from '$lib/components/Buttons/VisitOrgSite.svelte';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import * as Page from '@cio/ui/base/page';
let profileComponent: ProfilePage | null = $state(null);
let isLoading = $state(false);
async function handleUpdate() {
isLoading = true;
try {
await profileComponent?.handleUpdate();
} finally {
isLoading = false;
}
}
</script>
<svelte:head>
<title>Settings - ClassroomIO</title>
</svelte:head>
<section class="w-full md:max-w-6xl lg:mx-auto">
<div class="px-5 md:py-10">
<div class="mb-10 flex items-center justify-between">
<h1 class="text-3xl dark:text-white">{$t('settings.heading')}</h1>
<ViewSiteBtn />
</div>
<OrgSettings />
</div>
</section>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('settings.heading')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button variant="default" loading={isLoading} onclick={handleUpdate}>
{$t('settings.profile.update_profile')}
</Button>
<ViewSiteBtn />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<ProfilePage bind:this={profileComponent} />
{/snippet}
</Page.Body>
@@ -0,0 +1,20 @@
<script>
import { BillingPage } from '$lib/features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
<svelte:head>
<title>Billing - ClassroomIO</title>
</svelte:head>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('settings.billing.sub_title')}</Page.Title>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<BillingPage />
{/snippet}
</Page.Body>
@@ -1,24 +1,40 @@
<script>
import CustomizeLMS from '$lib/components/Org/Settings/CustomizeLms.svelte';
<script lang="ts">
import { CustomizeLmsPage } from '$lib/features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import { currentOrgPath } from '$lib/utils/store/org';
import { Button } from '@cio/ui/base/button';
import * as Page from '@cio/ui/base/page';
let customizeLmsComponent: CustomizeLmsPage | null = $state(null);
let isSaving = $state(false);
async function handleSave() {
isSaving = true;
try {
await customizeLmsComponent?.handleSave();
} finally {
isSaving = false;
}
}
</script>
<svelte:head>
<title>Customize LMS - ClassroomIO</title>
</svelte:head>
<section class="mx-auto w-full md:max-w-4xl">
<div class="px-3 py-10 md:px-5">
<a class="text-md text-gray-500 dark:text-white" href={`${$currentOrgPath}/settings?tab=org`}
>{$t('upgrade.back')}</a
>
<div class="mb-10 flex items-center justify-between">
<h1 class="text-3xl dark:text-white">
{$t('components.settings.customize_lms.title')}
</h1>
</div>
<CustomizeLMS />
</div>
</section>
<Page.Root class="w-full md:max-w-4xl lg:mx-auto">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('components.settings.customize_lms.title')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button variant="default" loading={isSaving} disabled={isSaving} onclick={handleSave}>
{$t('components.settings.customize_lms.save')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<CustomizeLmsPage bind:this={customizeLmsComponent} />
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,22 +1,20 @@
<script>
import Domains from '$lib/components/Org/Settings/Domains.svelte';
import { DomainsPage } from '$lib/features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import { currentOrgPath } from '$lib/utils/store/org';
import * as Page from '@cio/ui/base/page';
</script>
<svelte:head>
<title>Custom domain - ClassroomIO</title>
</svelte:head>
<section class="mx-auto w-full md:max-w-4xl">
<div class="px-3 py-10 md:px-5">
<a class="text-md text-gray-500 dark:text-white" href={`${$currentOrgPath}/settings?tab=org`}
>{$t('upgrade.back')}</a
>
<div class="mb-10 flex items-center justify-between">
<h1 class="text-3xl dark:text-white">{$t('components.settings.domains.title')}</h1>
</div>
<Domains />
</div>
</section>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('components.settings.domains.title')}</Page.Title>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<DomainsPage />
{/snippet}
</Page.Body>
@@ -0,0 +1,20 @@
<script>
import { IntegrationsPage } from '$lib/features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
<svelte:head>
<title>Integrations - ClassroomIO</title>
</svelte:head>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('settings.integrations.heading')}</Page.Title>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<IntegrationsPage />
{/snippet}
</Page.Body>
@@ -0,0 +1,38 @@
<script lang="ts">
import { LandingpagePage } from '$lib/features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import * as Page from '@cio/ui/base/page';
let landingPageComponent: LandingpagePage | null = $state(null);
let isSaving = $state(false);
async function handleSave() {
isSaving = true;
try {
await landingPageComponent?.handleSave();
} finally {
isSaving = false;
}
}
</script>
<svelte:head>
<title>Landing Page Settings - ClassroomIO</title>
</svelte:head>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('settings.landing_page.heading')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button variant="default" loading={isSaving} disabled={isSaving} onclick={handleSave}>
{$t('settings.landing_page.save_changes')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<LandingpagePage bind:this={landingPageComponent} />
{/snippet}
</Page.Body>
@@ -0,0 +1,38 @@
<script lang="ts">
import { OrgPage } from '$lib/features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import * as Page from '@cio/ui/base/page';
let orgComponent: OrgPage | null = $state(null);
let loading = $state(false);
async function handleUpdate() {
loading = true;
try {
await orgComponent?.handleUpdate();
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>Organization Settings - ClassroomIO</title>
</svelte:head>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('settings.organization.organization_profile.heading')}</Page.Title>
</Page.HeaderContent>
<Page.Action>
<Button variant="default" {loading} disabled={loading} onclick={handleUpdate}>
{$t('settings.organization.organization_profile.update_organization')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<OrgPage bind:this={orgComponent} />
{/snippet}
</Page.Body>
@@ -1,24 +1,20 @@
<script>
import TeamMembers from '$lib/components/Org/Settings/TeamMembers.svelte';
import { currentOrgPath } from '$lib/utils/store/org';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import { TeamsPage } from '$lib/features/settings/pages';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
<svelte:head>
<title>Teams - ClassroomIO</title>
</svelte:head>
<section class="mx-auto w-full md:max-w-4xl">
<div class="px-3 py-10 md:px-5">
<a class="text-md text-gray-500 dark:text-white" href={`${$currentOrgPath}/settings?tab=org`}
>{$t('upgrade.back')}</a
>
<div class="mb-10 flex items-center justify-between">
<h1 class="text-3xl dark:text-white">{$t('upgrade.team_members')}</h1>
</div>
<UpgradeBanner>{$t('upgrade.team')}</UpgradeBanner>
<TeamMembers />
</div>
</section>
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('upgrade.team_members')}</Page.Title>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<TeamsPage />
{/snippet}
</Page.Body>
@@ -1,26 +1,46 @@
<script lang="ts">
import Chip from '$lib/components/Chip/Text.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import { Button } from '@cio/ui/base/button';
import * as Item from '@cio/ui/base/item';
import * as Page from '@cio/ui/base/page';
import BadgeCheckIcon from '@lucide/svelte/icons/badge-check';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import UserPlusIcon from '@lucide/svelte/icons/user-plus';
import UsersIcon from '@lucide/svelte/icons/users';
import GlobeIcon from '@lucide/svelte/icons/globe';
import FileTextIcon from '@lucide/svelte/icons/file-text';
import SetupProgress from '$lib/features/setup/components/setup-progress.svelte';
import { currentOrg } from '$lib/utils/store/org';
import { goto } from '$app/navigation';
import { snackbar } from '$lib/components/Snackbar/store.js';
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
import { setupProgressApi } from '$lib/features/setup/api/setup-progress.svelte';
import { onMount } from 'svelte';
let { data } = $props();
const setupList = $derived(
(data.setup || []).map((item) => {
setupProgressApi.setupList.map((item) => {
if (item.id === 'profile') {
item.is_completed = !$profile.avatarUrl?.includes('avatars/avatar.png');
return {
...item,
is_completed: !$profile.avatarUrl?.includes('avatars/avatar.png')
};
}
return item;
})
);
const completed = $derived(setupList.filter((list) => list.is_completed).length);
const total = $derived(setupList.length);
// Ensure setup progress is fetched when page loads
onMount(() => {
if ($currentOrg.siteName && setupProgressApi.progress.setup.length === 0) {
// The app-header should have already fetched it, but ensure it's loaded
setupProgressApi.fetchSetupProgress($currentOrg.siteName);
}
});
const StepsEnum = {
UPDATE_PROFILE: 'profile',
@@ -43,8 +63,11 @@
case StepsEnum.CREATE_LESSON:
if (isCompleted('course')) {
const courseId = data?.courses[0].id;
goto(`/courses/${courseId}/lessons`);
const courses = setupProgressApi.progress.courses || [];
const courseId = courses[0]?.id;
if (courseId) {
goto(`/courses/${courseId}/lessons`);
}
} else {
snackbar.info('setup.info_course');
}
@@ -52,9 +75,13 @@
case StepsEnum.CREATE_EXERCISE:
if (isCompleted('lesson')) {
const courseId = data?.courses[0].id;
const lessonId = data?.lessons[0].id;
goto(`/courses/${courseId}/lessons/${lessonId}`);
const courses = setupProgressApi.progress.courses || [];
const lessons = setupProgressApi.progress.lessons || [];
const courseId = courses[0]?.id;
const lessonId = lessons[0]?.id;
if (courseId && lessonId) {
goto(`/courses/${courseId}/lessons/${lessonId}`);
}
} else {
snackbar.info('setup.info_lesson');
}
@@ -62,8 +89,11 @@
case StepsEnum.PUBLISH_COURSE:
if (isCompleted('course')) {
const courseId = data?.courses[0].id;
goto(`/courses/${courseId}/settings`);
const courses = setupProgressApi.progress.courses || [];
const courseId = courses[0]?.id;
if (courseId) {
goto(`/courses/${courseId}/settings`);
}
} else {
snackbar.info('setup.info_course');
}
@@ -74,52 +104,98 @@
break;
case StepsEnum.UPDATE_ORG_PROFILE:
goto(`/org/${$currentOrg.siteName}/settings?tab=org`);
goto(`/org/${$currentOrg.siteName}/settings/org`);
break;
}
};
</script>
<section class="mx-auto w-full md:max-w-3xl">
<div class="px-2 py-2 md:px-5 md:py-10">
<div class="flex items-center gap-2">
<h1 class="text-2xl md:text-3xl dark:text-white">{$t('setup.get_started')}</h1>
<Chip
value={`${completed}/${setupList.length}`}
className="text-[10px] font-semibold px-3 py-1!"
shape="rounded-full"
/>
</div>
<svelte:head>
<title>Setup - ClassroomIO</title>
</svelte:head>
<section class="px-4">
{#each setupList as list, i}
<div
class="flex w-full flex-col justify-between gap-2 border-b border-gray-300 py-8 lg:flex-row lg:items-center"
>
<div class={`flex-1 space-y-1 ${list.is_completed ? 'opacity-50' : ''} lg:max-w-[50%]`}>
<div class="flex items-center gap-3">
<Chip value={i + 1} className="text-[10px] font-semibold py-1" shape="rounded-full" />
<p class="text-lg font-medium">{$t(list.title)}</p>
<Page.Root class="w-full md:max-w-4xl lg:mx-auto">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('setup.get_started')}</Page.Title>
<Page.Subtitle>{$t('setup.description')}</Page.Subtitle>
</Page.HeaderContent>
<Page.Action>
<SetupProgress setupItems={setupList} />
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<section class="space-y-6 px-4">
<Item.Root variant="outline" class="ui:cursor-default">
<Item.Content>
<Item.Description class="mb-4">
{$t('setup.description')}
</Item.Description>
<div class="flex items-center gap-2">
<Item.Description class="text-sm">
{completed} of {total}
{$t('setup.completed')}
</Item.Description>
<div class="flex gap-1">
{#each Array(total) as _, i}
<div
class="size-2 rounded-full {i < completed ? 'bg-green-600' : 'bg-gray-300 dark:bg-gray-600'}"
></div>
{/each}
</div>
</div>
<p class="text-sm">
{$t(list.desc)}
</p>
</div>
<div class="w-full lg:w-[30%]">
<PrimaryButton
variant={list.is_completed ? VARIANTS.CONTAINED_DARK : VARIANTS.OUTLINED}
className="w-full font-normal text-sm flex items-center gap-2"
onClick={() => handleClick(list.id)}
isDisabled={list.is_completed}
>
{#if list.is_completed}
<CircleCheckIcon size={16} />
{/if}
{$t(list.button_label)}
</PrimaryButton>
</div>
</div>
{/each}
</section>
</div>
</section>
</Item.Content>
</Item.Root>
<!-- Setup Items -->
<Item.Group class="space-y-2">
{#each setupList as list}
<Item.Root variant="muted" class={list.is_completed ? 'opacity-60' : ''}>
<Item.Media variant="icon">
{#if list.is_completed}
<div class="flex size-5 items-center justify-center rounded">
<BadgeCheckIcon class="size-5 text-white" />
</div>
{:else if list.id === StepsEnum.CREATE_COURSE}
<BookOpenIcon class="size-5 text-gray-600" />
{:else if list.id === StepsEnum.CREATE_LESSON || list.id === StepsEnum.CREATE_EXERCISE}
<FileTextIcon class="size-5 text-gray-600" />
{:else if list.id === StepsEnum.PUBLISH_COURSE}
<GlobeIcon class="size-5 text-gray-600" />
{:else if list.id === StepsEnum.UPDATE_PROFILE}
<UserPlusIcon class="size-5 text-gray-600" />
{:else if list.id === StepsEnum.UPDATE_ORG_PROFILE}
<UsersIcon class="size-5 text-gray-600" />
{:else}
<BookOpenIcon class="size-5 text-gray-600" />
{/if}
</Item.Media>
<Item.Content>
<Item.Title class={list.is_completed ? 'text-gray-500 line-through' : 'font-semibold'}>
{$t(list.title)}
</Item.Title>
<Item.Description class={list.is_completed ? 'text-gray-500 line-through' : ''}>
{$t(list.desc)}
</Item.Description>
</Item.Content>
<Item.Actions>
{#if list.is_completed}
<Button variant="secondary" size="sm" disabled class="bg-gray-400 text-white">
{$t('setup.done')}
</Button>
{:else}
<Button variant="outline" size="sm" onclick={() => handleClick(list.id)} class="w-full sm:w-auto">
{$t('setup.todo')}
<ChevronRightIcon class="ml-2 size-4" />
</Button>
{/if}
</Item.Actions>
</Item.Root>
{/each}
</Item.Group>
</section>
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,160 +1,5 @@
import get from 'lodash/get';
import { getSupabase } from '$lib/utils/functions/supabase';
const supabase = getSupabase();
async function getGenericData(orgSlug: string): Promise<{
isCoursePublished: boolean;
isCourseCreated: boolean;
orgHasAvatarUrl: boolean;
courseData: any;
}> {
const { data: publishedCourse } = await supabase
.from('course')
.select(
`
is_published,
group:group_id!inner(
organization!inner(
siteName
)
)
`
)
.eq('is_published', true)
.eq('group.organization.siteName', orgSlug)
.limit(1)
.returns<{ is_published: boolean }>();
const { data } = await supabase
.from('course')
.select(
`
id,
is_published,
group:group_id!inner(
organization!inner(
avatar_url,
siteName
)
)
`
)
.eq('group.organization.siteName', orgSlug)
.limit(1);
const result = data || [];
return {
isCoursePublished: publishedCourse?.is_published === true,
isCourseCreated: result.length > 0,
orgHasAvatarUrl: !!get(result[0], 'group.organization.avatar_url', ''),
courseData: result
};
}
async function getIsLessonCreated(orgSlug: string): Promise<{ isLessonCreated: boolean; lessonData: any }> {
const { data } = await supabase
.from('lesson')
.select(
`
id,
course:course_id!inner(
group:group_id!inner(
organization!inner(
siteName
)
)
)
`
)
.eq('course.group.organization.siteName', orgSlug)
.limit(1);
console.log('lessonsData', data);
return {
isLessonCreated: Array.isArray(data) ? data.length > 0 : false,
lessonData: data
};
}
async function getIsExerciseCreated(orgSlug: string): Promise<boolean> {
const { data } = await supabase
.from('exercise')
.select(
`
id,
lesson:lesson_id!inner(
course:course_id!inner(
group:group_id!inner(
organization!inner(
siteName
)
)
)
)
`
)
.eq('lesson.course.group.organization.siteName', orgSlug)
.limit(1);
return Array.isArray(data) ? data?.length > 0 : false;
}
export const load = async ({ params = { slug: '' } }) => {
const { isCourseCreated, isCoursePublished, orgHasAvatarUrl, courseData } = await getGenericData(params.slug);
const { isLessonCreated, lessonData } = await getIsLessonCreated(params.slug);
const isExerciseCreated = await getIsExerciseCreated(params.slug);
const data = [
{
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'
}
];
return {
orgSiteName: params.slug,
setup: data,
courses: courseData,
lessons: lessonData
orgSiteName: params.slug
};
};
@@ -1,96 +0,0 @@
<script lang="ts">
import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import TextField from '$lib/components/Form/TextField.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { getConfirmPasswordError } from '$lib/utils/functions/validator';
import AuthUI from '$lib/components/AuthUI/index.svelte';
import { resetApi } from '$lib/features/auth/api/reset.svelte';
import type { TResetPasswordForm } from '$lib/features/auth/utils/types';
import { snackbar } from '$lib/components/Snackbar/store';
let fields: TResetPasswordForm = $state({
password: '',
confirmPassword: '',
token: ''
});
const isSubmitDisabled = $derived!!(
fields.password && fields.confirmPassword && fields.password !== fields.confirmPassword
);
const token = $derived(new URLSearchParams(page.url.search).get('token'));
onMount(() => {
if (token) return;
snackbar.error('Invalid Token');
setTimeout(() => {
goto(resolve('/login', {}));
}, 2000);
});
function setConfirmPasswordError(fields: TResetPasswordForm) {
untrack(() => {
const errors = { ...resetApi.errors };
errors.confirmPassword = getConfirmPasswordError(fields) ?? '';
resetApi.setError(errors);
});
}
$effect(() => {
setConfirmPasswordError(fields);
});
</script>
<svelte:head>
<title>Reset Password - ClassroomIO</title>
</svelte:head>
<AuthUI
isLogin={false}
handleSubmit={() => resetApi.submit({ ...fields, token: token ?? '' })}
showOnlyContent={true}
isLoading={resetApi.isLoading}
showLogo={true}
>
<div class="mt-4 w-full">
<h3 class="my-3 text-xl font-semibold dark:text-white">New Password</h3>
<p class="mb-6 text-sm dark:text-white">Enter your new password details</p>
<TextField
label="Your Password"
bind:value={fields.password}
type="password"
placeholder="************"
className="mb-6"
inputClassName="w-full"
isDisabled={resetApi.isLoading}
errorMessage={resetApi.errors.password}
helperMessage="Password must be more than 8 characters"
isRequired
/>
<TextField
label="Confirm Password"
bind:value={fields.confirmPassword}
type="password"
placeholder="************"
className="mb-6"
inputClassName="w-full"
isDisabled={resetApi.isLoading}
errorMessage={resetApi.errors.confirmPassword}
isRequired
/>
</div>
<div class="my-4 flex w-full items-center justify-end">
<PrimaryButton
label="Reset Password"
type="submit"
className="sm:w-full w-full"
isDisabled={isSubmitDisabled || resetApi.isLoading}
isLoading={resetApi.isLoading}
/>
</div>
</AuthUI>