feat: 🚀 😃 convert repo into a monorepo 🚀 😃 (#51)
* Merge main into turbo-monrepo * feat: rename apps * feat: remove supbase videos and serve all from project * feat: move supabase to root * feat: update README * docs: update link
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { generateSitename } from '$lib/utils/functions/org';
|
||||
import { getOrganizations } from '$lib/utils/services/org';
|
||||
import { blockedSubdomain } from '$lib/utils/constants/app';
|
||||
import { newOrgModal } from '../store';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { createOrgValidation } from '$lib/utils/functions/validator';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
type Error = {
|
||||
orgName: string;
|
||||
siteName: string;
|
||||
};
|
||||
let loading = false;
|
||||
let orgName = '';
|
||||
let siteName = '';
|
||||
|
||||
let errors: Error = {
|
||||
orgName: '',
|
||||
siteName: ''
|
||||
};
|
||||
|
||||
function resetForm() {
|
||||
orgName = '';
|
||||
siteName = '';
|
||||
loading = false;
|
||||
|
||||
errors = {
|
||||
orgName: '',
|
||||
siteName: ''
|
||||
};
|
||||
}
|
||||
|
||||
async function createNewOrg() {
|
||||
errors = createOrgValidation({
|
||||
orgName,
|
||||
siteName
|
||||
}) as Error;
|
||||
|
||||
if (Object.values(errors).length) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
// Validate if domain is among our seculeded subdomains
|
||||
if (blockedSubdomain.includes(siteName || '')) {
|
||||
errors.siteName = 'Sitename already exists.';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: org, error } = await supabase
|
||||
.from('organization')
|
||||
.insert({
|
||||
name: orgName,
|
||||
siteName: siteName
|
||||
})
|
||||
.select();
|
||||
console.log('Create organisation', org);
|
||||
|
||||
if (error) {
|
||||
console.log('Error: create organisation', error);
|
||||
errors.siteName = 'Sitename already exists.';
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(org) && org.length) {
|
||||
const orgData = org[0];
|
||||
const { data, error } = await supabase
|
||||
.from('organizationmember')
|
||||
.insert({
|
||||
organization_id: orgData.id,
|
||||
profile_id: $profile.id,
|
||||
role_id: 1
|
||||
})
|
||||
.select();
|
||||
|
||||
console.log('Create organisation member', data);
|
||||
|
||||
if (error) {
|
||||
console.log('Error: create organisation member', error);
|
||||
errors.siteName =
|
||||
'Something went wrong while creating this organization. Please reload and try again';
|
||||
|
||||
// Delete organization so it can be recreated.
|
||||
await supabase.from('organization').delete().match({ siteName });
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
snackbar.success();
|
||||
await getOrganizations($profile.id);
|
||||
|
||||
resetForm();
|
||||
|
||||
goto(`/org/${siteName}`);
|
||||
$newOrgModal.open = false;
|
||||
}
|
||||
}
|
||||
|
||||
$: siteName = generateSitename(orgName);
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
onClose={() => ($newOrgModal.open = false)}
|
||||
bind:open={$newOrgModal.open}
|
||||
width="w-96"
|
||||
modalHeading="Create Organization"
|
||||
>
|
||||
<form on:submit|preventDefault={createNewOrg} class="px-2">
|
||||
<TextField
|
||||
label="Name of Organization"
|
||||
bind:value={orgName}
|
||||
autoFocus={true}
|
||||
placeholder="e.g Pepsi Co"
|
||||
className="mb-4"
|
||||
isRequired={true}
|
||||
errorMessage={errors.orgName}
|
||||
autoComplete={false}
|
||||
/>
|
||||
<!-- Org Site Name -->
|
||||
<TextField
|
||||
label="Organisation Site name"
|
||||
helperMessage={`https://${siteName || ''}.classroomio.com`}
|
||||
bind:value={siteName}
|
||||
name="sitename"
|
||||
type="text"
|
||||
placeholder="e.g edforall"
|
||||
className="mb-5 w-full"
|
||||
labelClassName="text-lg font-normal"
|
||||
errorMessage={errors.siteName}
|
||||
isRequired={true}
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex items-center flex-row-reverse">
|
||||
<PrimaryButton className="px-6 py-3" label="Create" type="submit" isLoading={loading} />
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script>
|
||||
import {
|
||||
DataTable,
|
||||
Toolbar,
|
||||
ToolbarContent,
|
||||
ToolbarSearch,
|
||||
Pagination,
|
||||
DataTableSkeleton,
|
||||
} from 'carbon-components-svelte';
|
||||
import { orgAudience } from '$lib/utils/store/org';
|
||||
|
||||
export let isLoading = false;
|
||||
|
||||
const headers = [
|
||||
{ key: 'name', value: 'Name' },
|
||||
{ key: 'email', value: 'Email' },
|
||||
{ key: 'date_joined', value: 'Date Joined' },
|
||||
];
|
||||
let pageSize = 5;
|
||||
let page = 1;
|
||||
let filteredRowIds = [];
|
||||
let searchValue = '';
|
||||
</script>
|
||||
|
||||
{#if isLoading}
|
||||
<DataTableSkeleton {headers} rows={pageSize} />
|
||||
{:else}
|
||||
<DataTable size="tall" {headers} rows={$orgAudience} {pageSize} {page}>
|
||||
<Toolbar>
|
||||
<ToolbarContent>
|
||||
<ToolbarSearch
|
||||
persistent
|
||||
value={searchValue}
|
||||
shouldFilterRows
|
||||
bind:filteredRowIds
|
||||
/>
|
||||
</ToolbarContent>
|
||||
</Toolbar>
|
||||
</DataTable>
|
||||
{/if}
|
||||
|
||||
<Pagination
|
||||
bind:pageSize
|
||||
bind:page
|
||||
totalItems={filteredRowIds.length}
|
||||
pageSizeInputDisabled
|
||||
/>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script>
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
import AudienceList from './AudienceList.svelte';
|
||||
import AudienceEmptyIcon from '$lib/components/Icons/AudienceEmptyIcon.svelte';
|
||||
import { getOrgAudience } from '$lib/utils/services/org';
|
||||
import { orgAudience, currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
let isLoading = false;
|
||||
|
||||
async function fetchInitData(orgId) {
|
||||
if (!orgId) return;
|
||||
|
||||
isLoading = true;
|
||||
await getOrgAudience(orgId);
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
$: fetchInitData($currentOrg.id);
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-center lg:justify-start flex-wrap my-4 m-auto">
|
||||
{#if $orgAudience.length || isLoading}
|
||||
<AudienceList {isLoading} />
|
||||
{:else}
|
||||
<Box>
|
||||
<AudienceEmptyIcon />
|
||||
<h3 class="dark:text-white text-2xl text-center my-5">No audience to manage!</h3>
|
||||
<p class="dark:text-white w-1/3 text-center">
|
||||
Manage all your students here and stay connected with them.
|
||||
</p>
|
||||
</Box>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script>
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
|
||||
export let onDelete = () => {};
|
||||
export let onCancel = () => {};
|
||||
export let open = false;
|
||||
</script>
|
||||
|
||||
<Modal onClose={() => (open = false)} bind:open width="w-96" modalHeading="Delete comment">
|
||||
<div>
|
||||
<h1 class="dark:text-white text-lg">Are you sure you want to delete this comment?</h1>
|
||||
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
label="No, keep it"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
variant={VARIANTS.CONTAINED}
|
||||
label="Yes, delete"
|
||||
onClick={onDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { SkeletonPlaceholder, SkeletonText } from 'carbon-components-svelte';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="h-[70px] w-full border border-gray-100 dark:bg-neutral-800 mr-4 mb-4 mt-2 rounded-md transition ease-in-out relative flex pt-3 px-3"
|
||||
>
|
||||
<div class="w-10 flex justify-evenly flex-col px-1 mr-2 rounded-md">
|
||||
<SkeletonPlaceholder style="width: 100%; height: 40%; border-radius: 4px;" />
|
||||
<SkeletonPlaceholder style="width: 100%; height: 40%; border-radius: 4px;" />
|
||||
</div>
|
||||
|
||||
<div class="w-9/12 mt-1.5 flex justify-evenly flex-col rounded-md">
|
||||
<!-- first text -->
|
||||
<div class="w-3/6 h-full">
|
||||
<SkeletonText style="height: 50%; border-radius: 4px;" />
|
||||
</div>
|
||||
<!-- second text -->
|
||||
<SkeletonText style="width: 10%; height: 50%; border-radius: 4px;" />
|
||||
</div>
|
||||
|
||||
<div class="w-10 justify-evenly flex-col px-1 mt-1.5 ml-auto rounded-md">
|
||||
<SkeletonPlaceholder style="width: 100%; height: 41%; border-radius: 4px;" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import AddCommentIcon from 'carbon-icons-svelte/lib/AddComment.svelte';
|
||||
import CommunityLoader from './Loader.svelte';
|
||||
import Vote from '$lib/components/Vote/index.svelte';
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
import CoursesEmptyIcon from '$lib/components/Icons/CoursesEmptyIcon.svelte';
|
||||
import Space from '$lib/components/Space/index.svelte';
|
||||
import { currentOrgPath, currentOrg } from '$lib/utils/store/org';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
|
||||
export let isLMS = false;
|
||||
|
||||
let isLoading = false;
|
||||
let discussions = [];
|
||||
|
||||
async function fetchCommunityQuestions(orgId) {
|
||||
if (!orgId) return;
|
||||
isLoading = true;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('community_question')
|
||||
.select(
|
||||
`
|
||||
organization_id,
|
||||
title,
|
||||
votes,
|
||||
created_at,
|
||||
slug,
|
||||
comments:community_answer(count),
|
||||
author:organizationmember!community_question_author_id_fkey!inner(
|
||||
profile!inner(fullname)
|
||||
)
|
||||
`
|
||||
)
|
||||
.eq('organization_id', orgId);
|
||||
console.log('data', data);
|
||||
console.log('error', error);
|
||||
isLoading = false;
|
||||
|
||||
if (error) {
|
||||
console.error('Error loading community', error);
|
||||
return goto(isLMS ? '/lms' : $currentOrgPath);
|
||||
}
|
||||
|
||||
discussions =
|
||||
data?.map((discussion) => ({
|
||||
title: discussion.title,
|
||||
slug: discussion.slug,
|
||||
author: discussion?.author?.profile?.fullname,
|
||||
comments: discussion.comments?.[0]?.count || 0,
|
||||
votes: discussion.votes,
|
||||
createdAt: calDateDiff(discussion.created_at)
|
||||
})) || [];
|
||||
}
|
||||
|
||||
$: fetchCommunityQuestions($currentOrg.id);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-center lg:justify-start flex-wrap my-4 m-auto border-c rounded bg-gray-100 dark:bg-neutral-800"
|
||||
>
|
||||
{#if isLoading}
|
||||
<CommunityLoader />
|
||||
<CommunityLoader />
|
||||
<CommunityLoader />
|
||||
<CommunityLoader />
|
||||
{:else}
|
||||
{#each discussions as discussion}
|
||||
<div class="w-full flex border-bottom-c p-5">
|
||||
<Vote value={discussion.votes} />
|
||||
<div class="discussion-topic-author">
|
||||
<h4>
|
||||
<a
|
||||
class="text-black dark:text-white"
|
||||
href="{isLMS ? '/lms' : $currentOrgPath}/community/{discussion.slug}"
|
||||
>
|
||||
{discussion.title}
|
||||
</a>
|
||||
</h4>
|
||||
<span class="text-gray-600 dark:text-white">
|
||||
{discussion.author} asked {discussion.createdAt}
|
||||
</span>
|
||||
</div>
|
||||
<Space />
|
||||
<div class="flex items-center">
|
||||
<AddCommentIcon size={20} />
|
||||
<span class="ml-1">{discussion.comments}</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Box className="w-screen">
|
||||
<CoursesEmptyIcon />
|
||||
<h3 class="dark:text-white text-2xl my-5">No Questions asked</h3>
|
||||
<p class="dark:text-white w-1/3 text-center">Ask a question to the community</p>
|
||||
</Box>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
h4 {
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.discussion-topic-author {
|
||||
margin-right: 20px;
|
||||
}
|
||||
.discussion-topic-author span {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script>
|
||||
import { Search } from 'carbon-components-svelte';
|
||||
|
||||
let searchValue = '';
|
||||
</script>
|
||||
|
||||
<section class="bg-[#040f2d] min-h-[337px] w-full flex items-center justify-center p-">
|
||||
<div class="w-full flex items-center justify-center flex-col-reverse md:flex-row">
|
||||
<!-- Course Description -->
|
||||
<div class="course-descr w-11/12 py-10">
|
||||
<h1 class="dark:text-white text-5xl text-white text-center my-4 font-bold">
|
||||
Explore courses
|
||||
</h1>
|
||||
<p class="dark:text-white text-md text-white mb-6 text-center">
|
||||
Find courses you will love from best teachers all over the world🌎.
|
||||
</p>
|
||||
|
||||
<Search placeholder="Find a course..." bind:value={searchValue} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script>
|
||||
import { SkeletonPlaceholder, SkeletonText } from 'carbon-components-svelte';
|
||||
import CardLoader from '$lib/components/Courses/components/Card/Loader.svelte';
|
||||
</script>
|
||||
|
||||
<section class="w-screen h-screen p-5">
|
||||
<!-- navbar -->
|
||||
<div class="flex items-center justify-between w-full rounded-md overflow-hidden">
|
||||
<SkeletonText style="width:80%; margin-right:10rem;border-radius:10px" />
|
||||
<div class="flex items-center justify-between w-[20%]">
|
||||
<SkeletonText style="width:2%; margin-right:1rem; border-radius:10px" />
|
||||
<SkeletonText style="width:2%; margin-right:1rem; border-radius:10px" />
|
||||
<SkeletonText style="width:2%; border-radius:10px" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- hero -->
|
||||
<div
|
||||
class="flex flex-row gap-5 items-start justify-center flex-wrap w-full my-8 rounded-md overflow-hidden"
|
||||
>
|
||||
<SkeletonPlaceholder style="width:100%; height:20rem" />
|
||||
</div>
|
||||
<!-- cards -->
|
||||
<div class="flex gap-5 items-center justify-center flex-wrap my-4 rounded-md overflow-hidden">
|
||||
<CardLoader />
|
||||
<CardLoader />
|
||||
<CardLoader />
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,582 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import get from 'lodash/get';
|
||||
import { onMount } from 'svelte';
|
||||
import LogoFacebook from 'carbon-icons-svelte/lib/LogoFacebook.svelte';
|
||||
import LogoTwitter from 'carbon-icons-svelte/lib/LogoTwitter.svelte';
|
||||
import LogoLinkedin from 'carbon-icons-svelte/lib/LogoLinkedin.svelte';
|
||||
import { Accordion, AccordionItem, Grid, Row, Column } from 'carbon-components-svelte';
|
||||
import LocationFilled from 'carbon-icons-svelte/lib/LocationFilled.svelte';
|
||||
import Phone from 'carbon-icons-svelte/lib/Phone.svelte';
|
||||
import Email from 'carbon-icons-svelte/lib/Email.svelte';
|
||||
import Rocket from 'carbon-icons-svelte/lib/Rocket.svelte';
|
||||
import PageLoader from './PageLoader.svelte';
|
||||
import { orgLandingpageValidation } from '$lib/utils/functions/validator';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import Navigation from '$lib/components/Navigation/index.svelte';
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
import Card from '$lib/components/Courses/components/Card/index.svelte';
|
||||
import CardLoader from '$lib/components/Courses/components/Card/Loader.svelte';
|
||||
import CoursesEmptyIcon from '$lib/components/Icons/CoursesEmptyIcon.svelte';
|
||||
import { getSupabase } from '$lib/utils/functions/supabase';
|
||||
import { courses, courseMetaDeta } from '$lib/components/Courses/store';
|
||||
import { getCourseBySiteName } from '$lib/utils/services/org';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { validateEmail } from '$lib/utils/functions/validateEmail';
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { landingPageSettings } from '$lib/components/Org/Settings/store';
|
||||
|
||||
export let orgSiteName = '';
|
||||
export let org = {};
|
||||
|
||||
let email: string | undefined;
|
||||
let isAdding = false;
|
||||
let success = false;
|
||||
let successContactSaved = false;
|
||||
let viewAll = false;
|
||||
let isContactSubmiting = false;
|
||||
let player;
|
||||
let contact = {
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
message: ''
|
||||
};
|
||||
let contactError = {};
|
||||
|
||||
const supabase = getSupabase();
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!email || !validateEmail(email)) return;
|
||||
isAdding = true;
|
||||
|
||||
const { error } = await supabase.from('organization_emaillist').insert({
|
||||
email,
|
||||
organization_id: org.id
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Error', error);
|
||||
}
|
||||
|
||||
success = true;
|
||||
setTimeout(() => {
|
||||
isAdding = false;
|
||||
success = false;
|
||||
email = undefined;
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
async function handleContactSubmit() {
|
||||
isContactSubmiting = true;
|
||||
contactError = orgLandingpageValidation(contact);
|
||||
if (Object.keys(contactError).length) {
|
||||
isContactSubmiting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Save to db
|
||||
const { error } = await supabase.from('organization_contacts').insert({
|
||||
name: contact.name,
|
||||
email: contact.email,
|
||||
phone: contact.phone,
|
||||
message: contact.message,
|
||||
organization_id: org.id
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Something went wrong', error, '\n\nContact', contact);
|
||||
isContactSubmiting = false;
|
||||
}
|
||||
|
||||
isContactSubmiting = false;
|
||||
successContactSaved = true;
|
||||
contact = {
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
message: ''
|
||||
};
|
||||
}
|
||||
function isYouTubeLink(link: string) {
|
||||
const youtubeRegex =
|
||||
/^(https?:\/\/)?(www\.)?(youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
|
||||
|
||||
return youtubeRegex.test(link.trim());
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (!orgSiteName) return;
|
||||
try {
|
||||
console.log('sitename', orgSiteName);
|
||||
$courseMetaDeta.isLoading = true;
|
||||
const coursesResult = await getCourseBySiteName(orgSiteName);
|
||||
courses.set(coursesResult);
|
||||
$courseMetaDeta.isLoading = false;
|
||||
} catch (error) {
|
||||
console.log('error', error);
|
||||
}
|
||||
});
|
||||
function initPlyr(_player: any, _video: string | undefined) {
|
||||
if (!player) return;
|
||||
|
||||
// @ts-ignore
|
||||
const plyr = new Plyr('#player', { width: '400px', height: '300px', borderRadius: '10px' });
|
||||
// @ts-ignore
|
||||
window.player = plyr;
|
||||
}
|
||||
|
||||
// Use default data in store if user hasn't added their own content to landing page
|
||||
function setDefault(landingpage) {
|
||||
if (landingpage && Object.keys(landingpage).length) {
|
||||
if (!landingpage?.header?.banner) {
|
||||
landingpage.header.banner = $landingPageSettings.header.banner;
|
||||
}
|
||||
|
||||
$landingPageSettings = {
|
||||
...landingpage
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
$: initPlyr(player, $landingPageSettings.header?.banner?.video);
|
||||
$: setDefault(org.landingpage);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>
|
||||
{!org.name ? '' : `${org.name}'s `}Landing Page
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if !org.landingpage}
|
||||
<PageLoader />
|
||||
{:else}
|
||||
<main>
|
||||
<!-- Header Section -->
|
||||
{#if $landingPageSettings.header.show}
|
||||
<header id="header" class="banner w-full h-[100vh] md:h-[90vh] mb-10 relative">
|
||||
<Navigation logo={org.avatar_url} orgName={org.name} disableSignup={true} />
|
||||
<div class="absolute h-[100vh] md:h-[90vh] top-0 w-full opacity-80 z-10 bg-white" />
|
||||
{#if $landingPageSettings.header.banner.show}
|
||||
<div class="flex items-center justify-center md:h-full py-2">
|
||||
<div
|
||||
class="md:w-11/12 lg:w-5/6 w-full flex items-center justify-center md:justify-between flex-col-reverse md:flex-row z-20 relative"
|
||||
>
|
||||
<!-- Course Description -->
|
||||
<div class="md:w-2/5 w-11/12 py-10 flex flex-col items-center md:items-start mr-3">
|
||||
<p class=" text-primary-600 text-2xl font-semibold capitalize">
|
||||
{org.name}
|
||||
</p>
|
||||
<h1
|
||||
class="text-4xl md:text-5xl lg:text-6xl text-center md:text-start my-4 font-bold"
|
||||
>
|
||||
{$landingPageSettings.header.title} <br /><span class="text-primary-600"
|
||||
>{$landingPageSettings.header.titleHighlight}</span
|
||||
>
|
||||
</h1>
|
||||
<p class="text-md mb-3 md:mb-5 text-xl text-center md:text-start">
|
||||
{$landingPageSettings.header.subtitle}
|
||||
</p>
|
||||
|
||||
<PrimaryButton
|
||||
label={$landingPageSettings.header.action.label}
|
||||
className="mt-2 md:mt-5 px-10 w-fit"
|
||||
onClick={() => {
|
||||
$landingPageSettings.header.action.redirect &&
|
||||
goto($landingPageSettings.header.action.link);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="w-5/6 md:w-1/2 md:max-w-[650px] flex">
|
||||
{#if isYouTubeLink($landingPageSettings.header?.banner?.video) && $landingPageSettings.header.banner.type === 'video'}
|
||||
<div bind:this={player} id="player" style="width:100%; border-radius:12px">
|
||||
<iframe
|
||||
title="Header video"
|
||||
src={$landingPageSettings.header?.banner?.video}
|
||||
allowfullscreen
|
||||
allowtransparency
|
||||
allow="autoplay"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- <video class="w-full rounded-xl" controls loop autoplay>
|
||||
<source src={$landingPageSettings.header?.banner?.video} type="video/mp4" />
|
||||
<source src="/path/to/video.webm" type="video/webm" />
|
||||
Captions are optional
|
||||
<track kind="captions" />
|
||||
</video> -->
|
||||
<img
|
||||
class="rounded-md"
|
||||
src={$landingPageSettings.header?.banner?.image}
|
||||
alt="landing page banner"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-full h-full flex items-center justify-center md:flex-row z-20 relative">
|
||||
<div class="max-w-[600px] mx-auto w-11/12 py-10 flex flex-col items-center">
|
||||
<p class=" text-primary-600 text-2xl font-semibold capitalize">{org.name}</p>
|
||||
<h1 class="text-4xl md:text-5xl lg:text-6xl text-center my-4 font-bold">
|
||||
{$landingPageSettings.header.title} <br /><span class="text-primary-600"
|
||||
>{$landingPageSettings.header.titleHighlight}</span
|
||||
>
|
||||
</h1>
|
||||
<p class="text-md mb-6 text-center text-xl">
|
||||
{$landingPageSettings.header.subtitle}
|
||||
</p>
|
||||
|
||||
<PrimaryButton
|
||||
label={$landingPageSettings.header.action.label}
|
||||
className="mt-5 px-10 w-fit"
|
||||
onClick={() => {
|
||||
$landingPageSettings.header.action.redirect &&
|
||||
goto($landingPageSettings.header.action.link);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<!-- Our Story section -->
|
||||
{#if $landingPageSettings.aboutUs.show}
|
||||
<section id="aboutus" class="m-h-[400px] my-10 mx-auto max-w-6xl w-full">
|
||||
<div class="mx-4 flex items-center justify-evenly flex-col lg:flex-row">
|
||||
<div class="max-w-[600px] lg:w-2/5 mr-5 mb-5 lg:mb-0">
|
||||
<h2 class="text-4xl md:text-5xl lg:text-6xl">{$landingPageSettings.aboutUs.title}</h2>
|
||||
<p class="mb-2">
|
||||
{$landingPageSettings.aboutUs.content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="image">
|
||||
<img
|
||||
src={$landingPageSettings.aboutUs.imageUrl}
|
||||
alt="Our Story"
|
||||
class=" rounded-2xl max-h-[450px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Courses Section -->
|
||||
{#if $landingPageSettings.courses.show}
|
||||
<section id="courses" transition:fade class="my-10 mx-auto max-w-6xl w-full">
|
||||
<div class="w-full">
|
||||
<div class="max-w-[500px] mx-auto w-11/12 py-10">
|
||||
<h1 class="text-4xl md:text-5xl lg:text-6xl text-center my-4 font-bold">
|
||||
{$landingPageSettings.courses.title}
|
||||
<span class="text-primary-600">{$landingPageSettings.courses.titleHighlight}</span>
|
||||
</h1>
|
||||
<p class="text-md text-center">
|
||||
{$landingPageSettings.courses.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if $courseMetaDeta.isLoading}
|
||||
<div class="cards-container my-4 mx-2">
|
||||
<CardLoader />
|
||||
<CardLoader />
|
||||
<CardLoader />
|
||||
</div>
|
||||
{:else if $courses.length > 0}
|
||||
<div class="cards-container my-4 mx-2">
|
||||
{#each $courses.slice(0, viewAll ? $courses.length : 3) as courseData}
|
||||
<Card
|
||||
id={courseData.id}
|
||||
slug={courseData.slug}
|
||||
bannerImage={courseData.logo || '/images/classroomio-course-img-template.jpg'}
|
||||
title={courseData.title}
|
||||
description={courseData.description}
|
||||
role_id={courseData.role_id}
|
||||
isPublished={courseData.is_published}
|
||||
cost={courseData.cost}
|
||||
currency={courseData.currency}
|
||||
totalLessons={get(courseData, 'lessons[0].count', 0)}
|
||||
isOnLandingPage={true}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<Box>
|
||||
<CoursesEmptyIcon />
|
||||
<h3 class="dark:text-white text-2xl my-5">No Course Published</h3>
|
||||
<p class="dark:text-white w-1/3 text-center">
|
||||
We've got great courses coming your way, stay tuned!!!
|
||||
</p>
|
||||
</Box>
|
||||
{/if}
|
||||
|
||||
{#if $courses.length > 3}
|
||||
<div class="w-full mt-3 flex justify-center">
|
||||
<PrimaryButton
|
||||
variant={VARIANTS.OUTLINED}
|
||||
onClick={() => (viewAll = !viewAll)}
|
||||
label={viewAll ? 'View Less' : 'View All'}
|
||||
className="px-10 py-5 w-fit"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- FAQ Section -->
|
||||
{#if $landingPageSettings.faq.show}
|
||||
<section id="faq" transition:fade class="my-10 mx-auto max-w-[700px] w-full">
|
||||
<div class="py-10">
|
||||
<h1 class="text-4xl md:text-5xl lg:text-6xl text-center my-4 font-bold">
|
||||
{$landingPageSettings.faq.title}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="mx-2">
|
||||
<Accordion size="xl">
|
||||
{#each $landingPageSettings.faq.questions as faq}
|
||||
<AccordionItem title={faq.title} class="text-3xl">
|
||||
<p class="text-lg">
|
||||
{faq.content}
|
||||
</p>
|
||||
</AccordionItem>
|
||||
{/each}
|
||||
</Accordion>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Contact Section-->
|
||||
{#if $landingPageSettings.contact.show}
|
||||
<section id="contact" transition:fade class="my-10 w-full bg-primary-50">
|
||||
<div class="mx-auto max-w-6xl w-full">
|
||||
<div class="max-w-[500px] mx-auto w-11/12 py-10">
|
||||
<h1 class="text-4xl md:text-5xl lg:text-6xl text-center my-4 font-bold">
|
||||
{$landingPageSettings.contact.title}
|
||||
<span class="text-primary-600">{$landingPageSettings.contact.titleHighlight}</span>
|
||||
</h1>
|
||||
<p class="text-md text-center">{$landingPageSettings.contact.subtitle}</p>
|
||||
</div>
|
||||
<Grid class="max-w-[700px] pb-10">
|
||||
<!-- Contact Details -->
|
||||
<Row>
|
||||
<Column
|
||||
class="flex items-center flex-col justify-center break-all text-center cursor-pointer hover:shadow-xl rounded-lg transition-all duration-500 py-2 mx-2"
|
||||
>
|
||||
<LocationFilled size={32} />
|
||||
<p class="text-xs md:text-sm mt-3 max-w-[200px]">
|
||||
{$landingPageSettings.contact.address}
|
||||
</p>
|
||||
</Column>
|
||||
<Column
|
||||
class="flex items-center flex-col justify-center break-all text-center cursor-pointer hover:shadow-xl rounded-lg transition-all duration-500 py-2 mx-2"
|
||||
>
|
||||
<Phone size={32} />
|
||||
<p class="text-xs md:text-sm mt-3">{$landingPageSettings.contact.phone}</p>
|
||||
</Column>
|
||||
<Column
|
||||
class="flex items-center flex-col justify-center break-all text-center cursor-pointer hover:shadow-xl rounded-lg transition-all duration-500 py-2 mx-2"
|
||||
>
|
||||
<Email size={32} />
|
||||
<p class="text-xs md:text-sm mt-3">{$landingPageSettings.contact.email}</p>
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
<!-- Contact Form -->
|
||||
<div class="mt-8 bg-white p-7 rounded-lg">
|
||||
{#if successContactSaved}
|
||||
<div class="w-full flex items-center justify-center">
|
||||
Thank you for dropping a message, we will get back to you shortly
|
||||
</div>
|
||||
{:else}
|
||||
<form on:submit|preventDefault={handleContactSubmit}>
|
||||
<div class="w-full flex justify-between flex-col md:flex-row">
|
||||
<div class="w-full md:w-2/4 mr-5">
|
||||
<TextField
|
||||
label="Your Name"
|
||||
bind:value={contact.name}
|
||||
errorMessage={contactError.name}
|
||||
className="mb-5"
|
||||
labelClassName="font-bold"
|
||||
placeholder="Elon Musk"
|
||||
/>
|
||||
<TextField
|
||||
label="Your Email"
|
||||
bind:value={contact.email}
|
||||
errorMessage={contactError.email}
|
||||
className="text-xs font-normal mb-5"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
<TextField
|
||||
label="Your Phone"
|
||||
bind:value={contact.phone}
|
||||
errorMessage={contactError.phone}
|
||||
className="text-xs font-normal mb-5"
|
||||
placeholder="+1194802480"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full md:w-2/4">
|
||||
<TextArea
|
||||
label="Your Message"
|
||||
bind:value={contact.message}
|
||||
errorMessage={contactError.message}
|
||||
rows="9"
|
||||
maxRows={15}
|
||||
placeholder="Your message here"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PrimaryButton
|
||||
className="w-full mx-auto mt-5 md:mt-0"
|
||||
type="submit"
|
||||
isLoading={isContactSubmiting}
|
||||
>
|
||||
<span class="mr-2 text-md">Submit</span>
|
||||
<Rocket size={24} />
|
||||
</PrimaryButton>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</Grid>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Waitlist Section -->
|
||||
{#if $landingPageSettings.mailinglist.show}
|
||||
<section id="waitlist" transition:fade class="my-10 mx-auto max-w-6xl w-[95%]">
|
||||
<div
|
||||
class="bg-primary-700 rounded-lg flex flex-col lg:flex-row lg:items-center px-4 md:px-10 py-14"
|
||||
>
|
||||
<div class="w-full md:w-[65%] md:mr-4">
|
||||
<h1 class="text-4xl font-bold mb-5 mt-0 text-white">
|
||||
{$landingPageSettings.mailinglist.title}
|
||||
</h1>
|
||||
<p class="text-lg text-white">
|
||||
{$landingPageSettings.mailinglist.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<form on:submit|preventDefault={handleSubmit} class="my-4 w-full md:w-fit">
|
||||
<div class="flex items-center flex-col sm:flex-row">
|
||||
{#if success}
|
||||
<p class="text-white">You have been added successfully. Thanks for subscribing.</p>
|
||||
{:else}
|
||||
<TextField
|
||||
bind:value={email}
|
||||
type="email"
|
||||
placeholder="Enter your email.."
|
||||
className="sm:mr-3 my-0 w-full md:w-fit"
|
||||
isRequired={true}
|
||||
isDisabled={isAdding}
|
||||
inputClassName="py-2"
|
||||
/>
|
||||
<PrimaryButton
|
||||
className="my-1 w-full mt-2"
|
||||
variant={VARIANTS.CONTAINED_LIGHT}
|
||||
type="submit"
|
||||
isLoading={isAdding}>{$landingPageSettings.mailinglist.buttonLabel}</PrimaryButton
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Footer Section -->
|
||||
|
||||
{#if $landingPageSettings.footer.show}
|
||||
<footer
|
||||
id="footer"
|
||||
class="flex justify-center flex-col mt-10 w-full px-5 py-10 md:py-3 border-b-0 border-r-0 border-t border-l-0 border-gray-300"
|
||||
>
|
||||
<ul class="flex w-11/12 items-center flex-col sm:flex-row">
|
||||
<div class="logo">
|
||||
<a
|
||||
href="/"
|
||||
title={`Go to ${org.name} Home`}
|
||||
id="logo"
|
||||
data-hveid="8"
|
||||
class="flex items-center"
|
||||
>
|
||||
<img
|
||||
src={org.avatar_url || '/logo-192.png'}
|
||||
alt={`${org.name} logo`}
|
||||
class="rounded h-10 w-10 inline-block mx-auto"
|
||||
data-atf="1"
|
||||
/>
|
||||
<h3 class="text-black ml-3 text-xl">{org.name}</h3>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<span class="flex-grow" />
|
||||
|
||||
<div class="flex mt-5 sm:mt-0">
|
||||
{#if $landingPageSettings.footer.facebook}
|
||||
<li class="mx-2">
|
||||
<a
|
||||
href={$landingPageSettings.footer.facebook}
|
||||
target="_blank"
|
||||
title="Facebook"
|
||||
id="logo-fb"
|
||||
data-hveid="8"
|
||||
>
|
||||
<LogoFacebook size={24} />
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
{#if $landingPageSettings.footer.twitter}
|
||||
<li class="mx-2">
|
||||
<a
|
||||
href={$landingPageSettings.footer.twitter}
|
||||
target="_blank"
|
||||
title="Twitter"
|
||||
id="logo-tw"
|
||||
data-hveid="8"
|
||||
>
|
||||
<LogoTwitter size={24} />
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
|
||||
{#if $landingPageSettings.footer.linkedin}
|
||||
<li class="mx-2">
|
||||
<a
|
||||
href={$landingPageSettings.footer.linkedin}
|
||||
target="_blank"
|
||||
title="Linkedin"
|
||||
id="logo-ln"
|
||||
data-hveid="8"
|
||||
>
|
||||
<LogoLinkedin size={24} />
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
</div>
|
||||
</ul>
|
||||
<p class="text-center mt-5">
|
||||
Powered by
|
||||
<a class="text-primary-600 underline" href="https://classroomio.com" target="_blank"
|
||||
>ClassroomIO</a
|
||||
>
|
||||
</p>
|
||||
</footer>
|
||||
{/if}
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.banner {
|
||||
background: url('/images/org-landingpage-banner.jpeg') no-repeat center center fixed;
|
||||
-webkit-background-size: cover;
|
||||
-moz-background-size: cover;
|
||||
-o-background-size: cover;
|
||||
background-size: cover;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script>
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import { deleteModal, quizesStore } from '$lib/utils/store/org';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
|
||||
export let onDelete = () => {};
|
||||
|
||||
function closeModal() {
|
||||
deleteModal.update((m) => ({
|
||||
...m,
|
||||
open: false,
|
||||
id: null,
|
||||
isQuestion: false
|
||||
}));
|
||||
}
|
||||
|
||||
async function deleteQuiz() {
|
||||
const { data, error } = await supabase.from('quiz').delete().match({ id: $deleteModal.id });
|
||||
console.log('data', data);
|
||||
console.log('error', error);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
$quizesStore = $quizesStore.filter((q) => q.id !== $deleteModal.id);
|
||||
closeModal();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
onClose={closeModal}
|
||||
bind:open={$deleteModal.open}
|
||||
width="w-2/5"
|
||||
modalHeading={$deleteModal.isQuestion ? 'Delete Question' : 'Delete Quiz'}
|
||||
size="sm"
|
||||
>
|
||||
<div class="w-full flex flex-col items-center my-10">
|
||||
<h1 class="dark:text-white text-lg text-center">
|
||||
{#if $deleteModal.isQuestion}
|
||||
Are you sure you want to delete this question?
|
||||
{:else}
|
||||
Are you sure you want to delete this quiz?
|
||||
{/if}
|
||||
You will lose your content and this action can’t be undone.
|
||||
</h1>
|
||||
|
||||
<div class="mt-5">
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
variant={VARIANTS.CONTAINED}
|
||||
label="Delete"
|
||||
onClick={() => {
|
||||
if ($deleteModal.isQuestion) {
|
||||
onDelete();
|
||||
$deleteModal.open = false;
|
||||
} else {
|
||||
deleteQuiz();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { SkeletonPlaceholder, SkeletonText } from 'carbon-components-svelte';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="root w-full border border-gray-100 dark:bg-neutral-800 mr-4 mb-4 rounded-md transition ease-in-out relative flex p-3"
|
||||
>
|
||||
<SkeletonPlaceholder style="width: 20%; height: 100%;" />
|
||||
<div class="w-full mx-5 flex justify-evenly flex-col">
|
||||
<!-- Title -->
|
||||
<SkeletonText style="width: 50%;" />
|
||||
<!-- Description -->
|
||||
<SkeletonText style="width: 50%; height: 4rem;" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
height: 180px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import Modal from '$lib/components/Modal/index.svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { currentOrg, createQuizModal, currentOrgPath, quizesStore } from '$lib/utils/store/org';
|
||||
import { createQuizValidation } from '$lib/utils/functions/validator';
|
||||
|
||||
let open = false;
|
||||
let errors = {
|
||||
title: ''
|
||||
};
|
||||
let isLoading = false;
|
||||
|
||||
function handleClose() {
|
||||
$createQuizModal.id = null;
|
||||
$createQuizModal.title = '';
|
||||
$createQuizModal.open = false;
|
||||
$createQuizModal.openEdit = false;
|
||||
}
|
||||
|
||||
function errorNotification() {
|
||||
snackbar.error();
|
||||
}
|
||||
|
||||
async function createQuiz() {
|
||||
errors = createQuizValidation($createQuizModal);
|
||||
console.log('createQuiz|updateQuiz errors', errors);
|
||||
|
||||
if (Object.keys(errors).length) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
if ($createQuizModal.openEdit) {
|
||||
const { error } = await supabase
|
||||
.from('quiz')
|
||||
.update({ title: $createQuizModal.title, updated_at: new Date() })
|
||||
.match({ id: $createQuizModal.id });
|
||||
|
||||
isLoading = false;
|
||||
|
||||
if (error) {
|
||||
return errorNotification();
|
||||
} else {
|
||||
$quizesStore = $quizesStore.map((s) => {
|
||||
if (s.id === $createQuizModal.id) {
|
||||
s.title = $createQuizModal.title;
|
||||
}
|
||||
return s;
|
||||
});
|
||||
handleClose();
|
||||
}
|
||||
} else {
|
||||
const { data, error } = await supabase
|
||||
.from('quiz')
|
||||
.insert({
|
||||
title: $createQuizModal.title,
|
||||
organization_id: $currentOrg.id,
|
||||
questions: [
|
||||
{
|
||||
id: 1,
|
||||
label: '',
|
||||
type: 'multichoice',
|
||||
options: [
|
||||
{
|
||||
id: 'circle',
|
||||
label: '',
|
||||
isCorrect: false
|
||||
},
|
||||
{
|
||||
id: 'triangle',
|
||||
label: '',
|
||||
isCorrect: false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
.select();
|
||||
isLoading = false;
|
||||
|
||||
if (error) {
|
||||
return errorNotification();
|
||||
} else {
|
||||
$quizesStore = [...$quizesStore, data[0]];
|
||||
|
||||
handleClose();
|
||||
goto(`${$currentOrgPath}/quiz/${data[0].id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: open = $createQuizModal.openEdit || $createQuizModal.open;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Quizzes - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Modal
|
||||
onClose={handleClose}
|
||||
bind:open
|
||||
width="w-4/5 md:w-2/5"
|
||||
modalHeading="{$createQuizModal.openEdit ? 'Update' : 'Create'} a Quiz"
|
||||
>
|
||||
<form on:submit|preventDefault={createQuiz}>
|
||||
<TextField
|
||||
label="Quiz Title"
|
||||
bind:value={$createQuizModal.title}
|
||||
autofocus={true}
|
||||
placeholder="Your quiz title"
|
||||
className="mb-4"
|
||||
isRequired={true}
|
||||
errorMessage={errors.title}
|
||||
autoComplete={false}
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex items-center justify-end">
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
label={$createQuizModal.openEdit ? 'Save Changes' : 'Continue'}
|
||||
type="submit"
|
||||
isDisabled={isLoading}
|
||||
{isLoading}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { SyncLoader } from 'svelte-loading-spinners';
|
||||
import PlayContainer from './Container.svelte';
|
||||
import PlayHeader from './Header/index.svelte';
|
||||
import { quizStore, playQuizStore } from '$lib/utils/store/org';
|
||||
import { STEPS } from '$lib/utils/constants/quiz';
|
||||
import { genQuizPin } from '$lib/utils/functions/org';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
|
||||
let isGettingPin = true;
|
||||
|
||||
function getPin() {
|
||||
setTimeout(() => {
|
||||
$quizStore.pin = genQuizPin();
|
||||
isGettingPin = false;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function goToPlayersStep() {
|
||||
$playQuizStore.step = STEPS.WAIT_FOR_PLAYERS;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
getPin();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PlayContainer>
|
||||
<div slot="header">
|
||||
<PlayHeader startCount={true} showCountDown={true} />
|
||||
</div>
|
||||
|
||||
<div slot="body" class="w-full rounded-md bg-white dark:bg-neutral-800 py-7 px-5">
|
||||
<div class="mb-3">
|
||||
<p>1. Visit</p>
|
||||
<h3>play.classroomio.com</h3>
|
||||
</div>
|
||||
<div class="">
|
||||
<p>2. Enter Pin</p>
|
||||
{#if isGettingPin}
|
||||
<SyncLoader size="50" color="#1d4ed8" unit="px" duration="1s" />
|
||||
{:else}
|
||||
<h3>{$quizStore.pin}</h3>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div slot="footer" class="flex justify-center items-center">
|
||||
<p class="font-bold mr-3">Let's go</p>
|
||||
<PrimaryButton label="View Players" variant={VARIANTS.OUTLINED} onClick={goToPlayersStep} />
|
||||
</div>
|
||||
</PlayContainer>
|
||||
@@ -0,0 +1,43 @@
|
||||
<div class="root flex flex-col justify-between h-full m-auto">
|
||||
{#if $$slots.header}
|
||||
<!-- HEADER -->
|
||||
<div class="header">
|
||||
<slot name="header" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $$slots.body}
|
||||
<!-- BODY -->
|
||||
<div class="body mb-3">
|
||||
<slot name="body" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $$slots.footer}
|
||||
<!-- FOOTER -->
|
||||
<div class="footer bg-white dark:bg-neutral-800 py-6 px-2 m-auto">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
max-width: 680px;
|
||||
width: 80%;
|
||||
height: 95vh;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 15%;
|
||||
}
|
||||
|
||||
.body {
|
||||
min-height: 60%;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.footer {
|
||||
max-width: 400px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script>
|
||||
import CircleContent from './CircleContent.svelte';
|
||||
|
||||
export let answers = 0;
|
||||
</script>
|
||||
|
||||
<CircleContent value={answers} label="Answer(s)" />
|
||||
@@ -0,0 +1,45 @@
|
||||
<script>
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
export let value = false;
|
||||
export let label = '';
|
||||
export let className = '';
|
||||
|
||||
function fadeSlide(node, options) {
|
||||
const slideTrans = slide(node, options);
|
||||
return {
|
||||
duration: options.duration,
|
||||
css: (t) => `
|
||||
${slideTrans.css(t)}
|
||||
opacity: ${t};
|
||||
`
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="circle flex items-center flex-col justify-center {className}">
|
||||
{#key value}
|
||||
<h3 class="text-primary-600" transition:fadeSlide={{ duration: 500 }}>
|
||||
{value}
|
||||
</h3>
|
||||
{/key}
|
||||
<p class="text-primary-600 text-md">{label}</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.circle {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
line-height: 100px;
|
||||
border-radius: 50%;
|
||||
font-size: 50px;
|
||||
text-align: center;
|
||||
background: #fff;
|
||||
}
|
||||
.circle h3 {
|
||||
font-size: 40px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import CircleContent from './CircleContent.svelte';
|
||||
|
||||
export let startCount = false;
|
||||
export let timelimit = '';
|
||||
|
||||
let countDownNo = 0;
|
||||
let started = false;
|
||||
|
||||
function triggerCountDown(start) {
|
||||
if (!start || started) return;
|
||||
|
||||
const getNum = (k) => parseInt(timelimit.replace(k, ''));
|
||||
|
||||
if (timelimit.includes('s')) {
|
||||
countDownNo = getNum('s');
|
||||
} else if (timelimit.includes('m')) {
|
||||
countDownNo = getNum('m') * 60;
|
||||
}
|
||||
|
||||
window.refreshIntervalId = setInterval(() => {
|
||||
if (--countDownNo <= 0) {
|
||||
clearInterval(window.refreshIntervalId);
|
||||
}
|
||||
}, 1000);
|
||||
started = true;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (startCount) {
|
||||
triggerCountDown(startCount);
|
||||
}
|
||||
});
|
||||
|
||||
$: triggerCountDown(startCount);
|
||||
</script>
|
||||
|
||||
<CircleContent value={countDownNo} label="Second(s)" className="mr-3" />
|
||||
@@ -0,0 +1,18 @@
|
||||
<script>
|
||||
import { quizStore } from '$lib/utils/store/org';
|
||||
import Answer from './Answer.svelte';
|
||||
import Timer from './Timer.svelte';
|
||||
|
||||
export let startCount = false;
|
||||
export let showCountDown = false;
|
||||
</script>
|
||||
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<h3 class="text-white">{$quizStore.title}</h3>
|
||||
{#if showCountDown}
|
||||
<div class="flex">
|
||||
<Timer {startCount} timelimit={$quizStore.timelimit} />
|
||||
<Answer />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
players
|
||||
@@ -0,0 +1 @@
|
||||
Podium
|
||||
@@ -0,0 +1,102 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import ChevronLeftIcon from 'carbon-icons-svelte/lib/ChevronLeft.svelte';
|
||||
import ChevronRightIcon from 'carbon-icons-svelte/lib/ChevronRight.svelte';
|
||||
import QuizQuestion from '../QuizQuestion.svelte';
|
||||
import PlayContainer from './Container.svelte';
|
||||
import PlayHeader from './Header/index.svelte';
|
||||
import { quizStore } from '$lib/utils/store/org';
|
||||
import { themeImages } from '$lib/utils/constants/quiz';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
|
||||
export let exitPreview = () => {};
|
||||
|
||||
let bgImg;
|
||||
let totalQ = $quizStore.questions.length;
|
||||
let curQId = 0;
|
||||
let currentQuestion = $quizStore.questions[0] || {};
|
||||
|
||||
function handlePrev() {
|
||||
const prevQId = curQId - 1;
|
||||
console.log('prevQId', prevQId);
|
||||
const prev = $quizStore.questions[prevQId];
|
||||
console.log('prev', prev);
|
||||
if (prev) {
|
||||
currentQuestion = prev;
|
||||
curQId = prevQId;
|
||||
}
|
||||
}
|
||||
|
||||
function handleNext() {
|
||||
const nextQId = curQId + 1;
|
||||
console.log('nextqid', nextQId);
|
||||
const next = $quizStore.questions[nextQId];
|
||||
console.log('next', next);
|
||||
if (next) {
|
||||
currentQuestion = next;
|
||||
curQId = nextQId;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
bgImg = themeImages[$quizStore.theme].play;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if bgImg}
|
||||
<div
|
||||
class="absolute inset-0 z-50 bg-white h-screen w-screen"
|
||||
in:fly={{ y: 500, duration: 500 }}
|
||||
out:fly={{ y: 500, duration: 500 }}
|
||||
>
|
||||
<div
|
||||
class="p-5 h-full w-full"
|
||||
style="background: url({bgImg}) no-repeat center center fixed; -webkit-background-size: cover;-moz-background-size: cover;-o-background-size: cover;background-size: cover;min-height: 100vh;height: fit-content;"
|
||||
>
|
||||
<PlayContainer>
|
||||
<div slot="header">
|
||||
<PlayHeader startCount={true} showCountDown={true} />
|
||||
</div>
|
||||
|
||||
<div slot="body" class="quiz-body">
|
||||
{#if currentQuestion}
|
||||
<QuizQuestion {currentQuestion} isPreview={true} />
|
||||
{:else}
|
||||
<h2>No question added</h2>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div slot="footer" class="flex justify-center flex-col">
|
||||
{#if currentQuestion}
|
||||
<PrimaryButton
|
||||
label="Exit Preview"
|
||||
variant={VARIANTS.TEXT}
|
||||
onClick={exitPreview}
|
||||
className="w-fit"
|
||||
/>
|
||||
|
||||
<div class="flex justify-center items-center">
|
||||
<IconButton onClick={handlePrev} size="small">
|
||||
<ChevronLeftIcon size={16} class="carbon-icon dark:text-white" />
|
||||
</IconButton>
|
||||
<p class="mx-3">{curQId + 1} / {totalQ}</p>
|
||||
<IconButton onClick={handleNext} size="small">
|
||||
<ChevronRightIcon size={16} class="carbon-icon dark:text-white" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</PlayContainer>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.quiz-body {
|
||||
width: 500px;
|
||||
margin: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
Question
|
||||
@@ -0,0 +1 @@
|
||||
Scoreboard
|
||||
@@ -0,0 +1,47 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { SyncLoader } from 'svelte-loading-spinners';
|
||||
import { themeImages, STEPS } from '$lib/utils/constants/quiz';
|
||||
import { quizStore, playQuizStore } from '$lib/utils/store/org';
|
||||
import ConnectToPlay from './ConnectToPlay.svelte';
|
||||
import Players from './Players.svelte';
|
||||
import Question from './Question.svelte';
|
||||
import Scoreboard from './Scoreboard.svelte';
|
||||
import Podium from './Podium.svelte';
|
||||
|
||||
let bgImg;
|
||||
|
||||
onMount(() => {
|
||||
bgImg = themeImages[$quizStore.theme].play;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if bgImg}
|
||||
<div
|
||||
class="absolute inset-0 z-50 bg-white h-screen w-screen"
|
||||
in:fly={{ y: 500, duration: 500 }}
|
||||
out:fly={{ y: 500, duration: 500 }}
|
||||
>
|
||||
<div
|
||||
class="p-5 h-full w-full"
|
||||
style="background: url({bgImg}) no-repeat center center fixed; -webkit-background-size: cover;-moz-background-size: cover;-o-background-size: cover;background-size: cover;min-height: 100vh;height: fit-content;"
|
||||
>
|
||||
{#if $playQuizStore.step === STEPS.CONNECT_TO_PLAY}
|
||||
<ConnectToPlay />
|
||||
{:else if $playQuizStore.step === STEPS.WAIT_FOR_PLAYERS}
|
||||
<Players />
|
||||
{:else if $playQuizStore.step === STEPS.SHOW_NEXT_QUESTION}
|
||||
<Question />
|
||||
{:else if $playQuizStore.step === STEPS.SCOREBOARD}
|
||||
<Scoreboard />
|
||||
{:else if $playQuizStore.step === STEPS.PODIUM}
|
||||
<Podium />
|
||||
{:else}
|
||||
<div class="w-full m-auto">
|
||||
<SyncLoader size="20" color="#1d4ed8" unit="px" duration="1s" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,98 @@
|
||||
<script>
|
||||
import pluralize from 'pluralize';
|
||||
import { ImageLoader } from 'carbon-components-svelte';
|
||||
import OverflowMenuHorizontalIcon from 'carbon-icons-svelte/lib/OverflowMenuHorizontal.svelte';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
import Dropdown from '$lib/components/Dropdown/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { onRename, onDelete } from '$lib/utils/services/org/quiz';
|
||||
import { themeImages } from '$lib/utils/constants/quiz';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
|
||||
export let quiz;
|
||||
export let totalQuestions;
|
||||
|
||||
function getOptions() {
|
||||
return [
|
||||
// {
|
||||
// label: 'Edit',
|
||||
// onClick: onEdit(quiz),
|
||||
// },
|
||||
// {
|
||||
// label: 'Share',
|
||||
// onClick: onShare(quiz),
|
||||
// },
|
||||
// {
|
||||
// label: 'Report',
|
||||
// onClick: onReport(quiz),
|
||||
// },
|
||||
{
|
||||
label: 'Rename',
|
||||
onClick() {
|
||||
onRename(quiz);
|
||||
}
|
||||
},
|
||||
// {
|
||||
// label: 'Make a copy',
|
||||
// onClick() {
|
||||
// onMakeCopy(quiz);
|
||||
// },
|
||||
// },
|
||||
{
|
||||
label: 'Delete',
|
||||
onClick() {
|
||||
onDelete(quiz);
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function startQuiz() {}
|
||||
</script>
|
||||
|
||||
{#if quiz}
|
||||
<div
|
||||
class="root w-full border hover:shadow-2xl transition ease-in-out rounded-lg bg-gray-100 dark:bg-black mb-5 relative flex flex-col lg:flex-row p-3"
|
||||
>
|
||||
<ImageLoader
|
||||
src={themeImages[quiz.theme]?.card || themeImages.standard.card}
|
||||
alt="quiz-card"
|
||||
style="max-width:300px, min-width: 200px;"
|
||||
/>
|
||||
|
||||
<div class="p-2 md:p-5 flex flex-col justify-between w-full">
|
||||
<div class="flex justify-between">
|
||||
<h4 class="dark:text-white title text-md font-bold capitalize">
|
||||
<a href="{$currentOrgPath}/quiz/{quiz.id}">{quiz.title}</a>
|
||||
</h4>
|
||||
|
||||
<Dropdown options={getOptions()} classNames="absolute top-4 right-4" isIcon={true}>
|
||||
<div class="p-1 rounded-full bg-gray-200 dark:bg-gray-600">
|
||||
<OverflowMenuHorizontalIcon size={20} class="carbon-icon active" />
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
<div class="flex md:flex-row flex-col justify-between">
|
||||
<div>
|
||||
<p class="mb-2">{pluralize('question', totalQuestions, true)}</p>
|
||||
<p class="mb-2 md:mb-0">Updated {calDateDiff(quiz.updated_at)}</p>
|
||||
</div>
|
||||
|
||||
<PrimaryButton
|
||||
className="px-6 py-3"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
label="Start Quiz"
|
||||
onClick={startQuiz}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root {
|
||||
min-height: 180px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script>
|
||||
import CheckmarkFilledIcon from 'carbon-icons-svelte/lib/CheckmarkFilled.svelte';
|
||||
import CheckmarkOutlineIcon from 'carbon-icons-svelte/lib/CheckmarkOutline.svelte';
|
||||
import { optionImage } from '$lib/utils/constants/quiz';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
|
||||
export let currentQuestion = {
|
||||
label: ''
|
||||
};
|
||||
export let currentError = {};
|
||||
export let optionHasError = () => false;
|
||||
export let isPreview = false;
|
||||
</script>
|
||||
|
||||
{#if isPreview}
|
||||
<h1 class="text-white mb-5 font-bold">{currentQuestion.label}</h1>
|
||||
{:else}
|
||||
<TextField
|
||||
placeholder="Start typing your question"
|
||||
bind:value={currentQuestion.label}
|
||||
className="w-full my-4"
|
||||
errorMessage={currentError.isLabelEmpty && 'This field is required'}
|
||||
isRequired={true}
|
||||
bgColor="bg-white"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Options -->
|
||||
<div class="flex justify-between w-full flex-wrap">
|
||||
{#each currentQuestion.options || [] as option}
|
||||
<div class="w-2/5 bg-white dark:bg-neutral-800 rounded p-5 mb-5 relative">
|
||||
<img src={optionImage[option.id]} alt={option.id} />
|
||||
|
||||
{#if isPreview}
|
||||
<p class="mt-5">{option.label}</p>
|
||||
{:else}
|
||||
<TextField
|
||||
bind:value={option.label}
|
||||
placeholder="Type answer"
|
||||
bgColor="bg-white"
|
||||
className="mt-3"
|
||||
isDisabled={currentQuestion.type === 'boolean'}
|
||||
errorMessage={optionHasError(option.id, currentError.options) && 'Please enter a label'}
|
||||
/>
|
||||
<div class="flex justify-end absolute top-2 right-2">
|
||||
<IconButton
|
||||
value={option.id}
|
||||
onClick={() => (option.isCorrect = !option.isCorrect)}
|
||||
buttonClassName={option.isCorrect && 'success'}
|
||||
>
|
||||
{#if option.isCorrect}
|
||||
<CheckmarkFilledIcon size={24} class="carbon-icon dark:text-white" />
|
||||
{:else}
|
||||
<CheckmarkOutlineIcon size={24} class="carbon-icon dark:text-white" />
|
||||
{/if}
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import Box from '$lib/components/Box/index.svelte';
|
||||
import QuizLoader from './Loader.svelte';
|
||||
import QuizCard from './QuizCard.svelte';
|
||||
import CoursesEmptyIcon from '$lib/components/Icons/CoursesEmptyIcon.svelte';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { currentOrg, quizesStore } from '$lib/utils/store/org';
|
||||
|
||||
let isLoading = false;
|
||||
|
||||
async function fetchQuizes(id) {
|
||||
if (!id) return;
|
||||
isLoading = true;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('quiz')
|
||||
.select(`*`)
|
||||
.eq('organization_id', id)
|
||||
.order('updated_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
snackbar.error();
|
||||
return;
|
||||
}
|
||||
console.log('data', data);
|
||||
quizesStore.set(data);
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
$: fetchQuizes($currentOrg.id);
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-center lg:justify-start flex-wrap my-4 m-auto">
|
||||
{#if isLoading}
|
||||
<QuizLoader />
|
||||
<QuizLoader />
|
||||
<QuizLoader />
|
||||
{:else}
|
||||
{#each $quizesStore as quiz}
|
||||
<QuizCard {quiz} totalQuestions={quiz.questions?.length || 0} />
|
||||
{:else}
|
||||
<Box>
|
||||
<CoursesEmptyIcon />
|
||||
<h3 class="dark:text-white text-2xl my-5">No Quiz Created</h3>
|
||||
<p class="dark:text-white w-1/3 text-center">
|
||||
Create interactive quizzes with scoreboard for your students.
|
||||
</p>
|
||||
</Box>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,80 @@
|
||||
<!-- ResizableSidebar.svelte -->
|
||||
|
||||
<script>
|
||||
let expanding = null;
|
||||
let startX = 0;
|
||||
let initialX = 0;
|
||||
let initialWidth = 300; // Initial width
|
||||
let width = initialWidth;
|
||||
let x = 20; // Initial x-position
|
||||
const grabberWidth = 10;
|
||||
const MIN_WIDTH = 300; // Minimum width
|
||||
const MAX_WIDTH = 500; // Maximum width
|
||||
|
||||
function startExpand(type, event) {
|
||||
expanding = type;
|
||||
startX = event.pageX;
|
||||
initialX = x;
|
||||
initialWidth = width;
|
||||
}
|
||||
|
||||
function stopExpand() {
|
||||
expanding = null;
|
||||
}
|
||||
|
||||
function expand(event) {
|
||||
if (!expanding) return;
|
||||
|
||||
const deltaX = event.pageX - startX;
|
||||
const newWidth = initialWidth + deltaX;
|
||||
|
||||
// Ensure the width stays within the specified range
|
||||
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
|
||||
width = newWidth;
|
||||
x = initialX + deltaX;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:fragment on:mouseup={stopExpand} />
|
||||
|
||||
<svg viewBox="0 0 100 40" height="44" width="300" on:mousemove={expand} class:expanding>
|
||||
<rect {x} y="2" {width} height="36" class="step" />
|
||||
<rect
|
||||
{x}
|
||||
y="2"
|
||||
width={grabberWidth}
|
||||
height="36"
|
||||
fill="red"
|
||||
class:active={expanding === 'left' ? 'grip active' : 'grip'}
|
||||
on:mousedown={startExpand.bind(this, 'left')}
|
||||
/>
|
||||
<rect
|
||||
x={x + width - grabberWidth}
|
||||
y="2"
|
||||
width={grabberWidth}
|
||||
height="36"
|
||||
fill="blue"
|
||||
class:active={expanding === 'right' ? 'grip active' : 'grip'}
|
||||
on:mousedown={startExpand.bind(this, 'right')}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.step {
|
||||
fill: #8884;
|
||||
stroke: #222a;
|
||||
rx: 2;
|
||||
}
|
||||
.expanding .step {
|
||||
stroke: #228e;
|
||||
}
|
||||
.grip {
|
||||
cursor: col-resize;
|
||||
fill: #fff0;
|
||||
}
|
||||
.grip.active,
|
||||
.grip:hover {
|
||||
fill: #2288;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
<p class="dark:text-white font-bold">
|
||||
<slot />
|
||||
</p>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { Grid, Row, Column } from 'carbon-components-svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import ComingSoon from '$lib/components/ComingSoon/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { blockedSubdomain } from '$lib/utils/constants/app';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import SectionTitle from '../SectionTitle.svelte';
|
||||
import VisitOrgSiteButton from '$lib/components/Buttons/VisitOrgSite.svelte';
|
||||
|
||||
let siteName = '';
|
||||
let errors = '';
|
||||
let isLoading = false;
|
||||
|
||||
async function handleChangeDomain() {
|
||||
if (blockedSubdomain.includes(siteName || '')) {
|
||||
errors = 'Sitename already exists.';
|
||||
return;
|
||||
}
|
||||
isLoading = true;
|
||||
|
||||
const { data: org, error } = await supabase
|
||||
.from('organization')
|
||||
.update({ siteName })
|
||||
.match({ id: $currentOrg.id });
|
||||
|
||||
console.log('Updating organisation', org);
|
||||
if (error) {
|
||||
console.log('Error: create organisation', error);
|
||||
errors = 'Sitename already exists.';
|
||||
} else {
|
||||
snackbar.success();
|
||||
$currentOrg.siteName = siteName;
|
||||
|
||||
goto(`/org/${$currentOrg.siteName}/settings/domains`);
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
function setSiteName(_siteName: string) {
|
||||
if (!siteName) {
|
||||
siteName = _siteName;
|
||||
}
|
||||
}
|
||||
|
||||
function resetErrors(_siteName: string) {
|
||||
if (errors) {
|
||||
errors = '';
|
||||
}
|
||||
}
|
||||
|
||||
$: resetErrors(siteName);
|
||||
$: setSiteName($currentOrg.siteName);
|
||||
</script>
|
||||
|
||||
<Grid class="border rounded border-gray-200 w-full mt-5">
|
||||
<Row class="py-7 border-bottom-c">
|
||||
<Column sm={2} md={2} lg={4} class="text-lg"><SectionTitle>Add</SectionTitle></Column>
|
||||
<Column sm={2} md={6} lg={8}>
|
||||
<p class="text-md text-gray-500 dark:text-white mb-5">
|
||||
Add your team mates or collaborators to your organization. Start working together
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<!-- Org Site Name -->
|
||||
<TextField
|
||||
label="URL"
|
||||
helperMessage={`https://${siteName || ''}.classroomio.com`}
|
||||
bind:value={siteName}
|
||||
type="text"
|
||||
placeholder="e.g traversymedia"
|
||||
className="mb-5 w-full"
|
||||
labelClassName=""
|
||||
errorMessage={errors}
|
||||
/>
|
||||
<div class="flex items-center mb-6">
|
||||
<PrimaryButton
|
||||
label="Update Domain"
|
||||
className="py-4"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
onClick={handleChangeDomain}
|
||||
isDisabled={isLoading}
|
||||
/>
|
||||
|
||||
<VisitOrgSiteButton />
|
||||
</div>
|
||||
</div>
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
<Row class="py-7 border-bottom-c">
|
||||
<Column sm={2} md={2} lg={4} class="text-lg"><SectionTitle>Custom domain</SectionTitle></Column>
|
||||
<Column sm={2} md={6} lg={8}>
|
||||
<div class="flex items-center">
|
||||
<p class="dark:text-white mr-3">Add your own domain name</p>
|
||||
<ComingSoon />
|
||||
</div>
|
||||
</Column>
|
||||
</Row>
|
||||
</Grid>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import { Grid, Column, Row } from 'carbon-components-svelte';
|
||||
import CheckmarkFilled from 'carbon-icons-svelte/lib/CheckmarkFilled.svelte';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import SectionTitle from '../SectionTitle.svelte';
|
||||
import ArrowUpRightIcon from '$lib/components/Icons/ArrowTopRight.svelte';
|
||||
|
||||
let chatId: number | null;
|
||||
|
||||
async function addChatId() {
|
||||
if (!chatId || `${chatId}`.length < 5) return;
|
||||
|
||||
const { error } = await supabase
|
||||
.from('profile')
|
||||
.update({
|
||||
telegram_chat_id: chatId
|
||||
})
|
||||
.match({ id: $profile.id });
|
||||
|
||||
if (error) {
|
||||
console.log(error);
|
||||
snackbar.error('Integration Failed, please try again later');
|
||||
} else {
|
||||
$profile.telegram_chat_id = chatId;
|
||||
chatId = null;
|
||||
snackbar.success('Integration successful');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteChatId() {
|
||||
const { error } = await supabase
|
||||
.from('profile')
|
||||
.update({ telegram_chat_id: null })
|
||||
.match({ id: $profile.id });
|
||||
|
||||
if (error) {
|
||||
snackbar.error('Deletion Failed, please try again later');
|
||||
console.log(error);
|
||||
} else {
|
||||
$profile.telegram_chat_id = null;
|
||||
snackbar.success('Integration deleted');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Grid class="border-c rounded border-gray-200 w-full mt-5">
|
||||
<Row class="flex lg:flex-row flex-col justify-center lg:justify-start py-7 border-bottom-c">
|
||||
<Column sm={4} md={4} lg={4} class="flex items-center justify-center">
|
||||
<img src="/telegram-svg.svg" alt="" class="w-10 mr-2" />
|
||||
<SectionTitle>Telegram</SectionTitle></Column
|
||||
>
|
||||
|
||||
<Column sm={8} md={8} lg={8} class="mt-2 lg:mt-0 flex flex-col items-center lg:items-start">
|
||||
{#if $profile.telegram_chat_id !== null}
|
||||
<div class="flex flex-col items-center w-full">
|
||||
<div class="flex items-center">
|
||||
<CheckmarkFilled size={32} class="mr-2" style="fill: green;" />
|
||||
<SectionTitle>Integration Successful</SectionTitle>
|
||||
</div>
|
||||
<PrimaryButton className="mt-3" variant={VARIANTS.CONTAINED_DANGER} onClick={deleteChatId}
|
||||
>Disconnect</PrimaryButton
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<div>
|
||||
<h3 class="font-normal text-lg">
|
||||
Connect your account to telegram to get notified of any change within the application.
|
||||
</h3>
|
||||
<h4 class="font-normal">Step 1: Authenticate your account via the ClassroomIO bot.</h4>
|
||||
<PrimaryButton
|
||||
className="mb-5"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
onClick={() => window.open('https://t.me/classroomio_bot', '_blank')}
|
||||
><span class="mr-2">Open Bot</span> <ArrowUpRightIcon /></PrimaryButton
|
||||
>
|
||||
<h4 class="font-normal">Step 2: Enter the Chat ID given to you by the bot.</h4>
|
||||
<TextField
|
||||
bind:value={chatId}
|
||||
placeholder="360111"
|
||||
className="w-full lg:w-60 mb-5"
|
||||
type="number"
|
||||
isRequired
|
||||
/>
|
||||
<PrimaryButton onClick={addChatId}>Connect</PrimaryButton>
|
||||
</div>
|
||||
{/if}
|
||||
</Column>
|
||||
</Row>
|
||||
</Grid>
|
||||
@@ -0,0 +1,503 @@
|
||||
<script lang="ts">
|
||||
import { Grid, Row, Column, RadioButtonGroup, RadioButton } from 'carbon-components-svelte';
|
||||
import TrashCan from 'carbon-icons-svelte/lib/TrashCan.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import Save from 'carbon-icons-svelte/lib/Save.svelte';
|
||||
import IconButton from '$lib/components/IconButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import SectionTitle from '../SectionTitle.svelte';
|
||||
import { Toggle } from 'carbon-components-svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import TextArea from '$lib/components/Form/TextArea.svelte';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { getSupabase } from '$lib/utils/functions/supabase';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { landingPageSettings } from './store';
|
||||
import type { OrgLandingPageJson } from './store';
|
||||
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
|
||||
import { handleOpenWidget } from '$lib/components/CourseLandingPage/store';
|
||||
|
||||
let isSaving = false;
|
||||
let creatingNewQuestion = false;
|
||||
let widgetKey = '';
|
||||
const banner = [
|
||||
{ value: 'video', label: 'Video' },
|
||||
{ value: 'image', label: 'Image' }
|
||||
];
|
||||
const supabase = getSupabase();
|
||||
|
||||
let newQuestion = {
|
||||
title: '',
|
||||
content: ''
|
||||
};
|
||||
|
||||
function widgetControl(key: string) {
|
||||
widgetKey = key;
|
||||
$handleOpenWidget.open = true;
|
||||
}
|
||||
|
||||
function createNewFaq() {
|
||||
newQuestion = {
|
||||
title: '',
|
||||
content: ''
|
||||
};
|
||||
creatingNewQuestion = true;
|
||||
}
|
||||
|
||||
function saveNewFAQ() {
|
||||
if (newQuestion.title !== '' && newQuestion.content !== '') {
|
||||
$landingPageSettings.faq.questions = [
|
||||
...$landingPageSettings.faq.questions,
|
||||
{
|
||||
id: new Date().getTime(),
|
||||
title: newQuestion.title,
|
||||
content: newQuestion.content
|
||||
}
|
||||
];
|
||||
creatingNewQuestion = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelNewFAQ() {
|
||||
creatingNewQuestion = false;
|
||||
}
|
||||
|
||||
function deleteFaq(id: number) {
|
||||
let faqs = $landingPageSettings.faq.questions;
|
||||
const filteredFaq = faqs.filter((faq) => faq.id !== id);
|
||||
$landingPageSettings.faq.questions = filteredFaq;
|
||||
}
|
||||
|
||||
const checkPrefix = (inputValue: string) => {
|
||||
if (!inputValue) return;
|
||||
|
||||
if (inputValue.trim() !== '') {
|
||||
if (!inputValue.startsWith('https://')) {
|
||||
inputValue = 'https://' + inputValue;
|
||||
}
|
||||
}
|
||||
return inputValue;
|
||||
};
|
||||
|
||||
async function handleSave() {
|
||||
isSaving = true;
|
||||
$landingPageSettings.footer.twitter = checkPrefix($landingPageSettings.footer.twitter) || '';
|
||||
$landingPageSettings.footer.linkedin = checkPrefix($landingPageSettings.footer.linkedin) || '';
|
||||
$landingPageSettings.footer.facebook = checkPrefix($landingPageSettings.footer.facebook) || '';
|
||||
|
||||
const { error } = await supabase
|
||||
.from('organization')
|
||||
.update({ landingpage: $landingPageSettings })
|
||||
.match({ id: $currentOrg.id });
|
||||
|
||||
if (error) {
|
||||
const message = error?.message || 'Please try again';
|
||||
snackbar.error(`Update failed: ${message}`);
|
||||
} else {
|
||||
$currentOrg.landingpage = $landingPageSettings;
|
||||
snackbar.success();
|
||||
}
|
||||
|
||||
isSaving = false;
|
||||
}
|
||||
|
||||
function setDefault(landingpage: OrgLandingPageJson) {
|
||||
if (landingpage && Object.keys(landingpage).length) {
|
||||
// Added new key, support backward compatibility
|
||||
if (!landingpage?.header?.banner) {
|
||||
landingpage.header.banner = $landingPageSettings.header.banner;
|
||||
}
|
||||
|
||||
$landingPageSettings = {
|
||||
...landingpage
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
$: setDefault($currentOrg?.landingpage as OrgLandingPageJson);
|
||||
</script>
|
||||
|
||||
<Grid class="border-c rounded border-gray-200 w-full mt-5 relative">
|
||||
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
|
||||
<Column sm={4} md={4} lg={4}>
|
||||
<SectionTitle>Landing Page</SectionTitle>
|
||||
<Toggle bind:toggled={$landingPageSettings.header.show} size="sm">
|
||||
<span slot="labelA" style="color: gray">Hide section</span>
|
||||
<span slot="labelB" style="color: gray">Show section</span>
|
||||
</Toggle>
|
||||
</Column>
|
||||
<Column sm={8} md={8} lg={8} class="mt-4 lg:mt-0">
|
||||
<TextField
|
||||
label="Title"
|
||||
placeholder="Write your title here"
|
||||
bind:value={$landingPageSettings.header.title}
|
||||
className="w-full mb-5"
|
||||
/>
|
||||
<TextField
|
||||
label="Title-Highlight"
|
||||
placeholder="Write your titlehighlight here"
|
||||
className="w-full mb-5"
|
||||
bind:value={$landingPageSettings.header.titleHighlight}
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
label="Subtitle"
|
||||
labelClassName="font-light"
|
||||
placeholder="Write your subtitle here"
|
||||
className="w-full mb-5"
|
||||
bind:value={$landingPageSettings.header.subtitle}
|
||||
isAIEnabled={true}
|
||||
/>
|
||||
|
||||
<SectionTitle>Actions</SectionTitle>
|
||||
<TextField
|
||||
label="Label"
|
||||
placeholder="Write your label here"
|
||||
className="w-full mt-3 mb-5"
|
||||
bind:value={$landingPageSettings.header.action.label}
|
||||
/>
|
||||
<div class="gap-2 mb-5">
|
||||
<TextField
|
||||
label="Link"
|
||||
placeholder="Write your link here"
|
||||
bind:value={$landingPageSettings.header.action.link}
|
||||
/>
|
||||
<Toggle bind:toggled={$landingPageSettings.header.action.redirect} size="sm">
|
||||
<span slot="labelA" style="color: gray">No redirect</span>
|
||||
<span slot="labelB" style="color: gray">Redirect</span>
|
||||
</Toggle>
|
||||
</div>
|
||||
|
||||
<div />
|
||||
<RadioButtonGroup
|
||||
legendText="Banner Type"
|
||||
bind:selected={$landingPageSettings.header.banner.type}
|
||||
class="mt-10 mb-5"
|
||||
>
|
||||
{#each banner as item}
|
||||
<RadioButton value={item.value} labelText={item.label} />
|
||||
{/each}
|
||||
</RadioButtonGroup>
|
||||
{#if $landingPageSettings.header.banner.type === 'video'}
|
||||
<div class="gap-2 mt-3 mb-5">
|
||||
<TextField
|
||||
label="Link"
|
||||
placeholder="Write your video link here"
|
||||
bind:value={$landingPageSettings.header.banner.video}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<PrimaryButton
|
||||
variant={VARIANTS.OUTLINED}
|
||||
label="Select Image"
|
||||
className="mt-3"
|
||||
onClick={() => widgetControl('banner')}
|
||||
/>
|
||||
{/if}
|
||||
{#if $landingPageSettings.header.banner.image && $landingPageSettings.header.banner.type === 'image'}
|
||||
<img
|
||||
alt="bannerImage"
|
||||
src={$landingPageSettings.header.banner.image}
|
||||
class="mt-2 rounded-md w-full"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<Toggle bind:toggled={$landingPageSettings.header.banner.show} size="sm">
|
||||
<span slot="labelA" style="color: gray">Hide banner</span>
|
||||
<span slot="labelB" style="color: gray">Show banner</span>
|
||||
</Toggle>
|
||||
{#if $handleOpenWidget.open && widgetKey === 'banner'}
|
||||
<UploadWidget bind:imageURL={$landingPageSettings.header.banner.image} />
|
||||
{/if}
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
|
||||
<Column sm={4} md={4} lg={4}
|
||||
><SectionTitle>About</SectionTitle>
|
||||
<Toggle bind:toggled={$landingPageSettings.aboutUs.show} size="sm">
|
||||
<span slot="labelA" style="color: gray">Hide section</span>
|
||||
<span slot="labelB" style="color: gray">Show section</span>
|
||||
</Toggle></Column
|
||||
>
|
||||
<Column sm={8} md={8} lg={8} class="mt-4 lg:mt-0">
|
||||
<TextField
|
||||
label="Title"
|
||||
placeholder="Write your title here"
|
||||
bind:value={$landingPageSettings.aboutUs.title}
|
||||
className="mb-5"
|
||||
/>
|
||||
|
||||
<TextArea
|
||||
label="Content"
|
||||
labelClassName="font-light"
|
||||
placeholder="Write your Content here"
|
||||
className="mb-5"
|
||||
bind:value={$landingPageSettings.aboutUs.content}
|
||||
isAIEnabled={true}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<p class="font-bold">Upload an image</p>
|
||||
<PrimaryButton
|
||||
variant={VARIANTS.OUTLINED}
|
||||
label="Select Image"
|
||||
className="mt-3"
|
||||
onClick={() => widgetControl('about-us')}
|
||||
/>
|
||||
{#if $landingPageSettings.aboutUs.imageUrl}
|
||||
<img
|
||||
alt="About us"
|
||||
src={$landingPageSettings.aboutUs.imageUrl}
|
||||
class="mt-2 rounded-md w-full"
|
||||
/>
|
||||
{/if}
|
||||
{#if $handleOpenWidget.open && widgetKey === 'about-us'}
|
||||
<UploadWidget bind:imageURL={$landingPageSettings.aboutUs.imageUrl} />
|
||||
{/if}
|
||||
</div>
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
|
||||
<Column sm={4} md={4} lg={4}
|
||||
><SectionTitle>Courses</SectionTitle>
|
||||
<Toggle bind:toggled={$landingPageSettings.courses.show} size="sm">
|
||||
<span slot="labelA" style="color: gray">Hide section</span>
|
||||
<span slot="labelB" style="color: gray">Show section</span>
|
||||
</Toggle>
|
||||
</Column>
|
||||
<Column sm={8} md={8} lg={8} class="mt-4 lg:mt-0">
|
||||
<TextField
|
||||
label="Title"
|
||||
placeholder="Write your title here"
|
||||
bind:value={$landingPageSettings.courses.title}
|
||||
/>
|
||||
<TextField
|
||||
label="Title-Highlight"
|
||||
placeholder="Write your title highlight here"
|
||||
bind:value={$landingPageSettings.courses.titleHighlight}
|
||||
/>
|
||||
<TextArea
|
||||
label="Subtitle"
|
||||
labelClassName="font-light"
|
||||
placeholder="Write your subtitle here"
|
||||
bind:value={$landingPageSettings.courses.subtitle}
|
||||
isAIEnabled={true}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
|
||||
<Column sm={4} md={4} lg={4}
|
||||
><SectionTitle>Frequently Asked Questions</SectionTitle>
|
||||
<Toggle bind:toggled={$landingPageSettings.faq.show} size="sm">
|
||||
<span slot="labelA" style="color: gray">Hide section</span>
|
||||
<span slot="labelB" style="color: gray">Show section</span>
|
||||
</Toggle>
|
||||
</Column>
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
<TextField
|
||||
label="Title"
|
||||
placeholder="Write your title here"
|
||||
bind:value={$landingPageSettings.faq.title}
|
||||
className="mb-5"
|
||||
/>
|
||||
{#each $landingPageSettings.faq.questions as item (item.id)}
|
||||
<div class="mb-3">
|
||||
<TextField
|
||||
label="Question"
|
||||
placeholder="Write your question here"
|
||||
bind:value={item.title}
|
||||
className="mb-5"
|
||||
/>
|
||||
<TextArea
|
||||
label="Answer"
|
||||
labelClassName="font-light"
|
||||
placeholder="Write your answer here"
|
||||
bind:value={item.content}
|
||||
className="mb-5"
|
||||
isAIEnabled={true}
|
||||
/>
|
||||
<IconButton onClick={() => deleteFaq(item.id)}>
|
||||
<TrashCan size={24} class="fill-red-700" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/each}
|
||||
{#if creatingNewQuestion}
|
||||
<TextField
|
||||
label="Question"
|
||||
placeholder="Write your question here"
|
||||
bind:value={newQuestion.title}
|
||||
className="mb-5"
|
||||
/>
|
||||
<TextArea
|
||||
label="Answer"
|
||||
labelClassName="font-light"
|
||||
placeholder="Write your answer here"
|
||||
bind:value={newQuestion.content}
|
||||
className="mb-5"
|
||||
isAIEnabled={true}
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<PrimaryButton variant={VARIANTS.OUTLINED} label="Save" onClick={saveNewFAQ} />
|
||||
<PrimaryButton variant={VARIANTS.OUTLINED} label="Cancel" onClick={cancelNewFAQ} />
|
||||
</div>
|
||||
{:else}
|
||||
<PrimaryButton variant={VARIANTS.OUTLINED} label="Add New Faq" onClick={createNewFaq} />
|
||||
{/if}
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
|
||||
<Column sm={4} md={4} lg={4}
|
||||
><SectionTitle>Contact us</SectionTitle>
|
||||
<Toggle bind:toggled={$landingPageSettings.contact.show} size="sm">
|
||||
<span slot="labelA" style="color: gray">Hide section</span>
|
||||
<span slot="labelB" style="color: gray">Show section</span>
|
||||
</Toggle>
|
||||
</Column>
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
<TextField
|
||||
label="Title"
|
||||
placeholder="Write your title here"
|
||||
bind:value={$landingPageSettings.contact.title}
|
||||
className="mb-5"
|
||||
/>
|
||||
<TextField
|
||||
label="Title-highlight"
|
||||
labelClassName="font-light"
|
||||
placeholder="Write your title highlight here"
|
||||
className="mb-5"
|
||||
bind:value={$landingPageSettings.contact.titleHighlight}
|
||||
/>
|
||||
<TextArea
|
||||
label="Subtitle"
|
||||
labelClassName="font-light"
|
||||
placeholder="Write your subtitle here"
|
||||
className="mt-3 mb-5"
|
||||
bind:value={$landingPageSettings.contact.subtitle}
|
||||
isAIEnabled={true}
|
||||
/>
|
||||
<TextField
|
||||
label="Phone number"
|
||||
placeholder="Write your phone number here"
|
||||
className="mt-3 mb-5"
|
||||
bind:value={$landingPageSettings.contact.phone}
|
||||
/>
|
||||
<TextField
|
||||
label="Email"
|
||||
placeholder="Write your email here"
|
||||
className="mt-3 mb-5"
|
||||
bind:value={$landingPageSettings.contact.email}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
|
||||
<Column sm={4} md={4} lg={4}
|
||||
><SectionTitle>Mailing List</SectionTitle>
|
||||
<Toggle bind:toggled={$landingPageSettings.mailinglist.show} size="sm">
|
||||
<span slot="labelA" style="color: gray">Hide section</span>
|
||||
<span slot="labelB" style="color: gray">Show section</span>
|
||||
</Toggle>
|
||||
</Column>
|
||||
<Column sm={8} md={8} lg={8} class="mt-4 lg:mt-0">
|
||||
<TextField
|
||||
label="Title"
|
||||
placeholder="Write your title here"
|
||||
bind:value={$landingPageSettings.mailinglist.title}
|
||||
className="mb-5"
|
||||
/>
|
||||
<TextArea
|
||||
label="Subtitle"
|
||||
labelClassName="font-light"
|
||||
placeholder="Write your subtitle here"
|
||||
className="mb-5"
|
||||
bind:value={$landingPageSettings.mailinglist.subtitle}
|
||||
isAIEnabled={true}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Button-label"
|
||||
placeholder="Write your label here"
|
||||
className="mb-5"
|
||||
bind:value={$landingPageSettings.mailinglist.buttonLabel}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
<Row class="flex lg:flex-row flex-col py-7">
|
||||
<Column sm={4} md={4} lg={4}
|
||||
><SectionTitle>Footer</SectionTitle>
|
||||
<Toggle bind:toggled={$landingPageSettings.footer.show} size="sm">
|
||||
<span slot="labelA" style="color: gray">Hide section</span>
|
||||
<span slot="labelB" style="color: gray">Show section</span>
|
||||
</Toggle>
|
||||
</Column>
|
||||
<Column sm={8} md={8} lg={8} class="mt-4 lg:mt-0">
|
||||
<TextField
|
||||
label="Facebook"
|
||||
placeholder="Write your Facebook link here"
|
||||
bind:value={$landingPageSettings.footer.facebook}
|
||||
className="mb-5"
|
||||
/>
|
||||
<!-- <TextField
|
||||
label="Instagram"
|
||||
placeholder="Write your Instagram link here"
|
||||
className="mb-5"
|
||||
bind:value={$landingPageSettings.footer.instagram}
|
||||
/> -->
|
||||
<TextField
|
||||
label="Twitter"
|
||||
placeholder="Write your Twitter link here"
|
||||
className="mb-5"
|
||||
bind:value={$landingPageSettings.footer.twitter}
|
||||
/>
|
||||
<TextField
|
||||
label="Linkedin"
|
||||
placeholder="Write your Linkedin link here"
|
||||
className="mb-5"
|
||||
bind:value={$landingPageSettings.footer.linkedin}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
<div class="sticky desktop float-right bottom-12 mr-2 z-[120]">
|
||||
<PrimaryButton
|
||||
label="Save Changes"
|
||||
isLoading={isSaving}
|
||||
isDisabled={isSaving}
|
||||
onClick={handleSave}
|
||||
/>
|
||||
</div>
|
||||
</Grid>
|
||||
|
||||
<div
|
||||
class="absolute
|
||||
mobile right-6 bottom-8 z-[120]"
|
||||
>
|
||||
<span>
|
||||
<IconButton onClick={handleSave} disabled={isSaving}>
|
||||
<Save size={40} class=" bg-blue-700 p-1 rounded-full" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@media screen and (min-width: 769px) {
|
||||
.desktop {
|
||||
display: block;
|
||||
}
|
||||
.mobile {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width: 768px) {
|
||||
.desktop {
|
||||
display: none;
|
||||
}
|
||||
.mobile {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,199 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { Grid, Row, Column } from 'carbon-components-svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import UploadImage from '$lib/components/UploadImage/index.svelte';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
|
||||
import SectionTitle from '../SectionTitle.svelte';
|
||||
|
||||
let avatar;
|
||||
let loading = false;
|
||||
const themes = {
|
||||
rose: 'theme-rose',
|
||||
green: 'theme-green',
|
||||
orange: 'theme-orange',
|
||||
violet: 'theme-violet',
|
||||
default: ''
|
||||
};
|
||||
|
||||
function handleChangeTheme(t = '') {
|
||||
return async () => {
|
||||
document.body.className = document.body.className
|
||||
.split(' ')
|
||||
.filter((c) => !c.includes('theme'))
|
||||
.join(' ')
|
||||
.concat(!!t ? ' ' : '', t);
|
||||
$currentOrg.theme = t;
|
||||
|
||||
const res = await supabase
|
||||
.from('organization')
|
||||
.update({ theme: t })
|
||||
.match({ id: $currentOrg.id });
|
||||
|
||||
console.log('Update theme', res);
|
||||
};
|
||||
}
|
||||
|
||||
async function handleUpdate() {
|
||||
try {
|
||||
loading = true;
|
||||
|
||||
const updates = {
|
||||
name: $currentOrg.name
|
||||
};
|
||||
|
||||
if (avatar) {
|
||||
const filename = `user/${$currentOrg.name + Date.now()}.webp`;
|
||||
|
||||
const { data } = await supabase.storage.from('avatars').upload(filename, avatar, {
|
||||
cacheControl: '3600',
|
||||
upsert: false
|
||||
});
|
||||
|
||||
if (data) {
|
||||
const { data: response } = supabase.storage.from('avatars').getPublicUrl(filename);
|
||||
|
||||
updates.avatar_url = response.publicUrl;
|
||||
$currentOrg.avatar_url = response.publicUrl;
|
||||
}
|
||||
avatar = undefined;
|
||||
}
|
||||
|
||||
let { error } = await supabase
|
||||
.from('organization')
|
||||
.update(updates)
|
||||
.match({ id: $currentOrg.id });
|
||||
|
||||
currentOrg.update((_currentOrg) => ({
|
||||
..._currentOrg,
|
||||
...updates
|
||||
}));
|
||||
|
||||
snackbar.success('Update successful');
|
||||
|
||||
if (error) throw error;
|
||||
} catch (error) {
|
||||
let message = error.message;
|
||||
if (message.includes('profile_username_key')) {
|
||||
message = 'username already exists';
|
||||
}
|
||||
|
||||
snackbar.error(`Update failed: ${message}`);
|
||||
loading = false;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function gotoSetting(pathname) {
|
||||
goto(`${$currentOrgPath}/settings${pathname}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Grid class="border-c rounded border-gray-200 w-full mt-5">
|
||||
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
|
||||
<Column sm={4} md={4} lg={4}><SectionTitle>Organization Profile</SectionTitle></Column>
|
||||
<Column sm={8} md={8} lg={8} class="mt-2 lg:mt-0 flex flex-col items-center lg:items-start">
|
||||
<TextField
|
||||
label="Organization Name"
|
||||
bind:value={$currentOrg.name}
|
||||
className="w-full lg:w-60 mb-5"
|
||||
/>
|
||||
<UploadImage
|
||||
bind:avatar
|
||||
src={$currentOrg.avatar_url}
|
||||
shape="rounded-md"
|
||||
widthHeight="w-24 h-24"
|
||||
/>
|
||||
<PrimaryButton
|
||||
label="Update Organization"
|
||||
className="px-6 py-3 lg:mr-5 mt-5"
|
||||
isLoading={loading}
|
||||
isDisabled={loading}
|
||||
onClick={handleUpdate}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
|
||||
<Column sm={4} md={4} lg={4}><SectionTitle>Theme</SectionTitle></Column>
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
<h4 class="dark:text-white lg:mt-0">Set your brand color</h4>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="rounded-full border-2 {$currentOrg.theme === themes.default &&
|
||||
'border-[#1d4ee2]'} mr-3 flex items-center justify-center"
|
||||
on:click={handleChangeTheme(themes.default)}
|
||||
>
|
||||
<div class="w-3 h-3 md:w-6 md:h-6 bg-[#1d4ee2] rounded-full m-1" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="rounded-full border-2 {$currentOrg.theme === themes.rose &&
|
||||
'border-[#be1241]'} mr-3 flex items-center justify-center"
|
||||
on:click={handleChangeTheme(themes.rose)}
|
||||
>
|
||||
<div class="w-3 h-3 md:w-6 md:h-6 bg-[#be1241] rounded-full m-1" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="rounded-full border-2 {$currentOrg.theme === themes.green &&
|
||||
'border-[#0c891b]'} mr-3 flex items-center justify-center"
|
||||
on:click={handleChangeTheme(themes.green)}
|
||||
>
|
||||
<div class="w-3 h-3 md:w-6 md:h-6 bg-[#0c891b] rounded-full m-1" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="rounded-full border-2 {$currentOrg.theme === themes.orange &&
|
||||
'border-[#cc4902]'} mr-3 flex items-center justify-center"
|
||||
on:click={handleChangeTheme(themes.orange)}
|
||||
>
|
||||
<div class="w-3 h-3 md:w-6 md:h-6 bg-[#cc4902] rounded-full m-1" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="rounded-full border-2 {$currentOrg.theme === themes.violet &&
|
||||
'border-[#cf00ce]'} mr-3 flex items-center justify-center"
|
||||
on:click={handleChangeTheme(themes.violet)}
|
||||
>
|
||||
<div class="w-3 h-3 md:w-6 md:h-6 bg-[#cf00ce] rounded-full m-1" />
|
||||
</button>
|
||||
</div>
|
||||
</Column>
|
||||
</Row>
|
||||
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
|
||||
<Column sm={4} md={4} lg={4}><SectionTitle>Custom Domain</SectionTitle></Column>
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
<h4 class="dark:text-white lg:mt-0">Customise your Domain</h4>
|
||||
<p class="text-sm text-gray-500 dark:text-white">
|
||||
Create a custom URL so your audience can get to your organization easily
|
||||
</p>
|
||||
<PrimaryButton
|
||||
label="Edit domain"
|
||||
className="my-7 py-5 px-10 text-primary-700"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
onClick={() => gotoSetting('/domains')}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
<Row class="flex lg:flex-row flex-col py-7 border-bottom-c">
|
||||
<Column sm={4} md={4} lg={4}><SectionTitle>Team</SectionTitle></Column>
|
||||
<Column sm={8} md={8} lg={8}>
|
||||
<h4 class="dark:text-white lg:mt-0">Set up your website</h4>
|
||||
<p class="text-sm text-gray-500 dark:text-white">
|
||||
Add team mates to your organization so you can both collaborate on projects.
|
||||
</p>
|
||||
<PrimaryButton
|
||||
label="Manage Team"
|
||||
className="my-7 py-5 px-10 text-primary-700"
|
||||
variant={VARIANTS.OUTLINED}
|
||||
onClick={() => gotoSetting('/teams')}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
</Grid>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script>
|
||||
import { Grid, Row, Column } from 'carbon-components-svelte';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import SectionTitle from '../SectionTitle.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import UploadImage from '$lib/components/UploadImage/index.svelte';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import LogoutButton from '$lib/components/Buttons/Logout/index.svelte';
|
||||
import generateUUID from '$lib/utils/functions/generateUUID';
|
||||
|
||||
let avatar = '';
|
||||
let loading = false;
|
||||
|
||||
async function handleUpdate() {
|
||||
try {
|
||||
loading = true;
|
||||
|
||||
const updates = {
|
||||
fullname: $profile.fullname,
|
||||
username: $profile.username,
|
||||
email: $profile.email
|
||||
};
|
||||
|
||||
if (avatar) {
|
||||
const filename = `user/${generateUUID()}.webp`;
|
||||
|
||||
const { data } = await supabase.storage.from('avatars').upload(filename, avatar, {
|
||||
cacheControl: '3600',
|
||||
upsert: false
|
||||
});
|
||||
if (data) {
|
||||
const { data: response } = await supabase.storage.from('avatars').getPublicUrl(filename);
|
||||
|
||||
updates.avatar_url = response.publicUrl;
|
||||
$profile.avatar_url = response.publicUrl;
|
||||
}
|
||||
avatar = undefined;
|
||||
}
|
||||
|
||||
let { error } = await supabase.from('profile').update(updates).match({ id: $profile.id });
|
||||
|
||||
profile.update((_profile) => ({
|
||||
..._profile,
|
||||
...updates
|
||||
}));
|
||||
snackbar.success('Update successful');
|
||||
|
||||
if (error) throw error;
|
||||
} catch (error) {
|
||||
let message = error.message;
|
||||
if (message.includes('profile_username_key')) {
|
||||
message = 'username already exists';
|
||||
}
|
||||
snackbar.success(`Update failed: ${message}`);
|
||||
loading = false;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Grid class="border-c rounded border-gray-200 w-full mt-5">
|
||||
<Row class="flex flex-col lg:flex-row items-center lg:items-start py-7 border-bottom-c ">
|
||||
<Column sm={4} md={8} lg={4} class="mt-2 md:mt-0">
|
||||
<SectionTitle>Profile Picture</SectionTitle>
|
||||
</Column>
|
||||
<Column sm={2} md={4} lg={8} class="mt-2 lg:mt-0">
|
||||
<UploadImage bind:avatar src={$profile.avatar_url} widthHeight="w-16 h-16 lg:w-24 lg:h-24" />
|
||||
</Column>
|
||||
</Row>
|
||||
<Row class="flex flex-col lg:flex-row py-7 border-bottom-c">
|
||||
<Column sm={4} md={4} lg={4}>
|
||||
<SectionTitle>Personal Information</SectionTitle>
|
||||
</Column>
|
||||
<Column sm={8} md={8} lg={8} class="mt-2 lg:mt-0">
|
||||
<TextField label="Full Name" bind:value={$profile.fullname} className="w-full lg:w-60 mb-4" />
|
||||
<TextField label="Username" bind:value={$profile.username} className="w-full lg:w-60 mb-4" />
|
||||
<TextField label="Email" bind:value={$profile.email} className="w-full lg:w-60 mb-4" />
|
||||
</Column>
|
||||
</Row>
|
||||
<Row class="m-5 w-full flex items-center gap-2 lg:justify-center">
|
||||
<PrimaryButton
|
||||
label="Update Profile"
|
||||
className="mr-5"
|
||||
isLoading={loading}
|
||||
isDisabled={loading}
|
||||
onClick={handleUpdate}
|
||||
/>
|
||||
<LogoutButton />
|
||||
</Row>
|
||||
</Grid>
|
||||
@@ -0,0 +1,192 @@
|
||||
<script>
|
||||
import { Grid, Row, Column, Select, SelectItem } from 'carbon-components-svelte';
|
||||
import { Moon } from 'svelte-loading-spinners';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import TextChip from '$lib/components/Chip/Text.svelte';
|
||||
import ComingSoon from '$lib/components/ComingSoon/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
|
||||
import { validateEmailInString } from '$lib/utils/functions/validator';
|
||||
import { ROLE_LABEL, ROLE } from '$lib/utils/constants/roles';
|
||||
import { currentOrg, orgTeam } from '$lib/utils/store/org';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { getOrgTeam } from '$lib/utils/services/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import SectionTitle from '../SectionTitle.svelte';
|
||||
import {
|
||||
triggerSendEmail,
|
||||
NOTIFICATION_NAME
|
||||
} from '$lib/utils/services/notification/notification';
|
||||
|
||||
let emailsStr = '';
|
||||
let errorMessage = '';
|
||||
let role = ROLE.TUTOR;
|
||||
let isFetching = false;
|
||||
let isLoading = false;
|
||||
let isRemoving = false;
|
||||
|
||||
async function onSendInvite() {
|
||||
const { hasError, error: _error, emails } = validateEmailInString(emailsStr);
|
||||
|
||||
if (hasError) {
|
||||
errorMessage = _error;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
let apiError = '';
|
||||
emails.forEach(async (email, index) => {
|
||||
if (apiError) return;
|
||||
const { data, error } = await supabase
|
||||
.from('organizationmember')
|
||||
.insert({
|
||||
organization_id: $currentOrg.id,
|
||||
email,
|
||||
role_id: role,
|
||||
verified: false
|
||||
})
|
||||
.select();
|
||||
console.log('data', data);
|
||||
|
||||
if (error) {
|
||||
apiError = error;
|
||||
|
||||
console.error('onSendInvite:', error);
|
||||
snackbar.error(`Failed to send invite, please try again`);
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
const [newMember] = data || [];
|
||||
if (newMember) {
|
||||
orgTeam.update((team) => [
|
||||
{
|
||||
id: newMember.id,
|
||||
email: newMember?.email,
|
||||
verified: newMember?.verified,
|
||||
role: ROLE_LABEL[newMember?.role_id] || '',
|
||||
isAdmin: newMember?.role_id === ROLE.ADMIN
|
||||
},
|
||||
...team
|
||||
]);
|
||||
}
|
||||
|
||||
triggerSendEmail(NOTIFICATION_NAME.INVITE_TEACHER, {
|
||||
email: newMember.email,
|
||||
org: {
|
||||
id: $currentOrg.id,
|
||||
name: $currentOrg.name,
|
||||
siteName: $currentOrg.siteName
|
||||
}
|
||||
});
|
||||
|
||||
const isLast = index === emails.length - 1;
|
||||
if (isLast) {
|
||||
snackbar.success('Success');
|
||||
|
||||
emailsStr = '';
|
||||
isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function onRemove(id) {
|
||||
console.log('onRemove called');
|
||||
isRemoving = true;
|
||||
const { data, error } = await supabase.from('organizationmember').delete().match({ id });
|
||||
|
||||
if (error) {
|
||||
console.error('onRemove:', error);
|
||||
snackbar.error('Failed to remove user, please try again');
|
||||
} else {
|
||||
orgTeam.update((team) => [...team.filter((member) => member.id !== id)]);
|
||||
}
|
||||
|
||||
isRemoving = false;
|
||||
}
|
||||
|
||||
const fetchTeam = async (id) => {
|
||||
if (!id) return;
|
||||
|
||||
isFetching = true;
|
||||
await getOrgTeam(id);
|
||||
isFetching = false;
|
||||
};
|
||||
|
||||
const isTeamMemberAdmin = (members, profileId) => {
|
||||
return members.some((member) => member.profileId === profileId && member.isAdmin);
|
||||
};
|
||||
|
||||
$: fetchTeam($currentOrg.id);
|
||||
</script>
|
||||
|
||||
<Grid class="border rounded border-gray-200 w-full mt-5">
|
||||
<Row class="py-7 border-bottom-c">
|
||||
<Column sm={2} md={2} lg={4} class="text-lg"><SectionTitle>Add</SectionTitle></Column>
|
||||
<Column sm={2} md={6} lg={8}>
|
||||
<p class="text-md text-gray-500 dark:text-white mb-5">
|
||||
Add your team mates or collaborators to your organization. Start working together
|
||||
</p>
|
||||
|
||||
<div class="">
|
||||
<TextField
|
||||
label="Invite"
|
||||
placeholder="email comma separated"
|
||||
bind:value={emailsStr}
|
||||
className="mb-3"
|
||||
{errorMessage}
|
||||
/>
|
||||
|
||||
<Select labelText="Role" bind:selected={role} class="mb-5 w-40">
|
||||
<!-- <SelectItem value={ROLE.ADMIN} text={ROLE_LABEL[ROLE.ADMIN]} /> -->
|
||||
<SelectItem value={ROLE.TUTOR} text={ROLE_LABEL[ROLE.TUTOR]} />
|
||||
</Select>
|
||||
|
||||
<PrimaryButton
|
||||
label="Send Invite"
|
||||
onClick={onSendInvite}
|
||||
{isLoading}
|
||||
isDisabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
<Row class="py-7 border-bottom-c">
|
||||
<Column sm={2} md={2} lg={4} class="text-lg"><SectionTitle>Members</SectionTitle></Column>
|
||||
<Column sm={2} md={6} lg={8}>
|
||||
{#if isFetching}
|
||||
<Moon />
|
||||
{:else}
|
||||
{#each $orgTeam as teamMember}
|
||||
<div class="flex justify-between items-center mb-5">
|
||||
<div class="flex">
|
||||
<p class="text-sm text-gray-500 dark:text-white mr-3">
|
||||
{teamMember.email}
|
||||
</p>
|
||||
<TextChip value={teamMember.role} className="text-xs mr-3" size="sm" />
|
||||
{#if !teamMember.verified}
|
||||
<TextChip
|
||||
value="Invite Sent"
|
||||
className="text-xs bg-yellow-200 text-yellow-700"
|
||||
size="sm"
|
||||
/>
|
||||
{:else if teamMember.profileId === $profile.id}
|
||||
<ComingSoon label="You" />
|
||||
{/if}
|
||||
</div>
|
||||
{#if teamMember.profileId !== $profile.id && isTeamMemberAdmin($orgTeam, $profile.id)}
|
||||
<PrimaryButton
|
||||
label="Remove"
|
||||
variant={VARIANTS.TEXT_DANGER}
|
||||
onClick={() => onRemove(teamMember.id)}
|
||||
isLoading={isRemoving}
|
||||
isDisabled={isRemoving}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</Column>
|
||||
</Row>
|
||||
</Grid>
|
||||
@@ -0,0 +1,98 @@
|
||||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { Tabs, Tab, TabContent } from 'carbon-components-svelte';
|
||||
import Profile from './Profile.svelte';
|
||||
import LandingpageSettings from './LandingpageSettings.svelte';
|
||||
// import Account from './Account.svelte';
|
||||
import OrgSettings from './OrgSettings.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { goto } from '$app/navigation';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import Integrations from './Integrations.svelte';
|
||||
|
||||
let selected = 0;
|
||||
let query = new URLSearchParams($page.url.search);
|
||||
let tabKey = query.get('tab') || '';
|
||||
|
||||
let tabs = [];
|
||||
|
||||
function getSelectedByTab(tabKey = '') {
|
||||
const tab = tabs.find((t) => t.tabKey === tabKey);
|
||||
|
||||
return tab ? tab.key : 0;
|
||||
}
|
||||
function changeRouteOnTabChange(key = 0) {
|
||||
const tab = tabs.find((t) => t.key === key);
|
||||
if (tab) {
|
||||
return goto(tab.href);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
selected = getSelectedByTab(tabKey);
|
||||
});
|
||||
|
||||
$: {
|
||||
if (browser) {
|
||||
changeRouteOnTabChange(selected);
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
tabs = [
|
||||
{
|
||||
key: 0,
|
||||
label: 'Profile',
|
||||
tabKey: '',
|
||||
href: $page.url.pathname,
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
key: 1,
|
||||
label: 'Organization',
|
||||
tabKey: 'org',
|
||||
href: `${$page.url.pathname}?tab=org`,
|
||||
disabled: !$isOrgAdmin
|
||||
},
|
||||
{
|
||||
key: 2,
|
||||
label: 'LandingPage',
|
||||
tabKey: 'landingpage',
|
||||
href: `${$page.url.pathname}?tab=landingpage`,
|
||||
disabled: !$isOrgAdmin
|
||||
},
|
||||
{
|
||||
key: 3,
|
||||
label: 'Integrations',
|
||||
tabKey: 'integrations',
|
||||
href: `${$page.url.pathname}?tab=integrations`,
|
||||
disabled: false
|
||||
}
|
||||
];
|
||||
}
|
||||
</script>
|
||||
|
||||
<Tabs autoWidth bind:selected>
|
||||
{#each tabs as tab}
|
||||
<Tab label={tab.label} href={tab.href} disabled={tab.disabled} />
|
||||
{/each}
|
||||
<!-- <Tab label="Account" /> -->
|
||||
<svelte:fragment slot="content">
|
||||
<TabContent class="w-full p-0">
|
||||
<Profile />
|
||||
</TabContent>
|
||||
<!-- <TabContent>
|
||||
<Account />
|
||||
</TabContent> -->
|
||||
<TabContent class="w-full p-0">
|
||||
<OrgSettings />
|
||||
</TabContent>
|
||||
<TabContent class="w-full p-0">
|
||||
<LandingpageSettings />
|
||||
</TabContent>
|
||||
<TabContent class="w-full p-0">
|
||||
<Integrations />
|
||||
</TabContent>
|
||||
</svelte:fragment>
|
||||
</Tabs>
|
||||
@@ -0,0 +1,146 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { Writable } from 'svelte/store';
|
||||
|
||||
export interface OrgLandingPageJson {
|
||||
header: {
|
||||
title: string;
|
||||
titleHighlight: string;
|
||||
subtitle: string;
|
||||
action: {
|
||||
label: string;
|
||||
link: string;
|
||||
redirect: boolean;
|
||||
};
|
||||
banner: {
|
||||
video: string;
|
||||
image: string;
|
||||
type: string;
|
||||
show: boolean;
|
||||
};
|
||||
show: boolean;
|
||||
};
|
||||
aboutUs: {
|
||||
title: string;
|
||||
content: string;
|
||||
imageUrl: string;
|
||||
show: boolean;
|
||||
};
|
||||
courses: {
|
||||
title: string;
|
||||
titleHighlight: string;
|
||||
subtitle: string;
|
||||
show: boolean;
|
||||
};
|
||||
faq: {
|
||||
title: string;
|
||||
questions: Array<{
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
}>;
|
||||
show: boolean;
|
||||
};
|
||||
contact: {
|
||||
title: string;
|
||||
titleHighlight: string;
|
||||
subtitle: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
show: boolean;
|
||||
};
|
||||
mailinglist: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
buttonLabel: string;
|
||||
show: boolean;
|
||||
};
|
||||
footer: {
|
||||
facebook: string;
|
||||
instagram: string;
|
||||
twitter: string;
|
||||
linkedin: string;
|
||||
show: boolean;
|
||||
};
|
||||
}
|
||||
export let landingPageSettings: Writable<OrgLandingPageJson> = writable({
|
||||
header: {
|
||||
title: 'Web Development Courses for',
|
||||
titleHighlight: 'Everyone',
|
||||
subtitle:
|
||||
'Practical project-based courses that are easy to understand and straight to the point',
|
||||
action: {
|
||||
label: 'Start learning Today',
|
||||
link: '#contact',
|
||||
redirect: false
|
||||
},
|
||||
banner: {
|
||||
video: 'https://www.youtube.com/watch?v=qqAYW7uxErI',
|
||||
image: '',
|
||||
type: 'video',
|
||||
show: true
|
||||
},
|
||||
show: true
|
||||
},
|
||||
aboutUs: {
|
||||
title: 'Our Story',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.',
|
||||
imageUrl:
|
||||
'https://tapaozmyjsuykgerrfkt.supabase.co/storage/v1/object/public/avatars/landingpage/riverside.jpeg',
|
||||
show: true
|
||||
},
|
||||
courses: {
|
||||
title: 'Explore our',
|
||||
titleHighlight: 'Courses',
|
||||
subtitle: 'Find courses you will love from best teachers all over the world🌎.',
|
||||
show: true
|
||||
},
|
||||
faq: {
|
||||
title: 'Frequently Asked Questions.',
|
||||
questions: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Lorem ipsum dolor sit, amet con',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Lorem ipsum dolor sit, amet con',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Lorem ipsum dolor sit, amet con',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
|
||||
}
|
||||
],
|
||||
show: true
|
||||
},
|
||||
contact: {
|
||||
title: 'Have you got any',
|
||||
titleHighlight: 'Question?',
|
||||
subtitle: 'Reach out to us with any question you might have.',
|
||||
address: 'Ashwoodfield House Farm, Kidderminster Rd, Stourbridge, Kingswinford DY6 0AA',
|
||||
phone: '+4407339904995',
|
||||
email: '[email protected]',
|
||||
show: true
|
||||
},
|
||||
mailinglist: {
|
||||
title: 'Join our mailing list',
|
||||
subtitle:
|
||||
'We are constantly releasing new courses and sharing them with our email list. Subscribe to get notified once we release a new course',
|
||||
buttonLabel: 'Subscribe',
|
||||
show: true
|
||||
},
|
||||
footer: {
|
||||
facebook: '#',
|
||||
instagram: '#',
|
||||
twitter: '#',
|
||||
linkedin: '#',
|
||||
show: true
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import HelpIcon from 'carbon-icons-svelte/lib/Help.svelte';
|
||||
import ForumIcon from 'carbon-icons-svelte/lib/Forum.svelte';
|
||||
|
||||
import TextChip from '$lib/components/Chip/Text.svelte';
|
||||
import OrgSelector from '$lib/components/OrgSelector/OrgSelector.svelte';
|
||||
import HomeIcon from '$lib/components/Icons/HomeIcon.svelte';
|
||||
import CourseIcon from '$lib/components/Icons/CourseIcon.svelte';
|
||||
import QuizIcon from '$lib/components/Icons/QuizIcon.svelte';
|
||||
import SiteSettingsIcon from '$lib/components/Icons/SiteSettingsIcon.svelte';
|
||||
import AudienceIcon from '$lib/components/Icons/AudienceIcon.svelte';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { NavClasses } from '$lib/utils/constants/reusableClass';
|
||||
import { sideBar } from './store';
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
path: '',
|
||||
label: 'Dashboard'
|
||||
},
|
||||
// {
|
||||
// path: '/quiz',
|
||||
// label: 'Quizzes'
|
||||
// },
|
||||
{
|
||||
path: '/courses',
|
||||
label: 'Courses'
|
||||
},
|
||||
{
|
||||
path: '/community',
|
||||
label: 'Community'
|
||||
},
|
||||
// {
|
||||
// path: '/site',
|
||||
// label: 'Site settings',
|
||||
// },
|
||||
{
|
||||
path: '/audience',
|
||||
label: 'Audience'
|
||||
}
|
||||
];
|
||||
|
||||
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 toggleSidebar = () => {
|
||||
$sideBar.hidden = !$sideBar.hidden;
|
||||
};
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class={`${
|
||||
$sideBar.hidden
|
||||
? '-translate-x-[100%] absolute md:translate-x-0 md:relative z-40'
|
||||
: 'translate-x-0 absolute md:relative z-40'
|
||||
} overflow-y-auto transition w-[250px] min-w-[250px] bg-gray-100 dark:bg-neutral-900 h-[calc(100vh-48px)] border border-l-0 border-t-0 border-b-0 border-r-1`}
|
||||
>
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="border-b border-gray-200 pt-5 px-4">
|
||||
{#if $currentOrg.avatar_url && $currentOrg.name}
|
||||
<Avatar
|
||||
src={$currentOrg.avatar_url}
|
||||
name={$currentOrg.name}
|
||||
shape="rounded-md"
|
||||
width="w-7"
|
||||
height="h-7"
|
||||
/>
|
||||
{:else if $currentOrg.shortName}
|
||||
<TextChip
|
||||
value={$currentOrg.shortName}
|
||||
className="bg-primary-200 dark:text-black font-bold"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<OrgSelector />
|
||||
|
||||
<ul class="my-5">
|
||||
{#each menuItems as menuItem}
|
||||
<a
|
||||
href="{$currentOrgPath}{menuItem.path}"
|
||||
class="text-black no-underline"
|
||||
on:click={toggleSidebar}
|
||||
>
|
||||
<li
|
||||
class="flex items-center py-3 px-4 mb-2 {NavClasses.item} {isActive(
|
||||
$page.url.pathname,
|
||||
`${$currentOrgPath}${menuItem.path}`
|
||||
)
|
||||
? NavClasses.active
|
||||
: 'dark:text-white'}"
|
||||
>
|
||||
{#if menuItem.path === ''}
|
||||
<HomeIcon />
|
||||
{:else if menuItem.path === '/courses'}
|
||||
<CourseIcon />
|
||||
{:else if menuItem.path === '/site'}
|
||||
<SiteSettingsIcon />
|
||||
{:else if menuItem.path === '/community'}
|
||||
<ForumIcon size={24} class="carbon-icon dark:fill-[#fff] fill-[#000]" />
|
||||
{:else if menuItem.path === '/quiz'}
|
||||
<QuizIcon />
|
||||
{:else if menuItem.path === '/audience'}
|
||||
<AudienceIcon />
|
||||
{/if}
|
||||
<p class=" ml-2">{menuItem.label}</p>
|
||||
</li>
|
||||
</a>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
<span class="flex-grow" />
|
||||
<ul class="my-5 pb-5 px-4">
|
||||
<a href={$currentOrgPath} class="text-black no-underline" on:click={toggleSidebar}>
|
||||
<li class="flex items-center py-3 px-4 mb-2 rounded">
|
||||
<HelpIcon size={20} class="carbon-icon dark:text-white" />
|
||||
<p class="dark:text-white ml-2">Help</p>
|
||||
</li>
|
||||
</a>
|
||||
<a href="{$currentOrgPath}/settings" class="text-black no-underline" on:click={toggleSidebar}>
|
||||
<li
|
||||
class="flex items-center py-3 px-4 mb-2 {NavClasses.item} {isActive(
|
||||
$page.url.pathname,
|
||||
`${$currentOrgPath}/settings`
|
||||
)
|
||||
? NavClasses.active
|
||||
: 'dark:text-white'}"
|
||||
>
|
||||
<Avatar src={$profile.avatar_url} name={$profile.username} width="w-7" height="h-7" />
|
||||
<p class="ml-2">Settings</p>
|
||||
</li>
|
||||
</a>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const newOrgModal = writable({
|
||||
open: false
|
||||
});
|
||||
|
||||
export const sideBar = writable({
|
||||
hidden: false
|
||||
});
|
||||
Reference in New Issue
Block a user