feat: migrate auth from supabase to better auth (#596)
* feat: add README for @cio/db package and remove existingAccounts.json * load session, load profile and organization, add create profile hook * chore: cache session request The trick was passing all the cookies to the backend. Previously I was only passing the better-auth.session_token and not better-auth.session_data. Read about the difference here https://www.better-auth.com/docs/concepts/cookies#custom-cookies * feat: login, logout, onboarding, verify email, reset password. * feat: complete change email and email verification * feat: standardise how we make rpc calls and create org endpoints * fix ui errors * fix: handle potential undefined customerId and standardize avatar URL property * refactor: standardize custom domain handling and update telegram chat ID references * chore: add tailwind prefix to @cio/ui this fixed the media query issues because @cio/ui classes were overriding this dashboard tailwind build * chore: clean up org sidebar * chore: persist mode state * feat: implement order breadcrumbs * chore: upgrade to better auth 1.4 * chore: address common auth flow bugs and setup app for others to setup locally.
This commit is contained in:
@@ -3,10 +3,9 @@ import { getSupabase, supabase } from '$lib/utils/functions/supabase';
|
||||
import type { CurrentOrg } from '$lib/utils/types/org';
|
||||
import type { MetaTagsProps } from 'svelte-meta-tags';
|
||||
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
|
||||
import { blockedSubdomain } from '$lib/utils/constants/app';
|
||||
import { dev } from '$app/environment';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { getCurrentOrg } from '$lib/utils/services/org';
|
||||
import { getOrgSiteInfo } from '$lib/features/app/layout-setup';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
if (!supabase) {
|
||||
@@ -15,6 +14,8 @@ if (!supabase) {
|
||||
|
||||
export const ssr = PUBLIC_IS_SELFHOSTED === 'true' ? false : true;
|
||||
|
||||
const APP_SUBDOMAINS = env.PRIVATE_APP_SUBDOMAINS?.split(',') || [];
|
||||
|
||||
interface LoadOutput {
|
||||
orgSiteName: string;
|
||||
isOrgSite: boolean;
|
||||
@@ -22,115 +23,34 @@ interface LoadOutput {
|
||||
org: CurrentOrg | null;
|
||||
baseMetaTags: MetaTagsProps;
|
||||
serverLang: string;
|
||||
locals: App.Locals;
|
||||
}
|
||||
|
||||
const APP_SUBDOMAINS = env.PRIVATE_APP_SUBDOMAINS?.split(',') || [];
|
||||
export const load = async ({ url, cookies, request, locals }): Promise<LoadOutput> => {
|
||||
const debugPlay = cookies.get('debugPlay');
|
||||
|
||||
const orgSiteInfo = await getOrgSiteInfo(url, cookies);
|
||||
|
||||
export const load = async ({ url, cookies, request }): Promise<LoadOutput> => {
|
||||
const response: LoadOutput = {
|
||||
orgSiteName: '',
|
||||
isOrgSite: false,
|
||||
skipAuth: false,
|
||||
org: null,
|
||||
orgSiteName: orgSiteInfo.orgSiteName,
|
||||
isOrgSite: orgSiteInfo.isOrgSite,
|
||||
skipAuth: orgSiteInfo.subdomain === 'play' || debugPlay === 'true',
|
||||
org: orgSiteInfo.org,
|
||||
baseMetaTags: getBaseMetaTags(url),
|
||||
serverLang: request.headers?.get('accept-language') || ''
|
||||
serverLang: request.headers?.get('accept-language') || '',
|
||||
locals
|
||||
};
|
||||
|
||||
console.log('PUBLIC_IS_SELFHOSTED', PUBLIC_IS_SELFHOSTED);
|
||||
|
||||
// Selfhosted usecase would be here
|
||||
if (PUBLIC_IS_SELFHOSTED === 'true') {
|
||||
const subdomain = getSubdomain(url);
|
||||
console.log('subdomain', subdomain);
|
||||
|
||||
// Student dashboard
|
||||
if (subdomain) {
|
||||
const org = (await getCurrentOrg(subdomain, true)) || null;
|
||||
|
||||
// Organization by subdomain not found
|
||||
if (!org) {
|
||||
return response;
|
||||
}
|
||||
|
||||
response.org = org;
|
||||
response.isOrgSite = true;
|
||||
response.orgSiteName = subdomain;
|
||||
}
|
||||
|
||||
// Never go beyond this for selfhosted instances
|
||||
return response;
|
||||
}
|
||||
|
||||
const isLocalHost = url.host.includes('localhost');
|
||||
|
||||
const tempSiteName = url.searchParams.get('org');
|
||||
|
||||
if (isLocalHost && tempSiteName) {
|
||||
console.log('setting sitename temp');
|
||||
cookies.set('_orgSiteName', tempSiteName, {
|
||||
path: '/'
|
||||
});
|
||||
}
|
||||
|
||||
const _orgSiteName = cookies.get('_orgSiteName');
|
||||
const debugPlay = cookies.get('debugPlay');
|
||||
const debugMode = _orgSiteName && _orgSiteName !== 'false';
|
||||
|
||||
const subdomain = getSubdomain(url) || '';
|
||||
|
||||
const isDev = dev || isLocalHost;
|
||||
|
||||
if (isURLCustomDomain(url)) {
|
||||
// Custom domain
|
||||
response.org = (await getCurrentOrg(url.host, true, true)) || null;
|
||||
|
||||
console.log('custom domain response.org', response.org);
|
||||
|
||||
if (!response.org) {
|
||||
console.error('Custom domain org not found, loading dashboard');
|
||||
return response;
|
||||
}
|
||||
|
||||
response.isOrgSite = true;
|
||||
response.orgSiteName = response.org?.siteName || '';
|
||||
return response;
|
||||
} else if (!blockedSubdomain.includes(subdomain)) {
|
||||
if (APP_SUBDOMAINS.includes(subdomain)) {
|
||||
// This is an app domain specified in the .env file
|
||||
return response;
|
||||
}
|
||||
|
||||
console.log('subdomain', subdomain);
|
||||
|
||||
response.isOrgSite = debugMode || !!subdomain;
|
||||
response.orgSiteName = debugMode ? _orgSiteName : subdomain;
|
||||
response.org = (await getCurrentOrg(response.orgSiteName, true)) || null;
|
||||
|
||||
if (!response.org && !isDev) {
|
||||
redirect(307, 'https://app.classroomio.com/404?type=org');
|
||||
} else if (!response.org && _orgSiteName) {
|
||||
cookies.delete('_orgSiteName', { path: '/' });
|
||||
}
|
||||
} else if (subdomain === 'play' || debugPlay === 'true') {
|
||||
response.skipAuth = true;
|
||||
} else if (!APP_SUBDOMAINS.includes(subdomain) && !isDev) {
|
||||
// This case is for anything in our blockedSubdomains
|
||||
// If it isn't a registered dashboard domain and also not a valid sub domain.
|
||||
if (!APP_SUBDOMAINS.includes(orgSiteInfo.subdomain) && !dev && !orgSiteInfo.isOrgSite) {
|
||||
redirect(307, 'https://app.classroomio.com');
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
function isURLCustomDomain(url: URL) {
|
||||
if (url.host.includes('localhost')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const notCustomDomainHosts = [env.PRIVATE_APP_HOST || '', 'classroomio.com', 'vercel.app'].filter(Boolean);
|
||||
|
||||
return !notCustomDomainHosts.some((host) => url.host.endsWith(host));
|
||||
}
|
||||
|
||||
function getBaseMetaTags(url: URL) {
|
||||
return Object.freeze({
|
||||
title: 'ClassroomIO | The Open Source Learning Management System for Companies',
|
||||
@@ -168,19 +88,3 @@ function getBaseMetaTags(url: URL) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getSubdomain(url: URL) {
|
||||
const host = url.hostname.replace('www.', '');
|
||||
const parts = host.split('.');
|
||||
const appHost = env.PRIVATE_APP_HOST;
|
||||
|
||||
const appHostParts = appHost.split('.');
|
||||
const isAppHost = parts.slice(-appHostParts.length).join('.') === appHost;
|
||||
|
||||
if (isAppHost) {
|
||||
// Subdomain exists only if extra part(s) before main domain
|
||||
return parts.length > appHostParts.length ? parts[0] : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,39 +1,23 @@
|
||||
<script lang="ts">
|
||||
// import { browser } from '$app/environment';
|
||||
import { page } from '$app/stores';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { page } from '$app/state';
|
||||
|
||||
import { course } from '$lib/components/Course/store';
|
||||
import OrgNavigation from '$lib/components/Navigation/app.svelte';
|
||||
import LandingNavigation from '$lib/components/Navigation/index.svelte';
|
||||
import LMSNavigation from '$lib/components/Navigation/lms.svelte';
|
||||
import OrgLandingPage from '$lib/components/Org/LandingPage/index.svelte';
|
||||
import PlayQuiz from '$lib/components/Org/Quiz/Play/index.svelte';
|
||||
import { PageRestricted } from '$lib/components/Page';
|
||||
// import PageLoadProgressBar from '$lib/components/Progress/PageLoadProgressBar.svelte';
|
||||
import Snackbar from '$lib/components/Snackbar/index.svelte';
|
||||
import UpgradeModal from '$lib/components/Upgrade/Modal.svelte';
|
||||
import {
|
||||
isCoursesPage,
|
||||
isLMSPage,
|
||||
isOrgPage
|
||||
|
||||
// toggleBodyByMode
|
||||
} from '$lib/utils/functions/app';
|
||||
import { getProfile, setupAnalytics } from '$lib/utils/functions/appSetup';
|
||||
import hideNavByRoute from '$lib/utils/functions/routes/hideNavByRoute';
|
||||
import { getSupabase } from '$lib/utils/functions/supabase';
|
||||
import { user } from '$lib/utils/store/user';
|
||||
import { setupAnalytics } from '$lib/utils/functions/appSetup';
|
||||
import { setTheme } from '$lib/utils/functions/theme';
|
||||
import { initOrgAnalytics } from '$lib/utils/services/posthog';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { isMobile } from '$lib/utils/store/useMobile';
|
||||
import { appInitApi } from '$lib/features/app/init.svelte';
|
||||
import merge from 'lodash/merge';
|
||||
import { onMount } from 'svelte';
|
||||
import { MetaTags } from 'svelte-meta-tags';
|
||||
import isPublicRoute from '$lib/utils/functions/routes/isPublicRoute';
|
||||
import { hasSession } from '$lib/utils/functions/supabase';
|
||||
import { goto } from '$app/navigation';
|
||||
import { authClient } from '$lib/utils/services/auth/client';
|
||||
import { PageLoadProgress } from '$lib/features/ui';
|
||||
|
||||
import { ModeWatcher } from '@cio/ui/base/dark-mode';
|
||||
|
||||
@@ -41,31 +25,29 @@
|
||||
|
||||
let { data, children } = $props();
|
||||
|
||||
let supabase = getSupabase();
|
||||
let path = $derived($page.url?.pathname?.replace('/', ''));
|
||||
let queryParam = $page.url?.search;
|
||||
let path = $derived(page.url?.pathname?.replace('/', ''));
|
||||
|
||||
function handleResize() {
|
||||
isMobile.update(() => window.innerWidth <= 760);
|
||||
}
|
||||
|
||||
const getProfileDebounced = debounce(getProfile, 1000);
|
||||
|
||||
function pageSetup() {
|
||||
function intialAppSetup() {
|
||||
console.log(
|
||||
'Welcome to ClassroomIO, we are grateful you chose us.',
|
||||
$page.url.host,
|
||||
page.url.host,
|
||||
`\nIs student domain: ${data.isOrgSite}`,
|
||||
data
|
||||
);
|
||||
|
||||
// $globalStore.isDark = localStorage.getItem('mode') === 'dark';
|
||||
// toggleBodyByMode($globalStore.isDark);
|
||||
|
||||
setupAnalytics();
|
||||
|
||||
handleResize();
|
||||
if (data.locals.user) {
|
||||
user.set({
|
||||
...$user,
|
||||
isLoggedIn: true,
|
||||
currentSession: data.locals.user
|
||||
});
|
||||
}
|
||||
|
||||
console.log('user', $user);
|
||||
|
||||
// Authentication Steps
|
||||
if (!data.isOrgSite || !data.org) return;
|
||||
|
||||
$globalStore.orgSiteName = data.orgSiteName;
|
||||
@@ -80,43 +62,36 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
pageSetup();
|
||||
intialAppSetup();
|
||||
|
||||
if (!hasSession() && !isPublicRoute($page.url?.pathname)) {
|
||||
console.log('No auth token and is not a public route, redirect to login', path);
|
||||
return goto('/login?redirect=/' + path);
|
||||
}
|
||||
|
||||
const { data: authListener } = supabase.auth.onAuthStateChange((event) => {
|
||||
console.log(`event`, event);
|
||||
|
||||
if (path.includes('reset')) {
|
||||
console.log('Dont change auth when on reset page');
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.skipAuth) return;
|
||||
|
||||
if (event === 'SIGNED_IN' || event === 'INITIAL_SESSION') {
|
||||
getProfileDebounced({
|
||||
path,
|
||||
queryParam,
|
||||
isOrgSite: data.isOrgSite,
|
||||
orgSiteName: data.orgSiteName
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
console.log('unsubscribed');
|
||||
authListener.subscription.unsubscribe();
|
||||
};
|
||||
if (data.skipAuth) return;
|
||||
});
|
||||
|
||||
let metaTags = $derived(merge(data.baseMetaTags, $page.data.pageMetaTags));
|
||||
</script>
|
||||
const session = authClient.useSession();
|
||||
const isSessionReady = $derived(!$session.isPending && !$session.isRefetching && $session.data);
|
||||
|
||||
<svelte:window onresize={handleResize} />
|
||||
$effect(() => {
|
||||
// this means the session cookie 'classroomio.session_data' expired and we need to trigger a new session
|
||||
if (!data.locals.fromSessions && isSessionReady) {
|
||||
authClient.getSession().then(() => {
|
||||
console.log('triggered new session');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isSessionReady && !appInitApi.isInitializedAndReady && !appInitApi.loading) {
|
||||
console.log('setting up account with session data');
|
||||
|
||||
appInitApi.setupApp($session.data as App.Locals, {
|
||||
isOrgSite: data.isOrgSite,
|
||||
orgSiteName: data.orgSiteName
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const metaTags = $derived(merge(data.baseMetaTags, page.data.pageMetaTags));
|
||||
</script>
|
||||
|
||||
<ModeWatcher />
|
||||
|
||||
@@ -126,46 +101,19 @@
|
||||
|
||||
<Snackbar />
|
||||
|
||||
{#if data.org?.is_restricted || $currentOrg.is_restricted}
|
||||
{#if data.org?.is_restricted || $currentOrg.isRestricted}
|
||||
<PageRestricted />
|
||||
{:else if data.skipAuth}
|
||||
<PlayQuiz />
|
||||
{:else if data.isOrgSite && !path}
|
||||
<OrgLandingPage orgSiteName={data.orgSiteName} org={data.org} />
|
||||
{:else}
|
||||
<main class="z-20000 dark:bg-black">
|
||||
{#if !hideNavByRoute($page.url?.pathname)}
|
||||
{#if isOrgPage($page.url?.pathname) || $page.url?.pathname.includes('profile') || isCoursesPage(path)}
|
||||
<OrgNavigation bind:title={$course.title} isCoursePage={isCoursesPage(path)} />
|
||||
{:else if isLMSPage($page.url?.pathname)}
|
||||
<LMSNavigation />
|
||||
{:else}
|
||||
<LandingNavigation
|
||||
isOrgSite={data.isOrgSite}
|
||||
logo={data.isOrgSite ? $currentOrg.avatar_url : undefined}
|
||||
orgName={data.isOrgSite ? $currentOrg.name : undefined}
|
||||
disableSignup={false}
|
||||
/>
|
||||
{/if}
|
||||
<PageLoadProgress zIndex={10000} />
|
||||
|
||||
<!-- <PageLoadProgressBar textColorClass="text-neutral-700" /> -->
|
||||
{/if}
|
||||
|
||||
<div class={path.includes('home') ? '' : 'flex justify-between'}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</main>
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
main {
|
||||
background-color: white;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:global(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
:global(:root) {
|
||||
--main-primary-color: rgba(29, 78, 216, 1);
|
||||
--border-color: #eaecef;
|
||||
@@ -181,10 +129,6 @@
|
||||
background: var(--dark-app-background);
|
||||
}
|
||||
|
||||
:global(.bx--data-table-container) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:global(.dark svg.dark) {
|
||||
fill: #fff;
|
||||
}
|
||||
@@ -197,14 +141,6 @@
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
:global(.bx--search-close > svg) {
|
||||
fill: black;
|
||||
}
|
||||
|
||||
:global(.dark .bx--search-close:hover > svg) {
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
:global(.plyr__controls) {
|
||||
background:
|
||||
url(/logo-192.png) 99% 70% no-repeat,
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
import { config, loadTranslations } from '$lib/utils/functions/translations';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
const SUPPORTED_LANGUAGES = config?.loaders?.map((loader) => loader.locale) || [];
|
||||
|
||||
export const load = async ({ url, data }) => {
|
||||
const { pathname } = url;
|
||||
|
||||
const profileStore = get(profile);
|
||||
|
||||
const serverLang = data.serverLang.split('-')[0];
|
||||
|
||||
const userLocale = profileStore.id ? profileStore.locale : getInitialLocale(serverLang);
|
||||
const userLocale = data.locals?.profile?.locale || getInitialLocale(serverLang);
|
||||
|
||||
const initLocale = getInitialLocale(userLocale);
|
||||
await loadTranslations(initLocale, pathname); // keep this just before the `return`
|
||||
|
||||
@@ -1,114 +1,16 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import BotIcon from '@lucide/svelte/icons/bot';
|
||||
import DoorOpenIcon from '@lucide/svelte/icons/door-open';
|
||||
import ChartAreaIcon from '@lucide/svelte/icons/chart-area';
|
||||
|
||||
import { getSupabase } from '$lib/utils/functions/supabase';
|
||||
import { preventDefault } from '$lib/utils/functions/svelte';
|
||||
import { validateEmail } from '$lib/utils/functions/validateEmail';
|
||||
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
|
||||
let email = $state('');
|
||||
let isAdding = $state(false);
|
||||
let success = $state(false);
|
||||
|
||||
const supabase = getSupabase();
|
||||
const animate = 'transition delay-75 duration-300 ease-in-out';
|
||||
const areas = [
|
||||
{
|
||||
title: 'Access',
|
||||
description: 'Quickly find any material you need to get your job done. ClassroomIO offers the All-in-on-platform'
|
||||
},
|
||||
{
|
||||
title: 'Analytics',
|
||||
description:
|
||||
'Avoid assumptions, better understand the needs of your classrooms and give students a personlized experience'
|
||||
},
|
||||
{
|
||||
title: 'Automation',
|
||||
description:
|
||||
'Lesson reminders, deadline reminders and many other automated alerts to help everyone in the learning process be proactive'
|
||||
}
|
||||
];
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!email || !validateEmail(email)) return;
|
||||
isAdding = true;
|
||||
|
||||
await supabase.from('waitinglist').insert([{ email }]);
|
||||
|
||||
success = true;
|
||||
|
||||
setTimeout(() => {
|
||||
isAdding = false;
|
||||
success = false;
|
||||
email = '';
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
console.log('Welcome to CIO');
|
||||
});
|
||||
import { appInitApi } from '$lib/features/app/init.svelte';
|
||||
import { Circle } from 'svelte-loading-spinners';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>ClassroomIO - The classroom software</title>
|
||||
<title>ClassroomIO - Beautiful and Robust Course Platform</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="m-2 flex w-screen flex-col items-center justify-center sm:m-0 md:min-h-[93vh] dark:bg-black">
|
||||
<!--
|
||||
<img
|
||||
src="/logo-192.png"
|
||||
alt="ClassroomIO logo"
|
||||
class="rounded inline-block mx-auto w-20 h-20 sm:w-auto sm:h-auto"
|
||||
data-atf="1"
|
||||
/>
|
||||
-->
|
||||
<div>
|
||||
<h3 class="text-center text-4xl dark:text-white">
|
||||
Classroom<span class="text-primary-700">IO</span>
|
||||
</h3>
|
||||
<p class="text-center text-lg dark:text-white">The operating system for classroooms of the future 🚀🚀🚀.</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={preventDefault(handleSubmit)} class="my-4 hidden">
|
||||
<div class="flex hidden flex-col items-center sm:flex-row">
|
||||
{#if success}
|
||||
<p class="dark:text-white">You have been added successfully. Thanks for joining.</p>
|
||||
{:else}
|
||||
<TextField
|
||||
bind:value={email}
|
||||
type="email"
|
||||
placeholder="Enter your email.."
|
||||
className="mr-3 my-2"
|
||||
isRequired={true}
|
||||
isDisabled={isAdding}
|
||||
/>
|
||||
<PrimaryButton type="submit" isLoading={isAdding}>Join waiting list</PrimaryButton>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-4 flex md:flex-row">
|
||||
{#each areas as area, index}
|
||||
<div
|
||||
class="active hover:border-primary-700 m-3 max-w-[350px] rounded-md border-2 bg-white px-12 py-3 shadow-xl dark:bg-black {animate}"
|
||||
>
|
||||
<h3 class="text-3xl dark:text-white">
|
||||
{#if index === 0}
|
||||
<DoorOpenIcon size={30} />
|
||||
{:else if index === 1}
|
||||
<ChartAreaIcon size={30} />
|
||||
{:else if index === 2}
|
||||
<BotIcon size={30} />
|
||||
{/if}
|
||||
{area.title}
|
||||
</h3>
|
||||
<p class="dark:text-white">{area.description}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<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">
|
||||
{#if appInitApi.loading || appInitApi.data}
|
||||
<Circle size="50" color="#1d4ee2" unit="px" duration="1s" />
|
||||
{:else if appInitApi.error}
|
||||
<p class="text-red-500">{appInitApi.error}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import sendEmail from '$mail/sendEmail';
|
||||
|
||||
export async function POST({ fetch, request }) {
|
||||
const { to, profileId, orgSiteName, fullname } = await request.json();
|
||||
console.log('/POST api/email/verify_email', to, profileId);
|
||||
|
||||
if (!to || !profileId) {
|
||||
return json({ success: false, message: 'Name and To are required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const origin = request.headers.get('origin');
|
||||
const verificationData = JSON.stringify({
|
||||
profileId,
|
||||
orgSiteName
|
||||
});
|
||||
|
||||
const verificationLink = `${origin}/api/verify?data=${encodeURIComponent(btoa(verificationData))}`;
|
||||
|
||||
const emailData = [
|
||||
{
|
||||
from: `"Best from ClassroomIO" <[email protected]>`,
|
||||
to,
|
||||
subject: 'Action Required: Confirm your email',
|
||||
content: `
|
||||
<p><strong>Hi ${fullname} 👋</strong></p>
|
||||
<p>Welcome to ClassroomIO, new friend! In order to get your account ready for usage, we need to verify your email. </p>
|
||||
<p>We do this to make sure we don't get fake user emails in our signup. To get the best out of our product, we'll need you to verify your email by clicking the <strong>Verify</strong> button below
|
||||
</p>
|
||||
<div>
|
||||
<a class="button" href="${verificationLink}">Verify</a>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
await sendEmail(fetch)(emailData);
|
||||
|
||||
return json({
|
||||
success: true
|
||||
});
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import sendEmail from '$mail/sendEmail';
|
||||
|
||||
export async function POST({ fetch, request }) {
|
||||
const { to, name } = await request.json();
|
||||
console.log('/POST api/email/welcome', to, name);
|
||||
|
||||
if (!to || !name) {
|
||||
return json({ success: false, message: 'Name and To are required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
const emailData = [
|
||||
{
|
||||
from: `"Best from ClassroomIO" <[email protected]>`,
|
||||
to,
|
||||
subject: 'Welcome to ClassroomIO!',
|
||||
content: `
|
||||
<p>Dear ${name},</p>
|
||||
<p>My name is Best, the founder of ClassroomIO. I saw you signed up, any questions so far?</p>
|
||||
<p>
|
||||
Would you like to get a walkthrough of the tool in a 30 minutes product demo? It's free and we won't try to sell you anything, just want to learn from your use case and show you all the cool features and flows we thought about.
|
||||
</p>
|
||||
<p>
|
||||
Interested? just book a 30mn demo slot in our calendar!
|
||||
</p>
|
||||
<div>
|
||||
<a href="https://classroomio.com/demo">Book demo</a>
|
||||
</div>
|
||||
<p>
|
||||
PS: I reply personally to every email. We don't have an outsourced support team at the other end of the globe.. ;-)
|
||||
</p>
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
await sendEmail(fetch)(emailData);
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
message: 'Email sent'
|
||||
});
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { redirect, json } from '@sveltejs/kit';
|
||||
import { getServerSupabase } from '$lib/utils/functions/supabase.server';
|
||||
|
||||
export const GET = async ({ url }) => {
|
||||
const supabase = getServerSupabase();
|
||||
|
||||
const data = url.searchParams.get('data');
|
||||
if (!data) return json({ message: 'data is not found' }, { status: 404 });
|
||||
|
||||
const hashData = decodeURIComponent(data);
|
||||
console.log('hashData', hashData);
|
||||
|
||||
const decodedData = atob(hashData);
|
||||
console.log('decodedData', decodedData);
|
||||
|
||||
const { profileId, orgSiteName } = JSON.parse(decodedData);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('profile')
|
||||
.update({ is_email_verified: true, verified_at: new Date().toDateString() })
|
||||
.match({ id: profileId });
|
||||
|
||||
if (error) {
|
||||
console.log('profile not found', error);
|
||||
redirect(307, '/404');
|
||||
}
|
||||
|
||||
redirect(307, `/org/${orgSiteName}?welcomePopup=true`);
|
||||
};
|
||||
@@ -1,32 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { sideBar } from '$lib/components/Org/store';
|
||||
import hotkeys from 'hotkeys-js';
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
|
||||
function toggleSidebar() {
|
||||
$sideBar.hidden = !$sideBar.hidden;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
hotkeys('ctrl+b,command+b', function (event, handler) {
|
||||
// Prevent default behavior
|
||||
event.preventDefault();
|
||||
|
||||
console.log({ handlerKey: handler.key });
|
||||
|
||||
switch (handler.key) {
|
||||
case 'ctrl+b':
|
||||
case 'command+b':
|
||||
toggleSidebar();
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
id: groupMember?.id || '',
|
||||
username: $profile.username || '',
|
||||
fullname: $profile.fullname || '',
|
||||
avatar_url: $profile.avatar_url || ''
|
||||
avatar_url: $profile.avatarUrl || ''
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import BadgeXIcon from '@lucide/svelte/icons/badge-x';
|
||||
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { ROLE } from '$lib/utils/constants/roles';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { attendance } from '$lib/utils/store/attendance';
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
import { LANGUAGES } from '$lib/utils/constants/translation';
|
||||
import { getGroupMemberId } from '$lib/components/Course/function';
|
||||
import { getIsLessonComplete } from '$lib/components/Course/components/Lesson/functions';
|
||||
import { COURSE_VERSION, LOCALE, type Lesson, type LessonCompletion } from '$lib/utils/types';
|
||||
import { COURSE_VERSION, type Lesson, type LessonCompletion } from '$lib/utils/types';
|
||||
import LessonVersionHistory from '$lib/components/Course/components/Lesson/LessonVersionHistory.svelte';
|
||||
import { checkExercisesComplete, fetchLesson, updateLessonCompletion } from '$lib/utils/services/courses';
|
||||
import { lesson, setLesson, lessons, lessonSections } from '$lib/components/Course/components/Lesson/store/lessons';
|
||||
@@ -71,7 +71,7 @@
|
||||
lessonData,
|
||||
totalExercises,
|
||||
totalComments,
|
||||
locale: $profile.locale
|
||||
locale: $profile.locale || 'en'
|
||||
});
|
||||
$lesson.isFetching = false;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import TextChip from '$lib/components/Chip/Text.svelte';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import ComingSoon from '$lib/components/ComingSoon/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 InvitationModal from '$lib/components/Course/components/People/InvitationModal.svelte';
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
import type { UserCourseAnalytics } from '$lib/utils/types/analytics';
|
||||
|
||||
import Progress from '$lib/components/Progress/index.svelte';
|
||||
import Grid from '$lib/components/Org/Settings/Layout/Grid.svelte';
|
||||
import { Grid } from '$lib/components/Org/Settings/Layout';
|
||||
import { ActivityCard, HeroProfileCard, LoadingPage } from '$lib/components/Analytics';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -1,55 +1,25 @@
|
||||
<script>
|
||||
<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 { getSupabase } from '$lib/utils/functions/supabase';
|
||||
import { forgotValidation } from '$lib/utils/functions/validator';
|
||||
import { ROUTE } from '$lib/utils/constants/routes';
|
||||
import { FORGOT_PASSWORD_FIELDS } from '$lib/utils/constants/authentication';
|
||||
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 supabase = getSupabase();
|
||||
let fields = $state(Object.assign({}, FORGOT_PASSWORD_FIELDS));
|
||||
let submitError = $state();
|
||||
let loading = $state(false);
|
||||
let success = $state(false);
|
||||
let errors = $state({});
|
||||
|
||||
async function handleSubmit() {
|
||||
errors = forgotValidation(fields);
|
||||
console.log('errors', errors);
|
||||
|
||||
if (Object.keys(errors).length) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loading = true;
|
||||
const { data, error } = await supabase.auth.resetPasswordForEmail(fields.email, {
|
||||
redirectTo: `${window.location.origin}/reset`
|
||||
});
|
||||
console.log('data', data);
|
||||
if (error) throw error;
|
||||
|
||||
success = true;
|
||||
} catch (error) {
|
||||
submitError = error.error_description || error.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
let fields: TForgotPasswordForm = $state({ email: '' });
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Reset Password - ClassroomIO</title>
|
||||
<title>Forgot Password - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<AuthUI {supabase} {handleSubmit} showOnlyContent={true} showLogo={!success}>
|
||||
{#if success}
|
||||
<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 size={16} />
|
||||
<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
|
||||
@@ -72,12 +42,9 @@
|
||||
placeholder="[email protected]"
|
||||
className="mb-6"
|
||||
inputClassName="w-full"
|
||||
isDisabled={loading}
|
||||
errorMessage={errors.email}
|
||||
isDisabled={forgotApi.isLoading}
|
||||
errorMessage={forgotApi.errors.email}
|
||||
/>
|
||||
{#if submitError}
|
||||
<p class="text-sm text-red-500">{submitError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="my-4 flex w-full flex-col items-center justify-end">
|
||||
@@ -85,8 +52,7 @@
|
||||
label="Reset Password"
|
||||
type="submit"
|
||||
className="sm:w-full w-full mb-4"
|
||||
isDisabled={loading}
|
||||
isLoading={loading}
|
||||
isLoading={forgotApi.isLoading}
|
||||
/>
|
||||
<PrimaryButton
|
||||
label="Cancel"
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
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 '$lib/utils/constants/roles';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { triggerSendEmail, NOTIFICATION_NAME } from '$lib/utils/services/notification/notification';
|
||||
import { snackbar } from '$lib/components/Snackbar/store.js';
|
||||
import { capturePosthogEvent } from '$lib/utils/services/posthog';
|
||||
import { page } from '$app/stores';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
@@ -21,14 +21,14 @@
|
||||
let loading = $state(false);
|
||||
|
||||
let disableSubmit = false;
|
||||
let formRef: HTMLFormElement = $state();
|
||||
let formRef: HTMLFormElement | undefined = $state();
|
||||
|
||||
async function handleSubmit() {
|
||||
loading = true;
|
||||
|
||||
if (!$profile.id || !$profile.email) {
|
||||
console.log('Profile not found', $profile);
|
||||
return goto(`/signup?redirect=${$page.url?.pathname || ''}`);
|
||||
return goto(`/signup?redirect=${page.url?.pathname || ''}`);
|
||||
}
|
||||
|
||||
const { data: courseData, error } = await supabase.from('course').select('group_id').eq('id', data.id).single();
|
||||
@@ -115,7 +115,7 @@
|
||||
data: { session }
|
||||
} = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
return goto(`/login?redirect=${$page.url?.pathname || ''}`);
|
||||
return goto(`/login?redirect=${page.url?.pathname || ''}`);
|
||||
}
|
||||
|
||||
setTheme(data.currentOrg?.theme || '');
|
||||
|
||||
@@ -6,14 +6,15 @@
|
||||
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 { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
let formRef: HTMLFormElement = $state();
|
||||
let formRef: HTMLFormElement | undefined = $state();
|
||||
let supabase = getSupabase();
|
||||
let fields = $state(Object.assign({}, LOGIN_FIELDS));
|
||||
let submitError: string = $state();
|
||||
let submitError: string | undefined = $state();
|
||||
let loading = $state(false);
|
||||
let errors = $state(Object.assign({}, LOGIN_FIELDS));
|
||||
|
||||
@@ -28,22 +29,33 @@
|
||||
|
||||
try {
|
||||
loading = true;
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email: fields.email,
|
||||
password: fields.password
|
||||
});
|
||||
|
||||
const { data, error } = await authClient.signIn.email(
|
||||
{
|
||||
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: () => {
|
||||
capturePosthogEvent('login', {
|
||||
email: fields.email
|
||||
});
|
||||
|
||||
if ($globalStore.isOrgSite) {
|
||||
capturePosthogEvent('student_login', {
|
||||
email: fields.email
|
||||
});
|
||||
}
|
||||
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log('data', data);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
capturePosthogEvent('login', {
|
||||
email: fields.email
|
||||
});
|
||||
|
||||
if ($globalStore.isOrgSite) {
|
||||
capturePosthogEvent('student_login', {
|
||||
email: fields.email
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
submitError = error.error_description || error.message;
|
||||
loading = false;
|
||||
|
||||
@@ -1,221 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { goto } from '$app/navigation';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
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 { onboardingValidation } from '$lib/utils/functions/validator';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { blockedSubdomain } from '$lib/utils/constants/app';
|
||||
import { getOrganizations } from '$lib/utils/services/org';
|
||||
import { onboardingApi } from '$lib/features/onboarding/api/onboarding.svelte';
|
||||
import { generateSitename } from '$lib/utils/functions/org';
|
||||
import { triggerSendEmail, NOTIFICATION_NAME } from '$lib/utils/services/notification/notification';
|
||||
import { handleLocaleChange, t } from '$lib/utils/functions/translations';
|
||||
import { LOCALE } from '$lib/utils/types';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { GOALS, ONBOARDING_STEPS, SOURCES, DROPDOWN_ITEMS } from '$lib/features/onboarding/utils/constants';
|
||||
import type { OnboardingField } from '$lib/features/onboarding/utils/types';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
interface OnboardingField {
|
||||
fullname?: string;
|
||||
orgName?: string;
|
||||
siteName?: string;
|
||||
goal?: string;
|
||||
source?: string;
|
||||
locale?: LOCALE;
|
||||
metadata?: {};
|
||||
}
|
||||
|
||||
interface Goal {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const maxSteps = 2;
|
||||
let fields: OnboardingField = $state({
|
||||
fullname: '',
|
||||
orgName: '',
|
||||
siteName: '',
|
||||
locale: LOCALE.EN
|
||||
locale: 'en'
|
||||
});
|
||||
let errors: OnboardingField = $state({});
|
||||
let step = $state(1);
|
||||
let loading = $state(false);
|
||||
let isSiteNameTouched = $state(false);
|
||||
|
||||
let dropdownItems = [
|
||||
{ id: 'de' as LOCALE, text: 'German' },
|
||||
{ id: 'en' as LOCALE, text: 'English' },
|
||||
{ id: 'es' as LOCALE, text: 'Spanish' },
|
||||
{ id: 'fr' as LOCALE, text: 'French' },
|
||||
{ id: 'hi' as LOCALE, text: 'Hindi' },
|
||||
{ id: 'pl' as LOCALE, text: 'Polish' },
|
||||
{ id: 'pt' as LOCALE, text: 'Portuguese' },
|
||||
{ id: 'ru' as LOCALE, text: 'Russian' },
|
||||
{ id: 'vi' as LOCALE, text: 'Vietnamese' },
|
||||
{ id: 'da' as LOCALE, text: 'Danish' }
|
||||
];
|
||||
|
||||
const progress = $derived(Math.round((step / maxSteps) * 100));
|
||||
|
||||
const educatorGoals: Goal[] = [
|
||||
{
|
||||
label: $t('onboarding.sell'),
|
||||
value: 'sell-online'
|
||||
},
|
||||
{
|
||||
label: $t('onboarding.teach'),
|
||||
value: 'teach-online'
|
||||
},
|
||||
{
|
||||
label: $t('onboarding.employees'),
|
||||
value: 'employees'
|
||||
},
|
||||
{
|
||||
label: $t('onboarding.customers'),
|
||||
value: 'customers'
|
||||
},
|
||||
{
|
||||
label: $t('onboarding.expanding'),
|
||||
value: 'expanding-platform'
|
||||
}
|
||||
];
|
||||
|
||||
const sources = [
|
||||
{
|
||||
label: $t('onboarding.articles'),
|
||||
value: 'articles'
|
||||
},
|
||||
{
|
||||
label: $t('onboarding.search'),
|
||||
value: 'search-engine'
|
||||
},
|
||||
{
|
||||
label: $t('onboarding.social'),
|
||||
value: 'social-media'
|
||||
},
|
||||
{
|
||||
label: $t('onboarding.friends'),
|
||||
value: 'friends-family'
|
||||
}
|
||||
];
|
||||
|
||||
async function setMetaData() {
|
||||
if (!env.PUBLIC_IP_REGISTRY_KEY) return;
|
||||
|
||||
const response = await fetch(`https://api.ipregistry.co/?key=${env.PUBLIC_IP_REGISTRY_KEY}`);
|
||||
const payload = await response.json();
|
||||
fields.metadata = payload;
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
loading = true;
|
||||
// Run validation
|
||||
console.log(fields);
|
||||
errors = onboardingValidation(fields, step);
|
||||
console.log('errors', errors);
|
||||
|
||||
if (Object.keys(errors).length || !$profile.id) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (step === 1) {
|
||||
// Validate if domain is among our seculeded subdomains
|
||||
if (blockedSubdomain.includes(fields.siteName || '')) {
|
||||
errors.siteName = 'Sitename already exists.';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: org, error } = await supabase
|
||||
.from('organization')
|
||||
.insert({
|
||||
name: fields.orgName,
|
||||
siteName: fields.siteName
|
||||
})
|
||||
.select();
|
||||
console.log('Create organisation', org);
|
||||
if (error) {
|
||||
console.log('Error: create organisation', error);
|
||||
errors.siteName = 'Sitename already exists.';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(org) && org.length) {
|
||||
const orgData = org[0];
|
||||
const { data, error } = await supabase
|
||||
.from('organizationmember')
|
||||
.insert({
|
||||
organization_id: orgData.id,
|
||||
profile_id: $profile.id,
|
||||
role_id: 1,
|
||||
verified: true
|
||||
})
|
||||
.select();
|
||||
console.log('Create organisation member', data);
|
||||
|
||||
if (error) {
|
||||
console.log('Error: create organisation member', error);
|
||||
errors.siteName = $t('add_org.error_organization');
|
||||
|
||||
// Delete organization so it can be recreated.
|
||||
await supabase.from('organization').delete().match({ siteName: fields.siteName });
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await getOrganizations($profile.id);
|
||||
}
|
||||
|
||||
// client
|
||||
}
|
||||
|
||||
if (step === maxSteps) {
|
||||
// Submit filled
|
||||
|
||||
// Set extra metadata based off location
|
||||
await setMetaData();
|
||||
|
||||
await supabase
|
||||
.from('profile')
|
||||
.update({
|
||||
...fields,
|
||||
orgName: undefined,
|
||||
siteName: undefined
|
||||
})
|
||||
.match({ id: $profile.id });
|
||||
|
||||
loading = false;
|
||||
|
||||
if (fields.fullname) {
|
||||
$profile.fullname = fields.fullname;
|
||||
}
|
||||
if (fields.locale) {
|
||||
$profile.locale = fields.locale;
|
||||
|
||||
if (fields.locale !== LOCALE.EN) {
|
||||
handleLocaleChange(fields.locale);
|
||||
}
|
||||
}
|
||||
triggerSendEmail(NOTIFICATION_NAME.VERIFY_EMAIL, {
|
||||
to: $profile.email,
|
||||
profileId: $profile.id,
|
||||
fullname: $profile.fullname,
|
||||
orgSiteName: fields.siteName
|
||||
});
|
||||
|
||||
const welcomePopup = `${$profile.is_email_verified}`;
|
||||
|
||||
return goto(`/org/${fields.siteName}?welcomePopup=${welcomePopup}`);
|
||||
}
|
||||
|
||||
// Move to next step
|
||||
step = step + 1;
|
||||
loading = false;
|
||||
};
|
||||
const progress = $derived(Math.round((onboardingApi.step / Object.keys(ONBOARDING_STEPS).length) * 100));
|
||||
|
||||
function updateSiteName(sname?: string) {
|
||||
if (!sname) return;
|
||||
@@ -224,6 +29,7 @@
|
||||
fields.siteName = generateSitename(sname);
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
updateSiteName(fields.siteName);
|
||||
});
|
||||
@@ -238,13 +44,14 @@
|
||||
?.replace(/[^a-zA-Z0-9-]/g, '');
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
setOrgSiteName(fields.orgName, isSiteNameTouched);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $profile.id}
|
||||
<div class="flex min-h-screen w-full justify-center">
|
||||
<div class="flex min-h-screen w-full justify-center dark:bg-neutral-900">
|
||||
<div class="flex w-9/12 max-w-md flex-col items-center justify-center">
|
||||
<!-- Header With Logo -->
|
||||
<div class="flex flex-col items-center">
|
||||
@@ -257,13 +64,13 @@
|
||||
<div
|
||||
class="mb-6 flex w-64 items-center justify-center rounded-2xl border border-gray-300 bg-gray-100 py-6 dark:bg-neutral-800"
|
||||
>
|
||||
<UserProfileIcon size={16} />
|
||||
<UserProfileIcon />
|
||||
<p class="ml-2 text-sm dark:text-white">{$profile.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-container w-full overflow-y-auto px-2">
|
||||
{#if step === 1}
|
||||
{#if onboardingApi.step === ONBOARDING_STEPS.ORG_SETUP}
|
||||
<!-- Name/Organization Question -->
|
||||
<div id="role-question" class="mb-6 flex flex-col items-start">
|
||||
<!-- Full name -->
|
||||
@@ -275,7 +82,7 @@
|
||||
placeholder="e.g Joke Silva"
|
||||
className="mb-5 w-full"
|
||||
labelClassName="text-lg font-normal"
|
||||
errorMessage={errors.fullname}
|
||||
errorMessage={onboardingApi.errors.fullname}
|
||||
isRequired
|
||||
/>
|
||||
|
||||
@@ -288,7 +95,7 @@
|
||||
placeholder="e.g Education For All"
|
||||
className="mb-5 w-full"
|
||||
labelClassName="text-lg font-normal"
|
||||
errorMessage={errors.orgName}
|
||||
errorMessage={onboardingApi.errors.orgName}
|
||||
isRequired
|
||||
/>
|
||||
|
||||
@@ -302,7 +109,7 @@
|
||||
placeholder="e.g edforall"
|
||||
className="mb-5 w-full"
|
||||
labelClassName="text-lg font-normal"
|
||||
errorMessage={errors.siteName}
|
||||
errorMessage={onboardingApi.errors.siteName}
|
||||
onChange={() => {
|
||||
isSiteNameTouched = true;
|
||||
}}
|
||||
@@ -320,16 +127,16 @@
|
||||
</label>
|
||||
|
||||
<!-- Loop through Goals -->
|
||||
{#each educatorGoals as goal}
|
||||
{#each GOALS as goal}
|
||||
<label class="mb-1 inline-flex w-full items-center font-light dark:text-white">
|
||||
<input type="radio" bind:group={fields.goal} name="goal" value={goal.value} class="mr-2" />
|
||||
{goal.label}
|
||||
{$t(goal.label)}
|
||||
</label>
|
||||
{/each}
|
||||
<!-- Goal: Error message -->
|
||||
{#if errors.goal}
|
||||
{#if onboardingApi.errors.goal}
|
||||
<p class="text-sm text-red-500">
|
||||
{errors.goal}
|
||||
{onboardingApi.errors.goal}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -341,16 +148,16 @@
|
||||
</label>
|
||||
|
||||
<!-- Loop through Goals -->
|
||||
{#each sources as source}
|
||||
{#each SOURCES as source}
|
||||
<label class="mb-1 inline-flex w-full items-center font-light dark:text-white">
|
||||
<input type="radio" bind:group={fields.source} name="source" value={source.value} class="mr-2" />
|
||||
{source.label}
|
||||
{$t(source.label)}
|
||||
</label>
|
||||
{/each}
|
||||
<!-- Goal: Error message -->
|
||||
{#if errors.source}
|
||||
{#if onboardingApi.errors.source}
|
||||
<p class="text-sm text-red-500">
|
||||
{errors.source}
|
||||
{onboardingApi.errors.source}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -360,10 +167,10 @@
|
||||
<span class="dark:text-white">{$t('content.toggle_label')}: </span>
|
||||
<Select.Root type="single" bind:value={fields.locale}>
|
||||
<Select.Trigger class="w-full">
|
||||
<p>{fields.locale}</p>
|
||||
<p>{DROPDOWN_ITEMS.find((item) => item.id === fields.locale)?.text}</p>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each dropdownItems as item}
|
||||
{#each DROPDOWN_ITEMS as item}
|
||||
<Select.Item value={item.id}>{item.text}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
@@ -381,12 +188,12 @@
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
{#if step > 1}
|
||||
{#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={() => (step = step - 1)}
|
||||
onClick={() => (onboardingApi.step = ONBOARDING_STEPS.ORG_SETUP)}
|
||||
/>
|
||||
{/if}
|
||||
<PrimaryButton
|
||||
@@ -394,8 +201,8 @@
|
||||
variant={VARIANTS.CONTAINED}
|
||||
type="button"
|
||||
className="px-5 py-3"
|
||||
isLoading={loading}
|
||||
onClick={handleSubmit}
|
||||
isLoading={onboardingApi.isLoading}
|
||||
onClick={() => onboardingApi.submit(fields)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
export const load = ({ params = { slug: '' } }) => {
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { getSubdomain } from '$lib/features/app/layout-setup';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
const APP_SUBDOMAINS = env.PRIVATE_APP_SUBDOMAINS?.split(',') || ['app'];
|
||||
|
||||
export const load = async ({ params, url }) => {
|
||||
const subdomain = getSubdomain(url);
|
||||
const isOrgSite = subdomain && !APP_SUBDOMAINS.includes(subdomain);
|
||||
|
||||
// If this is LMS but user is on org site, redirect to LMS
|
||||
if (isOrgSite) {
|
||||
redirect(307, `/lms`);
|
||||
}
|
||||
|
||||
return {
|
||||
orgName: params.slug
|
||||
};
|
||||
|
||||
@@ -1,89 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import Settings2Icon from '@lucide/svelte/icons/settings-2';
|
||||
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
|
||||
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard';
|
||||
import MessageSquareMoreIcon from '@lucide/svelte/icons/message-square-more';
|
||||
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { isQuizPage } from '$lib/utils/functions/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { orgs, isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { AppHeader } from '$lib/features/ui';
|
||||
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
import OrgSideBar from '$lib/components/Sidebar/index.svelte';
|
||||
import { VerifyEmailModal } from '$lib/features/onboarding/components';
|
||||
|
||||
import { OrgSidebar } from '$lib/features/ui/sidebar/org-sidebar/index.js';
|
||||
import AddOrgModal from '$lib/components/Org/AddOrgModal/AddOrgModal.svelte';
|
||||
import VerifyEmailModal from '$lib/components/Org/VerifyEmail/VerifyEmailModal.svelte';
|
||||
|
||||
let { data, children } = $props();
|
||||
|
||||
function redirect(siteName: string) {
|
||||
if (!siteName) return;
|
||||
|
||||
const newUrl = page.url.pathname.replace('*', siteName);
|
||||
goto(newUrl + page.url.search);
|
||||
}
|
||||
|
||||
function isActive(pagePath: string, itemPath: string) {
|
||||
const pageLinkItems = pagePath.split('/');
|
||||
const itemLinkItems = itemPath.split('/');
|
||||
|
||||
if (itemLinkItems.length !== pageLinkItems.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return pagePath.includes(itemPath);
|
||||
}
|
||||
|
||||
const sidebarData = $derived({
|
||||
teams: $orgs,
|
||||
navMain: [
|
||||
{
|
||||
title: $t('org_navigation.dashboard'),
|
||||
url: '/',
|
||||
icon: LayoutDashboardIcon,
|
||||
isActive: isActive(page.url.pathname, `/org/${data.orgName}`)
|
||||
},
|
||||
{
|
||||
title: $t('org_navigation.courses'),
|
||||
url: `/courses`,
|
||||
icon: LibraryBigIcon,
|
||||
isActive: isActive(page.url.pathname, `/org/${data.orgName}/courses`)
|
||||
},
|
||||
{
|
||||
title: $t('org_navigation.community'),
|
||||
url: `/community`,
|
||||
icon: MessageSquareMoreIcon,
|
||||
isActive: isActive(page.url.pathname, `/org/${data.orgName}/community`)
|
||||
},
|
||||
{
|
||||
title: $t('org_navigation.audience'),
|
||||
url: `/audience`,
|
||||
icon: UsersIcon,
|
||||
isActive: isActive(page.url.pathname, `/org/${data.orgName}/audience`)
|
||||
},
|
||||
...(!!$isOrgAdmin
|
||||
? [
|
||||
{
|
||||
title: $t('org_navigation.setup'),
|
||||
url: `/setup`,
|
||||
icon: Settings2Icon,
|
||||
isActive: isActive(page.url.pathname, `/org/${data.orgName}/setup`)
|
||||
}
|
||||
]
|
||||
: [])
|
||||
],
|
||||
user: {
|
||||
name: $profile.fullname,
|
||||
email: $profile.email,
|
||||
avatar: $profile.avatar_url
|
||||
}
|
||||
});
|
||||
|
||||
const isSidebarReady = $derived($orgs.length > 0 && $profile.fullname && $profile.email);
|
||||
|
||||
$effect(() => {
|
||||
data.orgName === '*' && redirect($currentOrg.siteName);
|
||||
});
|
||||
@@ -93,76 +29,23 @@
|
||||
|
||||
<VerifyEmailModal />
|
||||
|
||||
<div class="org-root flex w-full items-center justify-between">
|
||||
<div class="org-slot flex w-full items-start bg-white dark:bg-black">
|
||||
{#if !isQuizPage(page.url?.pathname)}
|
||||
{#if isSidebarReady}
|
||||
<OrgSideBar data={sidebarData} />
|
||||
{:else}
|
||||
<div class="bg-sidebar flex h-[calc(100vh-48px)] w-64 flex-col gap-2 border-r p-2">
|
||||
<!-- org switcher -->
|
||||
<div class="flex items-center gap-2 rounded-md p-2">
|
||||
<Skeleton class="h-8 w-8 rounded-lg" />
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<Skeleton class="h-4 w-24" />
|
||||
<Skeleton class="h-3 w-16" />
|
||||
</div>
|
||||
<Skeleton class="h-4 w-4" />
|
||||
</div>
|
||||
<Sidebar.Provider>
|
||||
<OrgSidebar />
|
||||
|
||||
<!-- nav main -->
|
||||
<div class="flex flex-col gap-1 py-2">
|
||||
<Skeleton class="mb-2 h-3 w-16 px-2" />
|
||||
{#each Array(5) as _, i (i)}
|
||||
<div class="flex items-center gap-2 rounded-md p-2">
|
||||
<Skeleton class="h-4 w-4" />
|
||||
<Skeleton class="h-4 w-full" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<Sidebar.Inset>
|
||||
<AppHeader />
|
||||
|
||||
<!-- spacer -->
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<!-- upgrade banner -->
|
||||
<div class="mx-4 mb-2 flex flex-col items-center gap-3 rounded-md border p-4">
|
||||
<Skeleton class="h-16 w-16 rounded-md" />
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
<Skeleton class="h-4 w-32" />
|
||||
<Skeleton class="h-3 w-40" />
|
||||
</div>
|
||||
<Skeleton class="h-9 w-24 rounded-md" />
|
||||
</div>
|
||||
|
||||
<!-- nav user -->
|
||||
<div class="flex items-center gap-2 rounded-md p-2">
|
||||
<Skeleton class="h-8 w-8 rounded-lg" />
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<Skeleton class="h-4 w-24" />
|
||||
<Skeleton class="h-3 w-20" />
|
||||
</div>
|
||||
<Skeleton class="h-4 w-4" />
|
||||
</div>
|
||||
<div class="container mx-auto flex max-w-6xl flex-1 flex-col gap-4 p-4 pt-0">
|
||||
{#if data.orgName === '*'}
|
||||
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
|
||||
<Skeleton class="aspect-video rounded-xl" />
|
||||
<Skeleton class="aspect-video rounded-xl" />
|
||||
<Skeleton class="aspect-video rounded-xl" />
|
||||
</div>
|
||||
<Skeleton class="h-[50vh] w-full rounded-xl" />
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex-1">
|
||||
{#if data.orgName === '*'}
|
||||
<Box>Taking you to your organization...</Box>
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !isQuizPage(page.url?.pathname)}
|
||||
<div class="flex-1">
|
||||
{#if data.orgName === '*'}
|
||||
<Box>Taking you to your organization...</Box>
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Sidebar.Inset>
|
||||
</Sidebar.Provider>
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
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/Grid.svelte';
|
||||
import { Grid } from '$lib/components/Org/Settings/Layout';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import WelcomeModal from '$lib/components/WelcomeModal/WelcomeModal.svelte';
|
||||
import { WelcomeModal } from '$lib/features/onboarding/components';
|
||||
import VisitOrgSiteButton from '$lib/components/Buttons/VisitOrgSite.svelte';
|
||||
|
||||
let dashAnalytics: OrganisationAnalytics | undefined = $state();
|
||||
@@ -98,9 +98,9 @@
|
||||
|
||||
<WelcomeModal />
|
||||
|
||||
<div class="w-full px-5 py-10 md:mx-auto">
|
||||
<div class="w-full">
|
||||
<div class="mb-5 flex items-center justify-between">
|
||||
<h1 class="mb-3 text-2xl dark:text-white">
|
||||
<h1 class="m-0 text-xl font-medium dark:text-white">
|
||||
{$t(getGreeting())}
|
||||
{$profile.fullname}!
|
||||
</h1>
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
<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-end">
|
||||
<div class="flex items-center">
|
||||
<h1 class="m-0 text-2xl md:text-3xl dark:text-white">{$t('audience.title')}</h1>
|
||||
{#if $currentOrgPlan?.plan_name !== PLAN.ENTERPRISE}
|
||||
{#if $currentOrgPlan?.planName !== PLAN.ENTERPRISE}
|
||||
<span class="ml-2">
|
||||
({$orgAudience.length} / {$currentOrgMaxAudience})
|
||||
</span>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
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/Grid.svelte';
|
||||
import { Grid } from '$lib/components/Org/Settings/Layout';
|
||||
import { ActivityCard, HeroProfileCard, LoadingPage } from '$lib/components/Analytics';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
@@ -58,20 +58,20 @@
|
||||
icon: BookOpenIcon,
|
||||
title: $t('analytics.enrolled_courses'),
|
||||
description: $t('analytics.enrolled_courses_description'),
|
||||
percentage: userAnalytics?.courses?.length,
|
||||
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
|
||||
percentage: userAnalytics?.overallCourseProgress || 0
|
||||
},
|
||||
{
|
||||
icon: UnfoldVerticalIcon,
|
||||
title: $t('analytics.total_average_grade'),
|
||||
description: $t('analytics.total_average_grade_description'),
|
||||
percentage: userAnalytics?.overallAverageGrade
|
||||
percentage: userAnalytics?.overallAverageGrade || 0
|
||||
}
|
||||
]);
|
||||
|
||||
|
||||
@@ -17,18 +17,16 @@
|
||||
<title>Community - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="w-full md:mx-auto md:max-w-6xl">
|
||||
<div class="w-full px-5 py-10">
|
||||
<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 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>
|
||||
|
||||
@@ -115,53 +115,51 @@
|
||||
<title>Courses - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="w-full md:mx-auto md:max-w-6xl">
|
||||
<div class="px-2 py-2 md:px-5 md:py-10">
|
||||
<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>
|
||||
<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}
|
||||
<PrimaryButton
|
||||
label={$t('courses.heading_button')}
|
||||
variant={VARIANTS.CONTAINED_DARK}
|
||||
isDisabled={!$isOrgAdmin}
|
||||
onClick={openNewCourseModal}
|
||||
/>
|
||||
<IconButton onClick={() => setViewPreference('list')}>
|
||||
<ListIcon size={16} />
|
||||
</IconButton>
|
||||
{/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} />
|
||||
</div>
|
||||
|
||||
<NewCourseModal />
|
||||
<Courses courses={filteredCourses} />
|
||||
</section>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
const setupList = $derived(
|
||||
(data.setup || []).map((item) => {
|
||||
if (item.id === 'profile') {
|
||||
item.is_completed = !$profile.avatar_url.includes('avatars/avatar.png');
|
||||
item.is_completed = !$profile.avatarUrl?.includes('avatars/avatar.png');
|
||||
}
|
||||
return item;
|
||||
})
|
||||
@@ -31,11 +31,11 @@
|
||||
PUBLISH_COURSE: 'publish'
|
||||
};
|
||||
|
||||
const isCompleted = (id) => {
|
||||
const isCompleted = (id: string) => {
|
||||
return setupList?.find((c) => c.id === id)?.is_completed == true;
|
||||
};
|
||||
|
||||
const handleClick = (id) => {
|
||||
const handleClick = (id: string) => {
|
||||
switch (id) {
|
||||
case StepsEnum.CREATE_COURSE:
|
||||
goto(`/org/${$currentOrg.siteName}/courses?create=true`);
|
||||
@@ -86,7 +86,7 @@
|
||||
<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"
|
||||
className="text-[10px] font-semibold px-3 py-1!"
|
||||
shape="rounded-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@
|
||||
>
|
||||
<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" />
|
||||
<Chip value={i + 1} className="text-[10px] font-semibold py-1" shape="rounded-full" />
|
||||
<p class="text-lg font-medium">{$t(list.title)}</p>
|
||||
</div>
|
||||
<p class="text-sm">
|
||||
@@ -108,7 +108,7 @@
|
||||
<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"
|
||||
className="w-full font-normal text-sm flex items-center gap-2"
|
||||
onClick={() => handleClick(list.id)}
|
||||
isDisabled={list.is_completed}
|
||||
>
|
||||
|
||||
@@ -1,55 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
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 { getSupabase } from '$lib/utils/functions/supabase';
|
||||
import { resetValidation, getConfirmPasswordError, getDisableSubmit } from '$lib/utils/functions/validator';
|
||||
import { RESET_FIELDS } from '$lib/utils/constants/authentication';
|
||||
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 supabase = getSupabase();
|
||||
let fields = $state(Object.assign({}, RESET_FIELDS));
|
||||
let loading = $state(false);
|
||||
let errors: {
|
||||
email?: string;
|
||||
password?: string;
|
||||
confirmPassword?: string;
|
||||
} = $state({});
|
||||
let submitError = $state();
|
||||
let formRef: HTMLFormElement | undefined = $state();
|
||||
let fields: TResetPasswordForm = $state({
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
token: ''
|
||||
});
|
||||
|
||||
const disableSubmit = $derived(getDisableSubmit(fields));
|
||||
const isSubmitDisabled = $derived!!(
|
||||
fields.password && fields.confirmPassword && fields.password !== fields.confirmPassword
|
||||
);
|
||||
const token = $derived(new URLSearchParams(page.url.search).get('token'));
|
||||
|
||||
async function handleSubmit() {
|
||||
errors = resetValidation(fields);
|
||||
console.log('errors', errors);
|
||||
onMount(() => {
|
||||
if (token) return;
|
||||
|
||||
if (Object.keys(errors).length) {
|
||||
return;
|
||||
}
|
||||
snackbar.error('Invalid Token');
|
||||
|
||||
try {
|
||||
loading = true;
|
||||
const { data, error } = await supabase.auth.updateUser({
|
||||
password: fields.password
|
||||
});
|
||||
console.log('data', data);
|
||||
if (error) throw error;
|
||||
setTimeout(() => {
|
||||
goto(resolve('/login', {}));
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
formRef?.reset();
|
||||
|
||||
return goto('/login');
|
||||
} catch (error) {
|
||||
submitError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setConfirmPasswordError(fields) {
|
||||
function setConfirmPasswordError(fields: TResetPasswordForm) {
|
||||
untrack(() => {
|
||||
errors.confirmPassword = getConfirmPasswordError(fields);
|
||||
const errors = { ...resetApi.errors };
|
||||
errors.confirmPassword = getConfirmPasswordError(fields) ?? '';
|
||||
resetApi.setError(errors);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,17 +46,15 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Join ClassroomIO</title>
|
||||
<title>Reset Password - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<AuthUI
|
||||
{supabase}
|
||||
isLogin={false}
|
||||
{handleSubmit}
|
||||
handleSubmit={() => resetApi.submit({ ...fields, token: token ?? '' })}
|
||||
showOnlyContent={true}
|
||||
isLoading={loading}
|
||||
isLoading={resetApi.isLoading}
|
||||
showLogo={true}
|
||||
bind:formRef
|
||||
>
|
||||
<div class="mt-4 w-full">
|
||||
<h3 class="my-3 text-xl font-semibold dark:text-white">New Password</h3>
|
||||
@@ -81,9 +66,9 @@
|
||||
placeholder="************"
|
||||
className="mb-6"
|
||||
inputClassName="w-full"
|
||||
isDisabled={loading}
|
||||
errorMessage={errors.password}
|
||||
helperMessage="Password must be more than 6 characters"
|
||||
isDisabled={resetApi.isLoading}
|
||||
errorMessage={resetApi.errors.password}
|
||||
helperMessage="Password must be more than 8 characters"
|
||||
isRequired
|
||||
/>
|
||||
<TextField
|
||||
@@ -93,13 +78,10 @@
|
||||
placeholder="************"
|
||||
className="mb-6"
|
||||
inputClassName="w-full"
|
||||
isDisabled={loading}
|
||||
errorMessage={errors.confirmPassword}
|
||||
isDisabled={resetApi.isLoading}
|
||||
errorMessage={resetApi.errors.confirmPassword}
|
||||
isRequired
|
||||
/>
|
||||
{#if submitError}
|
||||
<p class="text-sm text-red-500">{submitError}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="my-4 flex w-full items-center justify-end">
|
||||
@@ -107,8 +89,8 @@
|
||||
label="Reset Password"
|
||||
type="submit"
|
||||
className="sm:w-full w-full"
|
||||
isDisabled={disableSubmit || loading}
|
||||
isLoading={loading}
|
||||
isDisabled={isSubmitDisabled || resetApi.isLoading}
|
||||
isLoading={resetApi.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</AuthUI>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import AuthUI from '$lib/components/AuthUI/index.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
@@ -12,13 +10,11 @@
|
||||
import { authValidation, getConfirmPasswordError, getDisableSubmit } from '$lib/utils/functions/validator';
|
||||
import { capturePosthogEvent } from '$lib/utils/services/posthog';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { authClient } from '$lib/utils/services/auth/client';
|
||||
|
||||
let supabase = getSupabase();
|
||||
let fields = $state(Object.assign({}, SIGNUP_FIELDS));
|
||||
let loading = $state(false);
|
||||
let success = false;
|
||||
let errors: {
|
||||
email?: string;
|
||||
password?: string;
|
||||
@@ -27,9 +23,6 @@
|
||||
let submitError: string = $state('');
|
||||
let formRef: HTMLFormElement | undefined = $state();
|
||||
|
||||
let query = new URLSearchParams(page.url.search);
|
||||
let redirect = query.get('redirect');
|
||||
|
||||
const disableSubmit = $derived(getDisableSubmit(fields));
|
||||
|
||||
async function handleSubmit() {
|
||||
@@ -43,74 +36,37 @@
|
||||
|
||||
try {
|
||||
loading = true;
|
||||
const name = fields.email.split('@')[0];
|
||||
|
||||
const {
|
||||
data: { session },
|
||||
error
|
||||
} = await supabase.auth.signUp({
|
||||
email: fields.email,
|
||||
password: fields.password
|
||||
});
|
||||
console.log('session', session);
|
||||
const { error } = await authClient.signUp.email(
|
||||
{
|
||||
email: fields.email,
|
||||
password: fields.password,
|
||||
name: name
|
||||
},
|
||||
{
|
||||
onSuccess: (ctx) => {
|
||||
console.log('Signup successful');
|
||||
capturePosthogEvent('user_signed_up', {
|
||||
distinct_id: ctx.data.user.id || '',
|
||||
email: ctx.data.user.email,
|
||||
username: name
|
||||
});
|
||||
|
||||
if ($globalStore.isOrgSite) {
|
||||
capturePosthogEvent('student_signed_up', {
|
||||
distinct_id: ctx.data.user.id || '',
|
||||
email: ctx.data.user.email,
|
||||
username: name
|
||||
});
|
||||
}
|
||||
|
||||
window.location.href = '/';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const { user: authUser } = session || {};
|
||||
if (!authUser) {
|
||||
throw 'Error creating user';
|
||||
}
|
||||
|
||||
if (!$currentOrg.id) return;
|
||||
|
||||
const [regexUsernameMatch] = [...(authUser.email?.matchAll(/(.*)@/g) || [])];
|
||||
const response = await fetch('https://api.ipregistry.co/?key=tryout');
|
||||
const metadata = await response.json();
|
||||
|
||||
const profileRes = await supabase
|
||||
.from('profile')
|
||||
.insert({
|
||||
id: authUser.id,
|
||||
username: regexUsernameMatch[1] + `${new Date().getTime()}`,
|
||||
fullname: regexUsernameMatch[1],
|
||||
email: authUser.email,
|
||||
metadata
|
||||
})
|
||||
.select();
|
||||
console.log('profileRes', profileRes);
|
||||
|
||||
if (profileRes.error) {
|
||||
throw profileRes.error;
|
||||
}
|
||||
|
||||
// Setting profile
|
||||
console.log('setting profile', profileRes.data[0]);
|
||||
profile.set(profileRes.data[0]);
|
||||
|
||||
capturePosthogEvent('user_signed_up', {
|
||||
distinct_id: $profile.id || '',
|
||||
email: authUser.email,
|
||||
username: regexUsernameMatch[1],
|
||||
metadata
|
||||
});
|
||||
|
||||
if ($globalStore.isOrgSite) {
|
||||
capturePosthogEvent('student_signed_up', {
|
||||
distinct_id: $profile.id || '',
|
||||
email: authUser.email,
|
||||
username: regexUsernameMatch[1],
|
||||
metadata
|
||||
});
|
||||
}
|
||||
|
||||
if (redirect) {
|
||||
goto(redirect);
|
||||
} else {
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
formRef?.reset();
|
||||
success = true;
|
||||
fields = Object.assign({}, SIGNUP_FIELDS);
|
||||
} catch (error: any) {
|
||||
submitError = error?.error_description || error?.message;
|
||||
loading = false;
|
||||
|
||||
Reference in New Issue
Block a user