feat: migrate course UI from supabase (#615)
* chore: fix storybook build * chore: implement theming * community list not showing properly * refactor: migrate Chip component to @cio/ui/custom/chip - Updated imports for the Chip component across multiple files to use the new location in the @cio/ui package. - Replaced the previous Chip implementation with a new version that utilizes a Badge for better styling and functionality. - Adjusted related components to ensure consistent usage of the updated Chip component. * fix: align course sidebar design to match organization design (#611) * fix: align course sidebar design to match organization design * feat: add hideSearch prop to AppHeader and update sidebar layout --------- Co-authored-by: Rotimi Best <[email protected]> * refactor: replace PrimaryButton with Button component across multiple files - Updated various components to use the new Button component from @cio/ui/base/button instead of the deprecated PrimaryButton. - Ensured consistent button usage throughout the application for improved styling and functionality. - Removed the PrimaryButton component and its associated constants from the codebase. * refactor: standardize button and input usage across components - Replaced instances of deprecated components such as TextField, TextArea, and IconButton with their updated counterparts (InputField, TextareaField, and updated IconButton). - Ensured consistent event handling by changing `onClick` to `onclick` for better alignment with Svelte's event binding conventions. - Removed unused components and streamlined imports to enhance code maintainability and readability. * chore: themeing, course card, use $features * refactor: centralize UI components and update modal implementations - Refactored various components to centralize UI elements, improving discoverability and maintainability. - Replaced deprecated modal components with the new Dialog component for consistent modal behavior across the application. - Removed unused components and streamlined imports to enhance code clarity and reduce complexity. * feat: add new documentation and refactor org and lms UI components - Introduced new documentation files for Auth Flow, Breadcrumbs Refactoring, Page and Route Creation, and Route Page Setup to enhance developer onboarding and reference. - Refactored various UI components, including the Course Sidebar and Lesson Modal, to improve layout and functionality. - Removed deprecated components and streamlined imports for better maintainability. - Updated translations across multiple languages to ensure consistency in user experience. * refactor: update UI components and improve styling consistency - Refactored various components to enhance styling consistency, including updates to button and text color classes. - Removed deprecated components and streamlined imports for better maintainability. - Replaced instances of PageBody with a more flexible Page component structure across multiple routes. - Updated translations and UI elements to align with the new design system. - Deleted unused components to reduce code complexity and improve clarity. * update output.css --------- Co-authored-by: Abdulmuizz Hamzat <[email protected]>
This commit is contained in:
co-authored by
Abdulmuizz Hamzat
parent
e6a3cf9332
commit
9fb6c7cbaa
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import { preventDefault } from '$lib/utils/functions/svelte';
|
||||
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { DomainInput } from '@cio/ui/custom/domain-input';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { generateSitename } from '$lib/utils/functions/org';
|
||||
import { newOrgModal } from '../new-org-modal-store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
|
||||
let orgName = $state('');
|
||||
let siteName = $derived(generateSitename(orgName));
|
||||
|
||||
function resetForm() {
|
||||
orgName = '';
|
||||
siteName = '';
|
||||
orgApi.errors = {};
|
||||
}
|
||||
|
||||
async function createNewOrg() {
|
||||
await orgApi.create({ name: orgName, siteName });
|
||||
|
||||
if (orgApi.success) {
|
||||
$newOrgModal.open = false;
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={$newOrgModal.open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) $newOrgModal.open = false;
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-96">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('add_org.create_org')}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<form onsubmit={preventDefault(createNewOrg)} class="px-2">
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('add_org.name')}</Field.Label>
|
||||
<Input bind:value={orgName} placeholder="e.g Pepsi Co" autofocus />
|
||||
{#if orgApi.errors.name || orgApi.errors.orgName}
|
||||
<Field.Error>{orgApi.errors.name || orgApi.errors.orgName}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('add_org.org_sitename')}</Field.Label>
|
||||
<DomainInput bind:value={siteName} placeholder="myschool" prefix="https://" suffix=".classroomio.com" />
|
||||
{#if orgApi.errors.siteName || orgApi.errors.general}
|
||||
<Field.Error>{orgApi.errors.siteName || orgApi.errors.general}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field orientation="horizontal">
|
||||
<Button type="submit" loading={orgApi.isLoading} class="ml-auto">
|
||||
{$t('add_org.create')}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
let { src, className, alt }: { src: string | undefined; className?: string; alt?: string } = $props();
|
||||
|
||||
function loadImage(src: string | undefined): Promise<void> {
|
||||
if (!src) return Promise.reject();
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve();
|
||||
img.onerror = reject;
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await loadImage(src)}
|
||||
<div class="flex max-h-[215px] w-full items-center justify-center rounded bg-gray-100 dark:bg-neutral-800">
|
||||
<Skeleton class={className} />
|
||||
</div>
|
||||
{:then}
|
||||
<img {src} {alt} class={className} />
|
||||
{:catch}
|
||||
<div class="flex h-[200px] w-full items-center justify-center rounded bg-gray-100 dark:bg-neutral-800">
|
||||
<p class="text-sm text-red-600">{$t('courses.course_card.error_message')}</p>
|
||||
</div>
|
||||
{/await}
|
||||
|
||||
@@ -0,0 +1,576 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { fade } from 'svelte/transition';
|
||||
import MailIcon from '@lucide/svelte/icons/mail';
|
||||
import PhoneIcon from '@lucide/svelte/icons/phone';
|
||||
import RocketIcon from '@lucide/svelte/icons/rocket';
|
||||
import MapPinIcon from '@lucide/svelte/icons/map-pin';
|
||||
import TwitterIcon from '@lucide/svelte/icons/twitter';
|
||||
import FacebookIcon from '@lucide/svelte/icons/facebook';
|
||||
import LinkedinIcon from '@lucide/svelte/icons/linkedin';
|
||||
import { preventDefault } from '$lib/utils/functions/svelte';
|
||||
|
||||
import Navigation from '$lib/components/Navigation/index.svelte';
|
||||
import { PoweredBy } from '$features/ui';
|
||||
import { CourseCardList, CourseCardLoader } from '$features/course/components';
|
||||
import { landingPageSettings } from '$features/org/components/settings/landing-page-store';
|
||||
import { CoursesEmptyIcon } from '$features/ui/icons';
|
||||
|
||||
import PageLoader from './page-loader.svelte';
|
||||
import * as Accordion from '@cio/ui/base/accordion';
|
||||
import * as Button from '@cio/ui/base/button';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { validateEmail } from '$lib/utils/functions/validateEmail';
|
||||
import { courseMetaDeta, courses } from '$features/course/utils/store';
|
||||
import { orgLandingpageValidation } from '$lib/utils/functions/validator';
|
||||
|
||||
interface Props {
|
||||
orgSiteName?: string;
|
||||
org: AccountOrg;
|
||||
}
|
||||
|
||||
let { orgSiteName = '', org }: Props = $props();
|
||||
|
||||
let email: string | undefined = $state('');
|
||||
let isAdding = $state(false);
|
||||
let success = $state(false);
|
||||
let successContactSaved = $state(false);
|
||||
let viewAll = $state(false);
|
||||
let isContactSubmiting = $state(false);
|
||||
let player = $state();
|
||||
let contact = $state({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
message: ''
|
||||
});
|
||||
let contactError: Record<string, string> = $state({});
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!email || !validateEmail(email) || !org) return;
|
||||
isAdding = true;
|
||||
|
||||
// TODO: Implement email list submission
|
||||
|
||||
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
|
||||
// TODO: Implement contact submission
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
function loadData(siteName) {
|
||||
if (!siteName) return;
|
||||
|
||||
untrack(async () => {
|
||||
try {
|
||||
$courseMetaDeta.isLoading = true;
|
||||
await orgApi.getCourseBySiteName(siteName);
|
||||
if (orgApi.error) {
|
||||
console.error('Error fetching courses', orgApi.error);
|
||||
courses.set([]);
|
||||
} else {
|
||||
courses.set(orgApi.orgSiteCourses);
|
||||
}
|
||||
$courseMetaDeta.isLoading = false;
|
||||
} catch (error) {
|
||||
console.log('error', error);
|
||||
$courseMetaDeta.isLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
$landingPageSettings = {
|
||||
...landingpage
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getBgImage(settings) {
|
||||
const defaultImg = '/images/org-landingpage-banner.jpeg';
|
||||
|
||||
const { show, image } = settings.header.background || {
|
||||
show: true,
|
||||
image: defaultImg
|
||||
};
|
||||
|
||||
if (!show) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `url('${image || defaultImg}')`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadData(orgSiteName);
|
||||
});
|
||||
$effect(() => {
|
||||
initPlyr(player, $landingPageSettings.header?.banner?.video);
|
||||
});
|
||||
$effect(() => {
|
||||
setDefault(org?.landingpage);
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>
|
||||
{!org?.name ? '' : `${org.name}'s `}{$t('course.navItem.landing_page.landing_page')}
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
<PoweredBy />
|
||||
|
||||
{#if !org?.landingpage}
|
||||
<PageLoader />
|
||||
{:else}
|
||||
<main>
|
||||
<Navigation
|
||||
logo={org.avatarUrl}
|
||||
orgName={org.name}
|
||||
disableSignup={true}
|
||||
isOrgSite={true}
|
||||
customLinks={$landingPageSettings.customLinks}
|
||||
/>
|
||||
<!-- Header Section -->
|
||||
{#if $landingPageSettings.header.show}
|
||||
<header
|
||||
id="header"
|
||||
class={`relative mb-10 h-screen w-full md:h-[90vh] ${
|
||||
$landingPageSettings.header.background?.show ? 'bg-cover bg-center' : 'border-b border-gray-300'
|
||||
}`}
|
||||
style="background-image: {getBgImage($landingPageSettings)}"
|
||||
>
|
||||
<div class="absolute top-0 z-10 h-screen w-full bg-white opacity-80 md:h-[90vh]"></div>
|
||||
{#if $landingPageSettings.header.banner.show}
|
||||
<div class="flex items-center justify-center py-2 md:h-full">
|
||||
<div
|
||||
class="relative z-20 flex w-full flex-col-reverse items-center justify-center md:w-11/12 md:flex-row md:justify-between lg:w-5/6"
|
||||
>
|
||||
<!-- Org Description -->
|
||||
<div class="mr-3 flex w-11/12 flex-col items-center py-10 md:w-2/5 md:items-start">
|
||||
<p class="ui:text-primary text-2xl font-semibold capitalize">
|
||||
{org.name}
|
||||
</p>
|
||||
<h1 class="my-4 text-center text-4xl md:text-start md:text-5xl lg:text-6xl">
|
||||
{$landingPageSettings.header.title} <br /><span class="ui:text-primary"
|
||||
>{$landingPageSettings.header.titleHighlight}</span
|
||||
>
|
||||
</h1>
|
||||
<p class="text-md mb-3 text-center text-xl md:mb-5 md:text-start">
|
||||
{$landingPageSettings.header.subtitle}
|
||||
</p>
|
||||
|
||||
<Button.Root
|
||||
class="mt-2 w-fit px-10 md:mt-5"
|
||||
onclick={() => {
|
||||
$landingPageSettings.header.action.redirect && goto($landingPageSettings.header.action.link);
|
||||
}}
|
||||
>
|
||||
{$landingPageSettings.header.action.label}
|
||||
</Button.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex w-5/6 md:w-1/2 md:max-w-[650px]">
|
||||
{#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={$t('course.navItem.landing_page.header_video')}
|
||||
src={$landingPageSettings.header?.banner?.video}
|
||||
allowfullscreen
|
||||
allowtransparency
|
||||
allow="autoplay"
|
||||
></iframe>
|
||||
</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="relative z-20 flex h-full w-full items-center justify-center md:flex-row">
|
||||
<div class="mx-auto flex w-11/12 max-w-[600px] flex-col items-center py-10">
|
||||
<p class=" ui:text-primary text-2xl font-semibold capitalize">{org.name}</p>
|
||||
<h1 class="my-4 text-center text-4xl md:text-5xl lg:text-6xl">
|
||||
{$landingPageSettings.header.title} <br /><span class="ui:text-primary"
|
||||
>{$landingPageSettings.header.titleHighlight}</span
|
||||
>
|
||||
</h1>
|
||||
<p class="text-md mb-6 text-center text-xl">
|
||||
{$landingPageSettings.header.subtitle}
|
||||
</p>
|
||||
|
||||
<Button.Root
|
||||
class="mt-5 w-fit px-10"
|
||||
onclick={() => {
|
||||
$landingPageSettings.header.action.redirect && goto($landingPageSettings.header.action.link);
|
||||
}}
|
||||
>
|
||||
{$landingPageSettings.header.action.label}
|
||||
</Button.Root>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<!-- Our Story section -->
|
||||
{#if $landingPageSettings.aboutUs.show}
|
||||
<section id="aboutus" class="m-h-[400px] mx-auto my-10 w-full max-w-6xl">
|
||||
<div class="mx-4 flex flex-col items-center justify-evenly lg:flex-row">
|
||||
<div class="mb-5 mr-5 max-w-[600px] lg:mb-0 lg:w-2/5">
|
||||
<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=" max-h-[450px] rounded-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Courses Section -->
|
||||
{#if $landingPageSettings.courses.show}
|
||||
<section id="courses" transition:fade class="mx-auto my-10 w-full max-w-6xl">
|
||||
<div class="w-full">
|
||||
<div class="mx-auto w-11/12 max-w-[500px] py-10">
|
||||
<h1 class="my-4 text-center text-4xl md:text-5xl lg:text-6xl">
|
||||
{$landingPageSettings.courses.title}
|
||||
<span class="text-primary">{$landingPageSettings.courses.titleHighlight}</span>
|
||||
</h1>
|
||||
<p class="text-md text-center">
|
||||
{$landingPageSettings.courses.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if $courseMetaDeta.isLoading}
|
||||
<div class="cards-container mx-2 my-4">
|
||||
<CourseCardLoader />
|
||||
<CourseCardLoader />
|
||||
<CourseCardLoader />
|
||||
</div>
|
||||
{:else if $courses.length > 0}
|
||||
<CourseCardList courses={$courses.slice(0, viewAll ? $courses.length : 3)} isOnLandingPage={true} />
|
||||
{:else}
|
||||
<Empty
|
||||
icon={CoursesEmptyIcon}
|
||||
title={$t('course.navItem.landing_page.no_course_published')}
|
||||
description={$t('course.navItem.landing_page.coming_your_way')}
|
||||
variant="page"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if $courses.length > 3}
|
||||
<div class="mt-3 flex w-full justify-center">
|
||||
<Button.Root variant="outline" onclick={() => (viewAll = !viewAll)} class="w-fit px-10 py-5">
|
||||
{viewAll ? $t('course.navItem.landing_page.view_less') : $t('course.navItem.landing_page.view_all')}
|
||||
</Button.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- FAQ Section -->
|
||||
{#if $landingPageSettings.faq.show}
|
||||
<section id="faq" transition:fade class="mx-auto my-10 w-full max-w-[700px]">
|
||||
<div class="py-10">
|
||||
<h1 class="my-4 text-center text-4xl md:text-5xl lg:text-6xl">
|
||||
{$landingPageSettings.faq.title}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="mx-2">
|
||||
<Accordion.Root type="single" class="w-full">
|
||||
{#each $landingPageSettings.faq.questions as faq, index}
|
||||
<Accordion.Item value="item-{index}">
|
||||
<Accordion.Trigger>
|
||||
{faq.title}
|
||||
</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
{faq.content}
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
{/each}
|
||||
</Accordion.Root>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Contact Section-->
|
||||
{#if $landingPageSettings.contact.show}
|
||||
<section id="contact" transition:fade class="ui:bg-primary-foreground my-10 w-full">
|
||||
<div class="mx-auto w-full max-w-6xl">
|
||||
<div class="mx-auto w-11/12 max-w-[500px] py-10">
|
||||
<h1 class="my-4 text-center text-4xl md:text-5xl lg:text-6xl">
|
||||
{$landingPageSettings.contact.title}
|
||||
<span class="ui:text-primary">{$landingPageSettings.contact.titleHighlight}</span>
|
||||
</h1>
|
||||
<p class="text-md text-center">{$landingPageSettings.contact.subtitle}</p>
|
||||
</div>
|
||||
<div class="mx-auto max-w-[700px] pb-10">
|
||||
<!-- Contact Details -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div
|
||||
class="mx-2 flex cursor-pointer flex-col items-center justify-center break-all rounded-lg border py-2 text-center transition-all duration-500"
|
||||
>
|
||||
<MapPinIcon size={16} class="filled" />
|
||||
<p class="mt-3 max-w-[200px] text-xs md:text-sm">
|
||||
{$landingPageSettings.contact.address}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="mx-2 flex cursor-pointer flex-col items-center justify-center break-all rounded-lg border py-2 text-center transition-all duration-500"
|
||||
>
|
||||
<PhoneIcon size={16} />
|
||||
<p class="mt-3 text-xs md:text-sm">{$landingPageSettings.contact.phone}</p>
|
||||
</div>
|
||||
<div
|
||||
class="mx-2 flex cursor-pointer flex-col items-center justify-center break-all rounded-lg border py-2 text-center transition-all duration-500"
|
||||
>
|
||||
<MailIcon size={16} />
|
||||
<p class="mt-3 text-xs md:text-sm">{$landingPageSettings.contact.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Form -->
|
||||
<div class="mt-8 rounded-lg bg-white p-7">
|
||||
{#if successContactSaved}
|
||||
<div class="flex w-full items-center justify-center">
|
||||
{$t('course.navItem.landing_page.thank_you')}
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={preventDefault(handleContactSubmit)}>
|
||||
<div class="flex w-full flex-col justify-between md:flex-row">
|
||||
<div class="mr-5 w-full md:w-2/4">
|
||||
<Field.Field class="mb-5">
|
||||
<Field.Label class="font-bold">
|
||||
{$t('course.navItem.landing_page.name')}
|
||||
</Field.Label>
|
||||
<Input bind:value={contact.name} placeholder="Elon Musk" aria-invalid={!!contactError.name} />
|
||||
{#if contactError.name}
|
||||
<Field.Error>{contactError.name}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
<Field.Field class="mb-5">
|
||||
<Field.Label class="text-xs font-normal">
|
||||
{$t('course.navItem.landing_page.email')}
|
||||
</Field.Label>
|
||||
<Input
|
||||
bind:value={contact.email}
|
||||
type="email"
|
||||
placeholder="[email protected]"
|
||||
aria-invalid={!!contactError.email}
|
||||
/>
|
||||
{#if contactError.email}
|
||||
<Field.Error>{contactError.email}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
<Field.Field class="mb-5">
|
||||
<Field.Label class="text-xs font-normal">
|
||||
{$t('course.navItem.landing_page.phone')}
|
||||
</Field.Label>
|
||||
<Input
|
||||
bind:value={contact.phone}
|
||||
placeholder="+1194802480"
|
||||
aria-invalid={!!contactError.phone}
|
||||
/>
|
||||
{#if contactError.phone}
|
||||
<Field.Error>{contactError.phone}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
</div>
|
||||
<div class="w-full md:w-2/4">
|
||||
<Field.Field>
|
||||
<Field.Label>
|
||||
{$t('course.navItem.landing_page.message')}
|
||||
</Field.Label>
|
||||
<Textarea
|
||||
bind:value={contact.message}
|
||||
rows={9}
|
||||
placeholder={$t('course.navItem.landing_page.your_message')}
|
||||
aria-invalid={!!contactError.message}
|
||||
/>
|
||||
{#if contactError.message}
|
||||
<Field.Error>{contactError.message}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button.Root class="mx-auto mt-5 w-full md:mt-0" type="submit" loading={isContactSubmiting}>
|
||||
<span class="text-md mr-2">{$t('course.navItem.landing_page.submit')}</span>
|
||||
<RocketIcon size={16} class="custom" />
|
||||
</Button.Root>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Waitlist Section -->
|
||||
{#if $landingPageSettings.mailinglist.show}
|
||||
<section id="waitlist" transition:fade class="mx-auto my-10 w-[95%] max-w-6xl">
|
||||
<div class="ui:bg-primary gap-4 rounded-lg px-4 py-14 md:px-10 lg:flex-row lg:items-center">
|
||||
<div class="mx-auto flex max-w-[700px] flex-col lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="w-3/5">
|
||||
<h1 class="mb-5 mt-0 text-4xl text-white">
|
||||
{$landingPageSettings.mailinglist.title}
|
||||
</h1>
|
||||
<p class="text-md text-white">
|
||||
{$landingPageSettings.mailinglist.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<form onsubmit={preventDefault(handleSubmit)}>
|
||||
<div class="flex max-w-60 flex-col items-center">
|
||||
{#if success}
|
||||
<p class="text-white">{$t('course.navItem.landing_page.successful_sub')}</p>
|
||||
{:else}
|
||||
<Input
|
||||
bind:value={email}
|
||||
type="email"
|
||||
placeholder={$t('course.navItem.landing_page.enter')}
|
||||
required={true}
|
||||
disabled={isAdding}
|
||||
/>
|
||||
<Button.Root class="my-1 mt-2 w-full" variant="outline" type="submit" loading={isAdding}>
|
||||
{$landingPageSettings.mailinglist.buttonLabel}
|
||||
</Button.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Footer Section -->
|
||||
|
||||
{#if $landingPageSettings.footer.show}
|
||||
<footer
|
||||
id="footer"
|
||||
class="my-10 flex w-full flex-col items-center justify-center border-b-0 border-l-0 border-r-0 border-t border-gray-300 px-5 py-10 md:py-3"
|
||||
>
|
||||
<ul class="flex w-11/12 flex-col items-center 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.avatarUrl || '/logo-192.png'}
|
||||
alt={`${org.name} logo`}
|
||||
class="mx-auto inline-block max-h-10 w-10 rounded"
|
||||
data-atf="1"
|
||||
/>
|
||||
<h3 class="ml-3 text-xl text-black">{org.name}</h3>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<span class="flex-grow"></span>
|
||||
|
||||
<div class="mt-5 flex gap-2 sm:mt-0">
|
||||
{#if $landingPageSettings.footer.facebook}
|
||||
<li>
|
||||
<a
|
||||
href={$landingPageSettings.footer.facebook}
|
||||
target="_blank"
|
||||
title={$t('settings.landing_page.footer.facebook')}
|
||||
id="logo-fb"
|
||||
data-hveid="8"
|
||||
>
|
||||
<FacebookIcon size={16} />
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
{#if $landingPageSettings.footer.twitter}
|
||||
<li>
|
||||
<a
|
||||
href={$landingPageSettings.footer.twitter}
|
||||
target="_blank"
|
||||
title={$t('settings.landing_page.footer.twitter')}
|
||||
id="logo-tw"
|
||||
data-hveid="8"
|
||||
>
|
||||
<TwitterIcon size={16} />
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
|
||||
{#if $landingPageSettings.footer.linkedin}
|
||||
<li>
|
||||
<a
|
||||
href={$landingPageSettings.footer.linkedin}
|
||||
target="_blank"
|
||||
title={$t('settings.landing_page.footer.linkedin')}
|
||||
id="logo-ln"
|
||||
data-hveid="8"
|
||||
>
|
||||
<LinkedinIcon size={16} />
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
</div>
|
||||
</ul>
|
||||
</footer>
|
||||
{/if}
|
||||
</main>
|
||||
{/if}
|
||||
@@ -0,0 +1,28 @@
|
||||
<script>
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
|
||||
let searchValue = $state('');
|
||||
</script>
|
||||
|
||||
<section class="p- flex min-h-[337px] w-full items-center justify-center bg-[#040f2d]">
|
||||
<div class="flex w-full flex-col-reverse items-center justify-center md:flex-row">
|
||||
<!-- Course Description -->
|
||||
<div class="course-descr w-11/12 py-10">
|
||||
<h1 class="my-4 text-center text-5xl text-white dark:text-white">
|
||||
{$t('course.navItem.landing_page.explore')}
|
||||
</h1>
|
||||
<p class="text-md mb-6 text-center text-white dark:text-white">
|
||||
{$t('course.navItem.landing_page.find')}🌎.
|
||||
</p>
|
||||
|
||||
<div class="relative mx-auto max-w-xl">
|
||||
<Input type="text" placeholder={$t('course.navItem.landing_page.find_course')} bind:value={searchValue} />
|
||||
<Search class="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<script>
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import { CourseCardLoader } from '$features/course/components';
|
||||
</script>
|
||||
|
||||
<section class="h-screen w-screen p-5">
|
||||
<!-- navbar -->
|
||||
<div class="flex w-full items-center justify-between overflow-hidden rounded-md">
|
||||
<Skeleton class="mr-40 h-4 w-[80%] rounded-[10px]" />
|
||||
<div class="flex w-[20%] items-center justify-between">
|
||||
<Skeleton class="mr-1 h-4 w-[2%] rounded-[10px]" />
|
||||
<Skeleton class="mr-1 h-4 w-[2%] rounded-[10px]" />
|
||||
<Skeleton class="h-4 w-[2%] rounded-[10px]" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- hero -->
|
||||
<div class="my-8 flex w-full flex-row flex-wrap items-start justify-center gap-5 overflow-hidden rounded-md">
|
||||
<Skeleton class="h-80 w-full rounded-md" />
|
||||
</div>
|
||||
<!-- cards -->
|
||||
<div class="my-4 flex flex-wrap items-center justify-center gap-5 overflow-hidden rounded-md">
|
||||
<CourseCardLoader />
|
||||
<CourseCardLoader />
|
||||
<CourseCardLoader />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const newOrgModal = writable({
|
||||
open: false
|
||||
});
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { deleteModal, quizesStore } from '$lib/utils/store/org';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
let { onDelete = () => {} } = $props();
|
||||
|
||||
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>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open={$deleteModal.open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) closeModal();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-2/5 max-w-[388px]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{$deleteModal.isQuestion ? $t('components.quiz.question_delete') : $t('components.quiz.quiz_delete')}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<div class="my-10 flex w-full flex-col items-center">
|
||||
<h1 class="text-center text-lg dark:text-white">
|
||||
{#if $deleteModal.isQuestion}
|
||||
{$t('components.quiz.delete_question')}
|
||||
{:else}
|
||||
{$t('components.quiz.delete_quiz')}
|
||||
{/if}
|
||||
{$t('components.quiz.lose_content')}
|
||||
</h1>
|
||||
|
||||
<div class="mt-5">
|
||||
<Button
|
||||
onclick={() => {
|
||||
if ($deleteModal.isQuestion) {
|
||||
onDelete();
|
||||
$deleteModal.open = false;
|
||||
} else {
|
||||
deleteQuiz();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{$t('components.quiz.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="root relative mb-4 mr-4 flex w-full rounded-md border border-gray-100 p-3 transition ease-in-out dark:border-neutral-600 dark:bg-neutral-800"
|
||||
>
|
||||
<Skeleton class="h-full w-[20%]" />
|
||||
<div class="mx-5 flex w-full flex-col justify-evenly">
|
||||
<!-- Title -->
|
||||
<Skeleton class="h-4 w-2/4" />
|
||||
<!-- Description -->
|
||||
<Skeleton class="h-16 w-2/4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
height: 180px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script>
|
||||
import { preventDefault } from '$lib/utils/functions/svelte';
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { snackbar } from '$features/ui/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';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
let open = $state(false);
|
||||
let errors = $state({
|
||||
title: ''
|
||||
});
|
||||
let isLoading = $state(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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
open = $createQuizModal.openEdit || $createQuizModal.open;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('components.quiz.title')} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Dialog.Root
|
||||
bind:open
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) handleClose();
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="w-4/5 md:w-2/5">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{$createQuizModal.openEdit ? $t('components.quiz.update') : $t('components.quiz.create')} {$t(
|
||||
'components.quiz.a_quiz'
|
||||
)}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<form onsubmit={preventDefault(createQuiz)}>
|
||||
<InputField
|
||||
label={$t('components.quiz.quiz_title')}
|
||||
bind:value={$createQuizModal.title}
|
||||
autofocus={true}
|
||||
placeholder={$t('components.quiz.placehoolder')}
|
||||
className="mb-4"
|
||||
isRequired={true}
|
||||
errorMessage={errors.title}
|
||||
autoComplete={false}
|
||||
/>
|
||||
|
||||
<div class="mt-5 flex items-center justify-end">
|
||||
<Button type="submit" disabled={isLoading} loading={isLoading}>
|
||||
{$createQuizModal.openEdit ? $t('components.quiz.save') : $t('components.quiz.continue')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import PlayContainer from './container.svelte';
|
||||
import PlayHeader from './header/index.svelte';
|
||||
import { quizStore, playQuizStore } from '$lib/utils/store/org';
|
||||
import { STEPS } from '$lib/utils/constants/quiz';
|
||||
import { genQuizPin } from '$lib/utils/functions/org';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
let isGettingPin = $state(true);
|
||||
|
||||
function getPin() {
|
||||
setTimeout(() => {
|
||||
$quizStore.pin = genQuizPin();
|
||||
isGettingPin = false;
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function goToPlayersStep() {
|
||||
$playQuizStore.step = STEPS.WAIT_FOR_PLAYERS;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
getPin();
|
||||
});
|
||||
</script>
|
||||
|
||||
<PlayContainer>
|
||||
{#snippet header()}
|
||||
<div>
|
||||
<PlayHeader startCount={true} showCountDown={true} />
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet body()}
|
||||
<div class="w-full rounded-md bg-white px-5 py-7 dark:bg-neutral-800">
|
||||
<div class="mb-3">
|
||||
<p>1. Visit</p>
|
||||
<h3>play.classroomio.com</h3>
|
||||
</div>
|
||||
<div class="">
|
||||
<p>2. Enter Pin</p>
|
||||
{#if isGettingPin}
|
||||
<Spinner class="size-10! text-blue-700!" />
|
||||
{:else}
|
||||
<h3>{$quizStore.pin}</h3>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet footer()}
|
||||
<div class="flex items-center justify-center">
|
||||
<p class="mr-3">Let's go</p>
|
||||
<Button variant="outline" onclick={goToPlayersStep}>View Players</Button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</PlayContainer>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
header?: import('svelte').Snippet;
|
||||
body?: import('svelte').Snippet;
|
||||
footer?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { header, body, footer }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="root m-auto flex h-full flex-col justify-between">
|
||||
{#if header}
|
||||
<!-- HEADER -->
|
||||
<div class="header">
|
||||
{@render header?.()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if body}
|
||||
<!-- BODY -->
|
||||
<div class="body mb-3">
|
||||
{@render body?.()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if footer}
|
||||
<!-- FOOTER -->
|
||||
<div class="footer m-auto bg-white px-2 py-6 dark:bg-neutral-800">
|
||||
{@render 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,11 @@
|
||||
<script lang="ts">
|
||||
import CircleContent from './circle-content.svelte';
|
||||
|
||||
interface Props {
|
||||
answers?: number;
|
||||
}
|
||||
|
||||
let { answers = 0 }: Props = $props();
|
||||
</script>
|
||||
|
||||
<CircleContent value={answers} label="Answer(s)" />
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
interface Props {
|
||||
value?: boolean;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
let { value = false, label = '', className = '' }: Props = $props();
|
||||
|
||||
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 flex-col items-center justify-center {className}">
|
||||
{#key value}
|
||||
<h3 class="ui:text-primary" transition:fadeSlide={{ duration: 500 }}>
|
||||
{value}
|
||||
</h3>
|
||||
{/key}
|
||||
<p class="ui:text-primary 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,22 @@
|
||||
<script lang="ts">
|
||||
import { quizStore } from '$lib/utils/store/org';
|
||||
import Answer from './answer.svelte';
|
||||
import Timer from './timer.svelte';
|
||||
|
||||
interface Props {
|
||||
startCount?: boolean;
|
||||
showCountDown?: boolean;
|
||||
}
|
||||
|
||||
let { startCount = false, showCountDown = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<h3 class="text-white">{$quizStore.title}</h3>
|
||||
{#if showCountDown}
|
||||
<div class="flex">
|
||||
<Timer {startCount} timelimit={$quizStore.timelimit} />
|
||||
<Answer />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import CircleContent from './circle-content.svelte';
|
||||
|
||||
interface Props {
|
||||
startCount?: boolean;
|
||||
timelimit?: string;
|
||||
}
|
||||
|
||||
let { startCount = false, timelimit = '' }: Props = $props();
|
||||
|
||||
let countDownNo = $state(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);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
triggerCountDown(startCount);
|
||||
});
|
||||
</script>
|
||||
|
||||
<CircleContent value={countDownNo} label="Second(s)" className="mr-3" />
|
||||
@@ -0,0 +1,47 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { themeImages, STEPS } from '$lib/utils/constants/quiz';
|
||||
import { quizStore, playQuizStore } from '$lib/utils/store/org';
|
||||
import ConnectToPlay from './connect-to-play.svelte';
|
||||
import Players from './players.svelte';
|
||||
import Question from './question.svelte';
|
||||
import Scoreboard from './scoreboard.svelte';
|
||||
import Podium from './podium.svelte';
|
||||
|
||||
let bgImg = $state();
|
||||
|
||||
onMount(() => {
|
||||
bgImg = themeImages[$quizStore.theme].play;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if bgImg}
|
||||
<div
|
||||
class="absolute inset-0 z-50 h-screen w-screen bg-white"
|
||||
in:fly={{ y: 500, duration: 500 }}
|
||||
out:fly={{ y: 500, duration: 500 }}
|
||||
>
|
||||
<div
|
||||
class="h-full w-full p-5"
|
||||
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="m-auto w-full">
|
||||
<Spinner class="size-8! text-blue-700!" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1 @@
|
||||
players
|
||||
@@ -0,0 +1 @@
|
||||
Podium
|
||||
@@ -0,0 +1,102 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import QuizQuestion from '../quiz-question.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 { Button } from '@cio/ui/base/button';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
|
||||
let { exitPreview = () => {} } = $props();
|
||||
|
||||
let bgImg = $state();
|
||||
let totalQ = $quizStore.questions.length;
|
||||
let curQId = $state(0);
|
||||
let currentQuestion = $state($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 h-screen w-screen bg-white"
|
||||
in:fly={{ y: 500, duration: 500 }}
|
||||
out:fly={{ y: 500, duration: 500 }}
|
||||
>
|
||||
<div
|
||||
class="h-full w-full p-5"
|
||||
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>
|
||||
{#snippet header()}
|
||||
<div>
|
||||
<PlayHeader startCount={true} showCountDown={true} />
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet body()}
|
||||
<div class="quiz-body">
|
||||
{#if currentQuestion}
|
||||
<QuizQuestion {currentQuestion} isPreview={true} />
|
||||
{:else}
|
||||
<h2>No question added</h2>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#snippet footer()}
|
||||
<div class="flex flex-col justify-center">
|
||||
{#if currentQuestion}
|
||||
<Button variant="ghost" onclick={exitPreview} class="w-fit">Exit Preview</Button>
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
<IconButton onclick={handlePrev}>
|
||||
<ChevronLeftIcon size={16} />
|
||||
</IconButton>
|
||||
<p class="mx-3">{curQId + 1} / {totalQ}</p>
|
||||
<IconButton onclick={handleNext}>
|
||||
<ChevronRightIcon size={16} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</PlayContainer>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.quiz-body {
|
||||
width: 500px;
|
||||
margin: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
Question
|
||||
@@ -0,0 +1 @@
|
||||
Scoreboard
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import pluralize from 'pluralize';
|
||||
// import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
|
||||
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { themeImages } from '$lib/utils/constants/quiz';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
// import { onRename, onDelete } from '$lib/utils/services/org/quiz';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
import { Image } from '$features/ui';
|
||||
|
||||
let { quiz, totalQuestions } = $props();
|
||||
|
||||
/*function getOptions() {
|
||||
return [
|
||||
// {
|
||||
// label: 'Edit',
|
||||
// onClick: onEdit(quiz),
|
||||
// },
|
||||
// {
|
||||
// label: 'Share',
|
||||
// onClick: onShare(quiz),
|
||||
// },
|
||||
// {
|
||||
// label: 'Report',
|
||||
// onClick: onReport(quiz),
|
||||
// },
|
||||
{
|
||||
label: $t('components.quiz.rename'),
|
||||
onClick() {
|
||||
onRename(quiz);
|
||||
}
|
||||
},
|
||||
// {
|
||||
// label: 'Make a copy',
|
||||
// onClick() {
|
||||
// onMakeCopy(quiz);
|
||||
// },
|
||||
// },
|
||||
{
|
||||
label: $t('components.quiz.delete'),
|
||||
onClick() {
|
||||
onDelete(quiz);
|
||||
}
|
||||
}
|
||||
];
|
||||
}*/
|
||||
|
||||
function startQuiz() {}
|
||||
</script>
|
||||
|
||||
{#if quiz}
|
||||
<div
|
||||
class="root relative mb-5 flex w-full flex-col rounded-lg border bg-gray-100 p-3 transition ease-in-out hover:shadow-2xl lg:flex-row dark:bg-black"
|
||||
>
|
||||
<Image
|
||||
src={themeImages[quiz.theme]?.card || themeImages.standard.card}
|
||||
alt="quiz-card"
|
||||
className="max-w-[300px] min-w-[200px]"
|
||||
/>
|
||||
|
||||
<div class="flex w-full flex-col justify-between p-2 md:p-5">
|
||||
<div class="flex justify-between">
|
||||
<h4 class="title text-md capitalize dark:text-white">
|
||||
<a href="{$currentOrgPath}/quiz/{quiz.id}">{quiz.title}</a>
|
||||
</h4>
|
||||
|
||||
<!-- <Dropdown options={getOptions()} classNames="absolute top-4 right-4" isIcon={true}>
|
||||
<div class="rounded-full bg-gray-200 p-1 dark:bg-gray-600">
|
||||
<EllipsisIcon size={16} />
|
||||
</div>
|
||||
</Dropdown> -->
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col justify-between md:flex-row">
|
||||
<div>
|
||||
<p class="mb-2">{pluralize('question', totalQuestions, true)}</p>
|
||||
<p class="mb-2 md:mb-0">{$t('components.quiz.updated')} {calDateDiff(quiz.updated_at)}</p>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" onclick={startQuiz}>
|
||||
{$t('components.quiz.start')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.root {
|
||||
min-height: 180px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { CircleCheckIcon } from '$features/ui/icons';
|
||||
import { optionImage } from '$lib/utils/constants/quiz';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
currentQuestion?: any;
|
||||
currentError?: any;
|
||||
optionHasError?: any;
|
||||
isPreview?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
currentQuestion = $bindable({
|
||||
label: ''
|
||||
}),
|
||||
currentError = {},
|
||||
optionHasError = () => false,
|
||||
isPreview = false
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if isPreview}
|
||||
<h1 class="mb-5 text-white">{currentQuestion.label}</h1>
|
||||
{:else}
|
||||
<InputField
|
||||
placeholder={$t('components.quiz.start_typing')}
|
||||
bind:value={currentQuestion.label}
|
||||
className="w-full my-4"
|
||||
errorMessage={currentError.isLabelEmpty && $t('components.quiz.required_field')}
|
||||
isRequired={true}
|
||||
bgColor="bg-white"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Options -->
|
||||
<div class="flex w-full flex-wrap justify-between">
|
||||
{#each currentQuestion.options || [] as option}
|
||||
<div class="relative mb-5 w-2/5 rounded bg-white p-5 dark:bg-neutral-800">
|
||||
<img src={optionImage[option.id]} alt={option.id} />
|
||||
|
||||
{#if isPreview}
|
||||
<p class="mt-5">{option.label}</p>
|
||||
{:else}
|
||||
<InputField
|
||||
bind:value={option.label}
|
||||
placeholder={$t('components.quiz.type_answer')}
|
||||
bgColor="bg-white"
|
||||
className="mt-3"
|
||||
isDisabled={currentQuestion.type === 'boolean'}
|
||||
errorMessage={optionHasError(option.id, currentError.options) && $t('components.quiz.label')}
|
||||
/>
|
||||
<div class="absolute right-2 top-2 flex justify-end">
|
||||
<IconButton
|
||||
onclick={() => (option.isCorrect = !option.isCorrect)}
|
||||
class={option.isCorrect ? 'success' : ''}
|
||||
>
|
||||
<CircleCheckIcon size={16} filled={option.isCorrect} />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,54 @@
|
||||
<script>
|
||||
import QuizLoader from './loader.svelte';
|
||||
import QuizCard from './quiz-card.svelte';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import HelpCircleIcon from '@lucide/svelte/icons/help-circle';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { supabase } from '$lib/utils/functions/supabase';
|
||||
import { currentOrg, quizesStore } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
let isLoading = $state(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;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
fetchQuizes($currentOrg.id);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="m-auto my-4 flex flex-wrap items-center justify-center lg:justify-start">
|
||||
{#if isLoading}
|
||||
<QuizLoader />
|
||||
<QuizLoader />
|
||||
<QuizLoader />
|
||||
{:else}
|
||||
{#each $quizesStore as quiz}
|
||||
<QuizCard {quiz} totalQuestions={quiz.questions?.length || 0} />
|
||||
{:else}
|
||||
<Empty
|
||||
title={$t('components.quiz.no_quizz')}
|
||||
description={$t('components.quiz.interactive')}
|
||||
icon={HelpCircleIcon}
|
||||
variant="page"
|
||||
/>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
import { LANGUAGES } from '$lib/utils/constants/translation';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
value?: TLocale;
|
||||
hasLangChanged?: boolean;
|
||||
change?: () => void;
|
||||
}
|
||||
|
||||
let { className = '', value = $bindable('en'), hasLangChanged = $bindable(false), change }: Props = $props();
|
||||
|
||||
function handleSelect(selectedValue: string) {
|
||||
value = selectedValue as TLocale;
|
||||
hasLangChanged = true;
|
||||
if (change) {
|
||||
change();
|
||||
}
|
||||
}
|
||||
|
||||
const triggerContent = $derived(
|
||||
LANGUAGES.find((lang) => lang.id === value)?.text ?? $t('settings.account.select_language')
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class={className}>
|
||||
<Label>{$t('settings.account.language')}</Label>
|
||||
<Select.Root type="single" bind:value onValueChange={handleSelect}>
|
||||
<Select.Trigger>
|
||||
{triggerContent}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each LANGUAGES as lang}
|
||||
<Select.Item value={lang.id} label={lang.text}>
|
||||
{lang.text}
|
||||
</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { OrgLandingPageJson } from '$lib/utils/types/org';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const landingPageSettings: Writable<OrgLandingPageJson> = writable({
|
||||
header: {
|
||||
title: 'Web Development Courses for',
|
||||
titleHighlight: 'Everyone',
|
||||
subtitle: 'Practical project-based courses that are easy to understand and straight to the point',
|
||||
action: {
|
||||
label: 'Start learning Today',
|
||||
link: '#contact',
|
||||
redirect: false
|
||||
},
|
||||
banner: {
|
||||
video: 'https://www.youtube.com/watch?v=qqAYW7uxErI',
|
||||
image: '',
|
||||
type: 'video',
|
||||
show: true
|
||||
},
|
||||
background: {
|
||||
image: '',
|
||||
show: true
|
||||
},
|
||||
show: true
|
||||
},
|
||||
aboutUs: {
|
||||
title: 'Our Story',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.',
|
||||
imageUrl: 'https://pgrest.classroomio.com/storage/v1/object/public/avatars/landingpage/riverside.jpeg',
|
||||
show: true
|
||||
},
|
||||
courses: {
|
||||
title: 'Explore our',
|
||||
titleHighlight: 'Courses',
|
||||
subtitle: 'Find courses you will love from best teachers all over the world🌎.',
|
||||
show: true
|
||||
},
|
||||
faq: {
|
||||
title: 'Frequently Asked Questions.',
|
||||
questions: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Lorem ipsum dolor sit, amet con',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Lorem ipsum dolor sit, amet con',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Lorem ipsum dolor sit, amet con',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
|
||||
}
|
||||
],
|
||||
show: true
|
||||
},
|
||||
contact: {
|
||||
title: 'Have you got any',
|
||||
titleHighlight: 'Question?',
|
||||
subtitle: 'Reach out to us with any question you might have.',
|
||||
address: 'Ashwoodfield House Farm, Kidderminster Rd, Stourbridge, Kingswinford DY6 0AA',
|
||||
phone: '+4407339904995',
|
||||
email: '[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
|
||||
},
|
||||
customLinks: {
|
||||
show: true,
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
label: 'About Us',
|
||||
url: '#aboutus',
|
||||
openInNewTab: false
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: 'Courses',
|
||||
url: '#courses',
|
||||
openInNewTab: false
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: 'FAQ',
|
||||
url: '#faq',
|
||||
openInNewTab: false
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
label: 'Contact Us',
|
||||
url: '#contact',
|
||||
openInNewTab: false
|
||||
}
|
||||
]
|
||||
},
|
||||
footer: {
|
||||
facebook: '#',
|
||||
instagram: '#',
|
||||
twitter: '#',
|
||||
linkedin: '#',
|
||||
show: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
import { LANGUAGES } from '$lib/utils/constants/translation';
|
||||
import { t, initialized } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
value?: TLocale;
|
||||
hasLangChanged?: boolean;
|
||||
change?: () => void;
|
||||
}
|
||||
|
||||
let { className = '', value = $bindable('en'), hasLangChanged = $bindable(false), change }: Props = $props();
|
||||
|
||||
function handleSelect(selectedValue: string) {
|
||||
value = selectedValue as TLocale;
|
||||
hasLangChanged = true;
|
||||
change?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $initialized}
|
||||
<div class={className}>
|
||||
<Label class="mb-2 block">{$t('content.toggle_label')}</Label>
|
||||
<Select.Root type="single" bind:value onValueChange={handleSelect}>
|
||||
<Select.Trigger class="w-full">
|
||||
<p>{value ? LANGUAGES.find((lang) => lang.id === value)?.text : 'Pick a Language'}</p>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each LANGUAGES as language}
|
||||
<Select.Item value={language.id}>{language.text}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user