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

* feat: new components, deploy storybook

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

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

* chore: add field, card, password components

* feat: improve page component & update AuthUI path

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

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

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

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

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

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

* chore: update organization API and UI components

- Added new API methods for organization plan operations and team management.
- Introduced middleware for API key authentication and organization admin checks.
- Refactored organization-related components to utilize the new API structure.
- Updated various UI components to improve consistency and maintainability.
- Enhanced error handling and validation for organization-related actions.
- Removed deprecated services and streamlined the codebase.
This commit is contained in:
Rotimi Best
2025-12-02 09:54:38 +01:00
committed by GitHub
parent 531516a8f7
commit c7854efae3
350 changed files with 13928 additions and 3895 deletions
@@ -0,0 +1,80 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { currentOrgPlan, isFreePlan } from '$lib/utils/store/org';
import HourglassIcon from '@lucide/svelte/icons/hourglass';
import { openUpgradeModal } from '$lib/utils/functions/org';
import ReceiptIcon from '@lucide/svelte/icons/receipt';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import { Button } from '@cio/ui/base/button';
import * as Item from '@cio/ui/base/item';
let isRedirecting = $state(false);
async function onOpenBilling() {
console.log({
plan: $currentOrgPlan
});
try {
if ($currentOrgPlan?.provider === 'polar') {
const url = new URL('/api/polar/portal', window.location.origin);
url.searchParams.set('customerId', $currentOrgPlan?.customerId || '');
window.open(url.toString(), '_blank');
}
if ($currentOrgPlan?.provider === 'lmz') {
isRedirecting = true;
const url = new URL('/api/lmz/subscription', window.location.origin);
url.searchParams.set('subscriptionId', $currentOrgPlan?.subscriptionId);
const response = await fetch(url.toString());
const result = await response.json();
isRedirecting = false;
window.open(result.customerPortal, '_blank');
}
} catch (error) {
isRedirecting = false;
console.error(error);
snackbar.error($t('settings.billing.error'));
}
}
</script>
<div class="max-w-lg! flex w-full flex-col gap-4 px-2">
<p class="text-md font-medium">{$t('settings.billing.manage')}</p>
{#if $isFreePlan}
<Item.Root variant="outline">
<Item.Media variant="icon">
<HourglassIcon />
</Item.Media>
<Item.Content>
<Item.Description>{$t('settings.billing.active')}</Item.Description>
</Item.Content>
<Item.Actions>
<Button size="sm" variant="outline" onclick={openUpgradeModal}>Upgrade</Button>
</Item.Actions>
</Item.Root>
{:else}
<Item.Root variant="outline">
<Item.Media variant="icon">
<ReceiptIcon />
</Item.Media>
<Item.Content>
<Item.Description>{$t('settings.billing.provider')}</Item.Description>
</Item.Content>
<Item.Actions>
<Button size="sm" variant="outline" onclick={onOpenBilling} loading={isRedirecting}>
<ExternalLinkIcon class="size-4" />
{$t('settings.billing.open_billing')}
</Button>
</Item.Actions>
</Item.Root>
{/if}
</div>
@@ -0,0 +1,127 @@
<script lang="ts">
import { Switch } from '@cio/ui/base/switch';
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { handleOpenWidget } from '$lib/components/CourseLandingPage/store';
import { Button } from '@cio/ui/base/button';
import { Input } from '@cio/ui/base/input';
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
import * as Field from '@cio/ui/base/field';
let widgetKey = $state('');
function widgetControl(key: string) {
widgetKey = key;
$handleOpenWidget.open = true;
}
export async function handleSave() {
await orgApi.update($currentOrg.id, {
customization: $currentOrg.customization
});
}
</script>
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Legend>{$t('components.settings.customize_lms.dashboard.title')}</Field.Legend>
<Field.Group>
<Field.Field orientation="horizontal">
<Field.Label>{$t('components.settings.customize_lms.dashboard.community')}</Field.Label>
<Switch bind:checked={$currentOrg.customization.dashboard.community} />
<Field.Description class="text-sm">
{$currentOrg.customization.dashboard.community
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Field.Description>
</Field.Field>
<Field.Field orientation="horizontal">
<Field.Label>{$t('components.settings.customize_lms.dashboard.exercises')}</Field.Label>
<Switch bind:checked={$currentOrg.customization.dashboard.exercise} />
<Field.Description class="text-sm">
{$currentOrg.customization.dashboard.exercise
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Field.Description>
</Field.Field>
<Field.Field>
<Field.Label>{$t('components.settings.customize_lms.dashboard.banner_image')}</Field.Label>
<Button variant="outline" onclick={() => widgetControl('banner-image')}>
{$t('components.settings.customize_lms.dashboard.banner_image_btn')}
</Button>
{#if $currentOrg.customization.dashboard.bannerImage}
<img alt="Banner" src={$currentOrg.customization.dashboard.bannerImage} class="mt-2 w-full rounded-md" />
{/if}
{#if $handleOpenWidget.open && widgetKey === 'banner-image'}
<UploadWidget bind:imageURL={$currentOrg.customization.dashboard.bannerImage} />
{/if}
</Field.Field>
<Field.Field>
<Field.Label>{$t('components.settings.customize_lms.dashboard.banner_text')}</Field.Label>
<Input
placeholder={$t('components.settings.customize_lms.dashboard.banner_text_placeholder')}
bind:value={$currentOrg.customization.dashboard.bannerText}
/>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('components.settings.customize_lms.course.title')}</Field.Legend>
<Field.Group>
<Field.Field orientation="horizontal">
<Field.Label>{$t('components.settings.customize_lms.course.newsfeed')}</Field.Label>
<Switch bind:checked={$currentOrg.customization.course.newsfeed} />
<Field.Description class="text-gray-600">
{$currentOrg.customization.course.newsfeed
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Field.Description>
</Field.Field>
<Field.Field orientation="horizontal">
<Field.Label>{$t('components.settings.customize_lms.course.grading')}</Field.Label>
<Switch bind:checked={$currentOrg.customization.course.grading} />
<Field.Description class="text-gray-600">
{$currentOrg.customization.course.grading
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Field.Description>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('components.settings.customize_lms.apps.title')}</Field.Legend>
<Field.Group>
<Field.Field orientation="horizontal">
<Field.Label>{$t('components.settings.customize_lms.apps.poll')}</Field.Label>
<Switch bind:checked={$currentOrg.customization.apps.poll} />
<Field.Description class="text-gray-600">
{$currentOrg.customization.apps.poll
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Field.Description>
</Field.Field>
<Field.Field orientation="horizontal">
<Field.Label>{$t('components.settings.customize_lms.apps.live_comment')}</Field.Label>
<Switch bind:checked={$currentOrg.customization.apps.comments} />
<Field.Description class="text-gray-600">
{$currentOrg.customization.apps.comments
? $t('components.settings.customize_lms.enabled')
: $t('components.settings.customize_lms.disabled')}
</Field.Description>
</Field.Field>
</Field.Group>
</Field.Set>
</Field.Group>
@@ -0,0 +1,360 @@
<script lang="ts">
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import TrashIcon from '@lucide/svelte/icons/trash';
import Copy from '@lucide/svelte/icons/copy';
import isValidDomain from 'is-valid-domain';
import { goto } from '$app/navigation';
import { untrack } from 'svelte';
import { parse } from 'tldts';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { blockedSubdomain } from '$lib/utils/constants/app';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import { copyToClipboard } from '$lib/utils/functions/formatYoutubeVideo';
import { updateOrgSiteNameValidation } from '$lib/utils/functions/validator';
import { sanitizeDomain, sendDomainRequest } from '$lib/utils/functions/domain';
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
import { Textarea } from '@cio/ui/base/textarea';
import { DomainInput } from '@cio/ui/custom/domain-input';
import { ComingSoon } from '$lib/features/ui';
import { UpgradeBanner } from '$lib/features/ui';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import VisitOrgSiteButton from '$lib/components/Buttons/VisitOrgSite.svelte';
import * as Field from '@cio/ui/base/field';
let siteName = $derived($currentOrg.siteName);
let customDomain = $state('');
let customCode = $state('');
let favicon = $state('');
let isLoading = $state(false);
let isCustomDomainLoading = $state(false);
let isRefreshing = $state(false);
const isDomainValid = $derived(isValidDomain(sanitizeDomain(customDomain), { subdomain: true }));
type Error = {
siteName: string;
customDomain: string;
};
let errors: Error = $state({
siteName: '',
customDomain: ''
});
async function handleSaveSiteName() {
errors = updateOrgSiteNameValidation(siteName) as Error;
if (Object.values(errors).length) {
isLoading = false;
return;
}
if (blockedSubdomain.includes(siteName || '')) {
errors.siteName = 'Sitename already exists.';
return;
}
isLoading = true;
await orgApi.update($currentOrg.id, {
siteName
});
if (orgApi.success) {
$currentOrg.siteName = siteName;
goto(`/org/${$currentOrg.siteName}/settings/domains`);
} else {
if (orgApi.errors.general) {
errors.siteName = orgApi.errors.general;
} else {
errors.siteName = $t('add_org.sitename');
}
}
isLoading = false;
}
async function handleSaveCustomDomain() {
if (!isDomainValid) return;
const sanitizedDomain = sanitizeDomain(customDomain);
const details = parse(sanitizedDomain);
if (!details.subdomain) {
errors.customDomain = $t('components.settings.domains.custom_domain_error');
return;
}
if (sanitizedDomain.includes('classroomio.com')) {
errors.customDomain = $t('components.settings.domains.custom_domain_not_classroomio');
return;
}
isCustomDomainLoading = true;
await orgApi.update($currentOrg.id, {
customDomain: sanitizedDomain
});
if (!orgApi.success) {
errors.customDomain = orgApi.errors.general || 'Failed to update custom domain';
isCustomDomainLoading = false;
return;
}
try {
const response = await sendDomainRequest('add_domain', sanitizedDomain);
const data = await response.json();
console.log('added domain to vercel', data);
} catch (error) {
console.log('Error: adding domain to vercel', error);
snackbar.error(error as string);
isCustomDomainLoading = false;
return;
}
$currentOrg.customDomain = sanitizedDomain;
isCustomDomainLoading = false;
}
async function handleRemoveCustomDomain() {
if (!$currentOrg.customDomain) return;
isCustomDomainLoading = true;
await orgApi.update($currentOrg.id, {
customDomain: null,
isCustomDomainVerified: false
});
if (!orgApi.success) {
snackbar.error(orgApi.errors.general || 'Failed to remove custom domain');
isCustomDomainLoading = false;
return;
}
try {
const response = await sendDomainRequest('remove_domain', $currentOrg.customDomain);
const data = await response.json();
if (!data.success) {
throw new Error(data.message);
}
console.log('removed domain from vercel', data);
} catch (error) {
console.log('Error: removing domain from vercel', error);
snackbar.error(error as string);
isCustomDomainLoading = false;
return;
}
$currentOrg.customDomain = '';
$currentOrg.isCustomDomainVerified = false;
isCustomDomainLoading = false;
}
async function handleRefreshCustomDomain() {
isRefreshing = true;
try {
const response = await sendDomainRequest('verify_domain', $currentOrg.customDomain || '');
const data = await response.json();
console.log('data', data);
if (data.verified && !$currentOrg.isCustomDomainVerified) {
await orgApi.update($currentOrg.id, {
isCustomDomainVerified: true
});
if (orgApi.success) {
$currentOrg.isCustomDomainVerified = true;
}
}
} catch (error) {
console.log('Error: refreshing domain', error);
snackbar.error(error as string);
}
isRefreshing = false;
}
function resetErrors(_siteName: string, _customDomain: string) {
untrack(() => {
if (errors.siteName) {
errors.siteName = '';
}
if (errors.customDomain) {
errors.customDomain = '';
}
});
}
function getSubdomain() {
const details = parse($currentOrg.customDomain || '');
return details.subdomain;
}
$effect(() => {
resetErrors(siteName, customDomain);
});
</script>
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Legend>{$t('components.settings.domains.add')}</Field.Legend>
<Field.Description class="">{$t('settings.organization.organization_profile.custom_domain.body')}</Field.Description
>
<Field.Field>
<Field.Label>URL</Field.Label>
<DomainInput bind:value={siteName} placeholder="myschool" prefix="https://" suffix=".classroomio.com" />
{#if errors.siteName}
<Field.Error>{errors.siteName}</Field.Error>
{/if}
</Field.Field>
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={handleSaveSiteName} disabled={isLoading}>
{$t('components.settings.domains.update')}
</Button>
<VisitOrgSiteButton />
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('components.settings.domains.custom')}</Field.Legend>
<UpgradeBanner>{$t('upgrade.domain')}</UpgradeBanner>
<Field.Group>
{#if $currentOrg.customDomain}
<Field.Field>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<p class="text-md flex items-center gap-2 font-medium">
{$currentOrg.customDomain}
<Button variant="outline" size="icon-sm" onclick={() => goto(`https://${$currentOrg.customDomain}`)}>
<ArrowUpRightIcon size={16} />
</Button>
</p>
<div
class="mt-1 h-2 w-2 rounded-full bg-{$currentOrg.isCustomDomainVerified ? 'green' : 'yellow'}-400"
></div>
</div>
{#if $currentOrg.isCustomDomainVerified}
<Badge variant="default" class="bg-green-500 px-3 text-xs text-white">Verified</Badge>
{:else}
<Badge variant="default" class="bg-yellow-500 px-3 text-xs text-white">Pending verification</Badge>
{/if}
</div>
</Field.Field>
<Field.Description>{$t('components.settings.domains.dns_description')}</Field.Description>
<Field.Field>
<div class="flex items-center gap-10 rounded-md border px-4 py-2">
<div class="flex h-[72px] flex-col justify-evenly">
<Field.Label class="font-light">{$t('components.settings.domains.dns_type')}</Field.Label>
<p class="flex h-[40px] items-center">CNAME</p>
</div>
<div class="flex h-[72px] flex-col justify-evenly">
<Field.Label class="font-light">{$t('components.settings.domains.dns_name')}</Field.Label>
<p class="flex h-[40px] items-center">
{getSubdomain()}
</p>
</div>
<div class="flex h-[72px] flex-col justify-evenly">
<Field.Label class="font-light">{$t('components.settings.domains.dns_value')}</Field.Label>
<p class=" flex items-center gap-1">
cname.vercel-dns.com
<Button variant="ghost" size="icon-sm" onclick={() => copyToClipboard('cname.vercel-dns.com')}>
<p>cname.vercel-dns.com</p>
<Copy />
</Button>
</p>
</div>
</div>
</Field.Field>
<Field.Field orientation="horizontal">
<Button
variant="outline"
class="flex items-center gap-2 py-2"
onclick={handleRefreshCustomDomain}
loading={isRefreshing}
>
{#if !isRefreshing}
<RotateCcwIcon size={16} />
{/if}
{$t('components.settings.domains.refresh')}
</Button>
<Button
variant="destructive"
class="flex items-center gap-2 py-2"
onclick={handleRemoveCustomDomain}
loading={isCustomDomainLoading}
>
{#if !isCustomDomainLoading}
<TrashIcon size={16} />
{/if}
{$t('components.settings.domains.remove')}
</Button>
</Field.Field>
{:else}
<Field.Field>
<Field.Label class="font-bold">{$t('components.settings.domains.your_domain')}</Field.Label>
<DomainInput bind:value={customDomain} placeholder="courses.yourwebsite" disabled={$isFreePlan} />
{#if errors.customDomain}
<Field.Error>{errors.customDomain}</Field.Error>
{/if}
</Field.Field>
<Field.Field orientation="horizontal">
<Button
onclick={$isFreePlan ? () => {} : handleSaveCustomDomain}
loading={isCustomDomainLoading}
disabled={isLoading || !isDomainValid}
>
{$t('components.settings.domains.save')}
</Button>
</Field.Field>
{/if}
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('components.settings.domains.custom_favicon')}</Field.Legend>
<ComingSoon />
<Field.Field>
<UploadImage
shape="rounded-md"
bind:avatar={favicon}
src={$currentOrg.favicon ?? '/logo-512.png'}
widthHeight="w-16 h-16 lg:w-24 lg:h-24"
flexDirection="flex-row"
isDisabled={true}
/>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('components.settings.domains.custom_code')}</Field.Legend>
<ComingSoon />
<Field.Field>
<Textarea
bind:value={customCode}
placeholder="e.g <link rel='stylesheet' href='https://example.com/style.css' />"
class="w-4/5"
rows={7}
disabled={true}
/>
</Field.Field>
</Field.Set>
</Field.Group>
@@ -0,0 +1,8 @@
export { default as ProfilePage } from './profile.svelte';
export { default as DomainsPage } from './domains.svelte';
export { default as TeamsPage } from './teams.svelte';
export { default as CustomizeLmsPage } from './customize-lms.svelte';
export { default as OrgPage } from './org.svelte';
export { default as LandingpagePage } from './landingpage.svelte';
export { default as BillingPage } from './billing.svelte';
export { default as IntegrationsPage } from './integrations.svelte';
@@ -0,0 +1,72 @@
<script lang="ts">
import { profile } from '$lib/utils/store/user';
import { t } from '$lib/utils/functions/translations';
// import { snackbar } from '$lib/components/Snackbar/store';
import { Input } from '@cio/ui/base/input';
import { Button } from '@cio/ui/base/button';
import ArrowUpRightIcon from '$lib/components/Icons/ArrowTopRight.svelte';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
import * as Field from '@cio/ui/base/field';
let chatId: number | null = $state(null);
async function addChatId() {
if (!chatId || `${chatId}`.length < 5) return;
// TODO: Update profile with telegram chat id
// if (error) {
// console.log(error);
// snackbar.error('snackbar.course_settings.error.failed_integration');
// } else {
// $profile.telegramChatId = chatId;
// chatId = null;
// snackbar.success('snackbar.course_settings.success.successful_integration');
// }
}
async function deleteChatId() {
// const { error } = await supabase.from('profile').update({ telegram_chat_id: null }).match({ id: $profile.id });
// if (error) {
// snackbar.error('snackbar.course_settings.error.failed_deletion');
// console.log(error);
// } else {
// $profile.telegramChatId = null;
// snackbar.success('snackbar.course_settings.success.successful_deletion');
// }
}
</script>
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Legend>
<img src="/telegram-svg.svg" alt="" class="mr-2 w-10" />
{$t('settings.integrations.heading')}
</Field.Legend>
{#if $profile.telegramChatId !== null}
<Field.Field>
<div class="flex items-center">
<CircleCheckIcon size={16} filled />
<Field.Description>{$t('settings.integrations.success_message')}</Field.Description>
</div>
<Button variant="destructive" onclick={deleteChatId}>
{$t('settings.integrations.disconnect')}
</Button>
</Field.Field>
{:else}
<Field.Group>
<Field.Description>{$t('settings.integrations.sub_heading')}</Field.Description>
<Field.Description>{$t('settings.integrations.step_authenticate')}</Field.Description>
<Button variant="outline" onclick={() => window.open('https://t.me/classroomio_bot', '_blank')}>
<span class="mr-2">{$t('settings.integrations.open_bot_button')}</span>
<ArrowUpRightIcon />
</Button>
<Field.Description>{$t('settings.integrations.step_chatId')}</Field.Description>
<Field.Field>
<Input bind:value={chatId} placeholder="360111" class="w-full lg:w-60" type="number" required />
</Field.Field>
<Button variant="default" onclick={addChatId}>{$t('settings.integrations.connect_button')}</Button>
</Field.Group>
{/if}
</Field.Set>
</Field.Group>
@@ -0,0 +1,762 @@
<script lang="ts">
import { Label } from '@cio/ui/base/label';
import { Switch } from '@cio/ui/base/switch';
import TrashIcon from '@lucide/svelte/icons/trash';
import * as RadioGroup from '@cio/ui/base/radio-group';
import { landingPageSettings } from '../utils/store';
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { handleOpenWidget } from '$lib/components/CourseLandingPage/store';
import { Checkbox } from '@cio/ui/base/checkbox';
import { Input } from '@cio/ui/base/input';
import { Textarea } from '@cio/ui/base/textarea';
import { Button } from '@cio/ui/base/button';
import UploadWidget from '$lib/components/UploadWidget/index.svelte';
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
import * as Field from '@cio/ui/base/field';
import type { OrgLandingPageJson } from '$lib/utils/types/org';
import type { AccountOrg } from '$lib/features/app/types';
let creatingNewQuestion = $state(false);
let creatingNewCustomLink = $state(false);
let hasUnsavedChanges = $state(false);
let widgetKey = $state('');
const banner = [
{ value: 'video', label: `${$t('settings.landing_page.actions.banner_type.video')}` },
{ value: 'image', label: `${$t('settings.landing_page.actions.banner_type.image')}` }
];
let newQuestion = $state({
title: '',
content: ''
});
let newCustomLink = $state({
label: '',
url: '',
openInNewTab: false
});
function widgetControl(key: string) {
widgetKey = key;
$handleOpenWidget.open = true;
}
function createNewFaq() {
newQuestion = {
title: '',
content: ''
};
creatingNewQuestion = true;
}
function saveNewFAQ() {
if (newQuestion.title !== '' && newQuestion.content !== '') {
$landingPageSettings.faq.questions = [
...$landingPageSettings.faq.questions,
{
id: new Date().getTime(),
title: newQuestion.title,
content: newQuestion.content
}
];
creatingNewQuestion = false;
}
}
function cancelNewFAQ() {
creatingNewQuestion = false;
}
function deleteFaq(id: number) {
let faqs = $landingPageSettings.faq.questions;
const filteredFaq = faqs.filter((faq) => faq.id !== id);
$landingPageSettings.faq.questions = filteredFaq;
}
function createNewCustomLink() {
newCustomLink = {
label: '',
url: '',
openInNewTab: false
};
creatingNewCustomLink = true;
}
function saveNewCustomLink() {
if (newCustomLink.label !== '' && newCustomLink.url !== '') {
$landingPageSettings.customLinks.links = [
...$landingPageSettings.customLinks.links,
{
id: new Date().getTime(),
label: newCustomLink.label,
url: newCustomLink.url,
openInNewTab: newCustomLink.openInNewTab
}
];
creatingNewCustomLink = false;
}
}
function cancelNewCustomLink() {
creatingNewCustomLink = false;
}
function deleteCustomLink(id: number) {
let customLinks = $landingPageSettings.customLinks.links;
const filteredCustomLinks = customLinks.filter((link) => link.id !== id);
$landingPageSettings.customLinks.links = filteredCustomLinks;
}
const checkPrefix = (inputValue: string) => {
if (!inputValue) return;
if (inputValue.trim() !== '') {
if (!inputValue.startsWith('https://')) {
inputValue = 'https://' + inputValue;
}
}
return inputValue;
};
export async function handleSave() {
$landingPageSettings.footer.twitter = checkPrefix($landingPageSettings.footer.twitter) || '';
$landingPageSettings.footer.linkedin = checkPrefix($landingPageSettings.footer.linkedin) || '';
$landingPageSettings.footer.facebook = checkPrefix($landingPageSettings.footer.facebook) || '';
await orgApi.update($currentOrg.id, {
landingpage: $landingPageSettings as AccountOrg['landingpage']
});
if (orgApi.success) {
$currentOrg.landingpage = $landingPageSettings as AccountOrg['landingpage'];
hasUnsavedChanges = false;
}
}
// Set default from store
currentOrg.subscribe((cOrg) => {
if (cOrg.landingpage && Object.keys(cOrg.landingpage).length) {
const landingpage = { ...(cOrg.landingpage as unknown as OrgLandingPageJson) };
// Fallbacks for new keys we added into JSON, in case a user already saved the old JSON
if (!landingpage?.header?.banner) {
landingpage.header.banner = $landingPageSettings.header.banner;
}
if (!landingpage?.header?.background) {
landingpage.header.background = $landingPageSettings.header.background;
}
if (!landingpage.customLinks) {
landingpage.customLinks = $landingPageSettings.customLinks;
}
$landingPageSettings = {
...landingpage
};
}
});
</script>
<UnsavedChanges bind:hasUnsavedChanges />
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Legend>{$t('settings.landing_page.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.header.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Field.Label class="text-gray-600">
{$landingPageSettings.header.show
? $t('settings.landing_page.show_section')
: $t('settings.landing_page.hide_section')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.landing_page.title')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.header.title}
oninput={() => (hasUnsavedChanges = true)}
class="w-full"
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.title_highlight')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.highlight')}
class="w-full"
bind:value={$landingPageSettings.header.titleHighlight}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.subtitle')}</Field.Label>
<Textarea
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
class="w-full"
bind:value={$landingPageSettings.header.subtitle}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.actions.heading')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.label')}
class="w-full"
bind:value={$landingPageSettings.header.action.label}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.actions.link')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.link')}
bind:value={$landingPageSettings.header.action.link}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch
bind:checked={$landingPageSettings.header.action.redirect}
onCheckedChange={() => (hasUnsavedChanges = true)}
/>
<Field.Label class="text-gray-600">
{$landingPageSettings.header.action.redirect
? $t('settings.landing_page.actions.redirect')
: $t('settings.landing_page.actions.no_redirect')}
</Field.Label>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.actions.banner_type.heading')}</Field.Label>
<RadioGroup.Root
bind:value={$landingPageSettings.header.banner.type}
onValueChange={() => {
if (hasUnsavedChanges) return;
hasUnsavedChanges = true;
}}
>
{#each banner as item}
<div class="flex items-center space-x-2">
<RadioGroup.Item value={item.value} id={item.value} />
<Label for={item.value} class="cursor-pointer">{item.label}</Label>
</div>
{/each}
</RadioGroup.Root>
</Field.Field>
{#if $landingPageSettings.header.banner.type === 'video'}
<Field.Field>
<Field.Label>{$t('settings.landing_page.actions.link')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.video')}
bind:value={$landingPageSettings.header.banner.video}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
{:else}
<Field.Field>
<Button variant="outline" onclick={() => widgetControl('header.banner.image')}>
{$t('settings.landing_page.about.select_image')}
</Button>
</Field.Field>
{/if}
{#if $landingPageSettings.header.banner.image && $landingPageSettings.header.banner.type === 'image'}
<img alt="bannerImage" src={$landingPageSettings.header.banner.image} class="mt-2 w-full rounded-md" />
{/if}
<Field.Field orientation="horizontal">
<Switch
bind:checked={$landingPageSettings.header.banner.show}
onCheckedChange={() => (hasUnsavedChanges = true)}
/>
<Field.Label class="text-gray-600">
{$landingPageSettings.header.banner.show
? $t('settings.landing_page.actions.show_banner')
: $t('settings.landing_page.actions.hide_banner')}
</Field.Label>
</Field.Field>
{#if $handleOpenWidget.open && widgetKey === 'banner'}
<UploadWidget
bind:imageURL={$landingPageSettings.header.banner.image}
onchange={() => (hasUnsavedChanges = true)}
/>
{/if}
<Field.Field>
<Field.Label>{$t('settings.landing_page.background.title')}</Field.Label>
<Button variant="outline" onclick={() => widgetControl('background')}>
{$t('settings.landing_page.about.select_image')}
</Button>
</Field.Field>
{#if $landingPageSettings.header.background?.image}
<img alt="backgroundImage" src={$landingPageSettings.header.background.image} class="mt-2 w-full rounded-md" />
{/if}
<Field.Field orientation="horizontal">
<Switch
checked={$landingPageSettings.header.background?.show}
onCheckedChange={() => {
hasUnsavedChanges = true;
if (!$landingPageSettings.header.background) {
$landingPageSettings.header.background = {
image: '',
show: true
};
} else {
$landingPageSettings.header.background.show = !$landingPageSettings.header.background.show;
}
}}
/>
<Field.Label class="text-gray-600">
{$landingPageSettings.header.background?.show
? $t('settings.landing_page.background.show_background')
: $t('settings.landing_page.background.hide_background')}
</Field.Label>
</Field.Field>
{#if $handleOpenWidget.open && widgetKey === 'background'}
<UploadWidget
imageURL={$landingPageSettings.header.background?.image}
onchange={(imgUrl) => {
hasUnsavedChanges = true;
if (!$landingPageSettings.header.background) {
$landingPageSettings.header.background = {
image: imgUrl,
show: true
};
} else {
$landingPageSettings.header.background.image = imgUrl;
}
}}
/>
{/if}
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.landing_page.about.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.aboutUs.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Field.Label class="text-gray-600">
{$landingPageSettings.aboutUs.show
? $t('settings.landing_page.show_section')
: $t('settings.landing_page.hide_section')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.landing_page.about.title')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.aboutUs.title}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.about.content')}</Field.Label>
<Textarea
placeholder={$t('course.navItem.lessons.exercises.all_exercises.content')}
bind:value={$landingPageSettings.aboutUs.content}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label class="font-bold">{$t('settings.landing_page.about.upload_an_image')}</Field.Label>
<Button variant="outline" onclick={() => widgetControl('about-us')}>
{$t('settings.landing_page.about.select_image')}
</Button>
{#if $landingPageSettings.aboutUs.imageUrl}
<img alt="About us" src={$landingPageSettings.aboutUs.imageUrl} class="mt-2 w-full rounded-md" />
{/if}
{#if $handleOpenWidget.open && widgetKey === 'about-us'}
<UploadWidget bind:imageURL={$landingPageSettings.aboutUs.imageUrl} />
{/if}
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.landing_page.courses.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.courses.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Field.Label class="text-gray-600">
{$landingPageSettings.courses.show
? $t('settings.landing_page.courses.show_section')
: $t('settings.landing_page.courses.hide_section')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.landing_page.courses.title')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.courses.title}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.courses.title_highlight')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.highlight')}
bind:value={$landingPageSettings.courses.titleHighlight}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.courses.subtitle')}</Field.Label>
<Textarea
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
bind:value={$landingPageSettings.courses.subtitle}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.landing_page.faq.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.faq.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Field.Label class="text-gray-600">
{$landingPageSettings.faq.show
? $t('settings.landing_page.faq.show_section')
: $t('settings.landing_page.faq.hide_section')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.landing_page.faq.title')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.faq.title}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
{#each $landingPageSettings.faq.questions as item (item.id)}
<Field.Field>
<Field.Label>{$t('settings.landing_page.faq.question')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_question_here')}
bind:value={item.title}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.faq.answer')}</Field.Label>
<Textarea
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_answer_here')}
bind:value={item.content}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Button variant="ghost" size="icon" onclick={() => deleteFaq(item.id)}>
<TrashIcon size={16} />
</Button>
{/each}
{#if creatingNewQuestion}
<Field.Field>
<Field.Label>{$t('settings.landing_page.faq.question')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_question_here')}
bind:value={newQuestion.title}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.faq.answer')}</Field.Label>
<Textarea
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_answer_here')}
bind:value={newQuestion.content}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={saveNewFAQ}>
{$t('settings.landing_page.faq.save')}
</Button>
<Button variant="outline" onclick={cancelNewFAQ}>
{$t('settings.landing_page.faq.cancel')}
</Button>
</Field.Field>
{:else}
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={createNewFaq}>
{$t('settings.landing_page.faq.button')}
</Button>
</Field.Field>
{/if}
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.landing_page.contact_us.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.contact.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Field.Label class="text-gray-600">
{$landingPageSettings.contact.show
? $t('settings.landing_page.contact_us.show_section')
: $t('settings.landing_page.contact_us.hide_section')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.landing_page.contact_us.title')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.contact.title}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.contact_us.title_highlight')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.highlight')}
bind:value={$landingPageSettings.contact.titleHighlight}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.contact_us.subtitle')}</Field.Label>
<Textarea
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
bind:value={$landingPageSettings.contact.subtitle}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.contact_us.address')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.address')}
bind:value={$landingPageSettings.contact.address}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.contact_us.phone_number')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.number')}
bind:value={$landingPageSettings.contact.phone}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.contact_us.email')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.email')}
bind:value={$landingPageSettings.contact.email}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.landing_page.mailing_list.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.mailinglist.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Field.Label class="text-gray-600">
{$landingPageSettings.mailinglist.show
? $t('settings.landing_page.mailing_list.show_section')
: $t('settings.landing_page.mailing_list.hide_section')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.landing_page.mailing_list.title')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_title')}
bind:value={$landingPageSettings.mailinglist.title}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.mailing_list.subtitle')}</Field.Label>
<Textarea
placeholder={$t('course.navItem.lessons.exercises.all_exercises.write_your_subtitle')}
bind:value={$landingPageSettings.mailinglist.subtitle}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.mailing_list.button_label')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.label')}
bind:value={$landingPageSettings.mailinglist.buttonLabel}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.landing_page.custom_links.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.customLinks.show} />
<Field.Label class="text-gray-600">
{$landingPageSettings.customLinks.show
? $t('settings.landing_page.custom_links.show_links')
: $t('settings.landing_page.custom_links.hide_links')}
</Field.Label>
</Field.Field>
<Field.Description>{$t('settings.landing_page.custom_links.description')}</Field.Description>
<Field.Group>
{#each $landingPageSettings.customLinks.links as link (link.id)}
<div class="flex flex-col gap-2">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field.Field>
<Field.Label>{$t('settings.landing_page.custom_links.label')}</Field.Label>
<Input placeholder={$t('settings.landing_page.custom_links.label_placeholder')} bind:value={link.label} />
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.custom_links.url')}</Field.Label>
<Input placeholder={$t('settings.landing_page.custom_links.url_placeholder')} bind:value={link.url} />
</Field.Field>
</div>
<div class="flex items-center justify-between">
<Field.Field orientation="horizontal">
<Checkbox id="new-tab-{link.id}" bind:checked={link.openInNewTab} />
<Field.Label class="text-sm">{$t('settings.landing_page.custom_links.new_tab')}</Field.Label>
</Field.Field>
<Button variant="ghost" size="icon" onclick={() => deleteCustomLink(link.id)}>
<TrashIcon size={16} />
</Button>
</div>
<Field.Separator />
</div>
{/each}
{#if creatingNewCustomLink}
<div class="mb-4 rounded-lg border border-gray-200 bg-gray-50 p-4">
<div class="mb-3 grid grid-cols-1 gap-4 md:grid-cols-2">
<Field.Field>
<Field.Label>{$t('settings.landing_page.custom_links.label')}</Field.Label>
<Input
placeholder={$t('settings.landing_page.custom_links.label_placeholder')}
bind:value={newCustomLink.label}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.custom_links.url')}</Field.Label>
<Input
placeholder={$t('settings.landing_page.custom_links.url_placeholder')}
bind:value={newCustomLink.url}
/>
</Field.Field>
</div>
<div class="flex items-center justify-between">
<Field.Field orientation="horizontal">
<input type="checkbox" bind:checked={newCustomLink.openInNewTab} class="mr-2" />
<Field.Label class="text-sm">{$t('settings.landing_page.custom_links.new_tab')}</Field.Label>
</Field.Field>
<div class="flex gap-2">
<Button variant="outline" onclick={saveNewCustomLink} class="px-3 py-1 text-sm">
{$t('settings.landing_page.custom_links.save')}
</Button>
<Button variant="outline" onclick={cancelNewCustomLink} class="px-3 py-1 text-sm">
{$t('settings.landing_page.custom_links.cancel')}
</Button>
</div>
</div>
</div>
{:else}
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={createNewCustomLink}>
{$t('settings.landing_page.custom_links.add')}
</Button>
</Field.Field>
{/if}
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.landing_page.footer.heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={$landingPageSettings.footer.show} onCheckedChange={() => (hasUnsavedChanges = true)} />
<Field.Label class="text-gray-600">
{$landingPageSettings.footer.show
? $t('settings.landing_page.footer.show_section')
: $t('settings.landing_page.footer.hide_section')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.landing_page.footer.facebook')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.facebook')}
bind:value={$landingPageSettings.footer.facebook}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<!-- <TextField
label="Instagram"
placeholder="Write your Instagram link here"
className="mb-5"
bind:value={$landingPageSettings.footer.instagram}
/> -->
<Field.Field>
<Field.Label>{$t('settings.landing_page.footer.twitter')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.twitter')}
bind:value={$landingPageSettings.footer.twitter}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.footer.linkedin')}</Field.Label>
<Input
placeholder={$t('course.navItem.lessons.exercises.all_exercises.linkedin')}
bind:value={$landingPageSettings.footer.linkedin}
oninput={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
</Field.Group>
</Field.Set>
</Field.Group>
<style>
:global(.dark) {
--cp-text-color: #fff;
--cp-border-color: white;
--cp-text-color: white;
--cp-input-color: #555;
--cp-button-hover-color: #777;
}
:global(.dark .alpha) {
background: #333 !important;
}
</style>
@@ -0,0 +1,256 @@
<script lang="ts">
import { untrack } from 'svelte';
import { goto } from '$app/navigation';
import ZapIcon from '@lucide/svelte/icons/zap';
import debounce from 'lodash/debounce';
import ColorPicker from 'svelte-awesome-color-picker';
import { t } from '$lib/utils/functions/translations';
import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
import { injectCustomTheme, setCustomTheme, setTheme } from '$lib/utils/functions/theme';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { Input } from '@cio/ui/base/input';
import { Button } from '@cio/ui/base/button';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
import * as Field from '@cio/ui/base/field';
let avatar = $state<string | File | undefined>();
let hasUnsavedChanges = $state(false);
let hex = $state('');
$effect(() => {
const theme = $currentOrg.theme;
// if not a custom theme, don't update hex
if (!theme || theme.includes('theme-')) return;
// only update hex with value of `theme` when theme changes not when HEX changes
untrack(() => {
hex = theme;
});
});
const themes = {
rose: 'theme-rose',
green: 'theme-green',
orange: 'theme-orange',
violet: 'theme-violet',
default: ''
};
const saveTheme = debounce(async (theme: string) => {
await orgApi.update($currentOrg.id, {
theme
});
}, 700);
function handleChangeTheme(t = '') {
return async () => {
document.body.className = document.body.className
.split(' ')
.filter((c) => !c.includes('theme'))
.join(' ')
.concat(t ? ' ' : '', t);
$currentOrg.theme = t;
hex = '';
setTheme(t);
saveTheme(t);
};
}
async function handleCustomTheme() {
if (!hex) return;
injectCustomTheme(hex);
setCustomTheme('theme-custom');
$currentOrg.theme = hex;
saveTheme(hex);
}
export async function handleUpdate() {
await orgApi.update($currentOrg.id, {
name: $currentOrg.name,
avatar
});
if (orgApi.success) {
hasUnsavedChanges = false;
avatar = undefined;
}
}
function gotoSettings(pathname: string) {
goto(`${$currentOrgPath}/settings${pathname}`);
}
let isCustomTheme = $derived(hex && !hex.includes('theme-'));
</script>
<UnsavedChanges bind:hasUnsavedChanges />
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.organization.organization_profile.organization_name')}</Field.Label>
<Input bind:value={$currentOrg.name} oninput={() => (hasUnsavedChanges = true)} class="w-full lg:w-60" />
{#if orgApi.errors.name}
<Field.Error>{$t(orgApi.errors.name)}</Field.Error>
{/if}
</Field.Field>
<Field.Field>
<UploadImage
bind:avatar
src={$currentOrg.avatarUrl}
shape="rounded-md"
widthHeight="w-24 h-24"
change={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.organization.organization_profile.theme.heading')}</Field.Legend>
<Field.Description>{$t('settings.organization.organization_profile.theme.sub_heading')}</Field.Description>
<Field.Field>
<div class="flex items-center gap-5">
<button
class="rounded-full border-2 {$currentOrg.theme === themes.default &&
'border-[#1d4ee2]'} flex h-fit items-center justify-center"
onclick={handleChangeTheme(themes.default)}
aria-label="Default blue theme"
>
<div class="m-1 h-6 w-6 rounded-full bg-[#1d4ee2] md:h-6 md:w-6"></div>
</button>
<button
class="rounded-full border-2 {$currentOrg.theme === themes.rose &&
'border-[#be1241]'} flex h-fit items-center justify-center"
onclick={handleChangeTheme(themes.rose)}
aria-label="Rose theme"
>
<div class="m-1 h-6 w-6 rounded-full bg-[#be1241] md:h-6 md:w-6"></div>
</button>
<button
class="rounded-full border-2 {$currentOrg.theme === themes.green &&
'border-[#0c891b]'} flex h-fit items-center justify-center"
onclick={handleChangeTheme(themes.green)}
aria-label="Green theme"
>
<div class="m-1 h-6 w-6 rounded-full bg-[#0c891b] md:h-6 md:w-6"></div>
</button>
<button
class="rounded-full border-2 {$currentOrg.theme === themes.orange &&
'border-[#cc4902]'} flex h-fit items-center justify-center"
onclick={handleChangeTheme(themes.orange)}
aria-label="Orange theme"
>
<div class="m-1 h-6 w-6 rounded-full bg-[#cc4902] md:h-6 md:w-6"></div>
</button>
<button
class="rounded-full border-2 {$currentOrg.theme === themes.violet &&
'border-[#cf00ce]'} flex h-fit items-center justify-center"
onclick={handleChangeTheme(themes.violet)}
aria-label="Violet theme"
>
<div class="m-1 h-6 w-6 rounded-full bg-[#cf00ce] md:h-6 md:w-6"></div>
</button>
<div
class="h-auto w-fit border-2 {isCustomTheme
? 'border-primary-700'
: 'dark:border-neutral-700'} group relative rounded-full"
>
<!-- plus icon positioned over the color picker -->
<div
class="pointer-events-none absolute inset-0 flex items-center justify-center transition-opacity duration-200"
>
<svg
class="h-6 w-6 text-{isCustomTheme ? 'white' : 'black'} z-10 opacity-100 dark:text-white"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"></path>
</svg>
</div>
<ColorPicker position="responsive" label="" bind:hex on:input={handleCustomTheme} />
</div>
</div>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.organization.organization_profile.customize_lms.heading')}</Field.Legend>
<Field.Description>{$t('settings.organization.organization_profile.customize_lms.sub_heading')}</Field.Description>
<Field.Description>{$t('settings.organization.organization_profile.customize_lms.body')}</Field.Description>
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={() => gotoSettings('/customize-lms')}>
{$t('settings.organization.organization_profile.customize_lms.button')}
</Button>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.organization.organization_profile.custom_domain.heading')}</Field.Legend>
<Field.Description>{$t('settings.organization.organization_profile.custom_domain.sub_heading')}</Field.Description>
<Field.Description>{$t('settings.organization.organization_profile.custom_domain.body')}</Field.Description>
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={() => gotoSettings('/domains')}>
{#if $isFreePlan}
<ZapIcon size={16} class="filled" />
{/if}
{$t('settings.organization.organization_profile.custom_domain.button')}
</Button>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.organization.organization_profile.team.heading')}</Field.Legend>
<Field.Description>{$t('settings.organization.organization_profile.team.sub_heading')}</Field.Description>
<Field.Description>{$t('settings.organization.organization_profile.team.body')}</Field.Description>
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={() => gotoSettings('/teams')}>
{#if $isFreePlan}
<ZapIcon size={16} class="filled" />
{/if}
{$t('settings.organization.organization_profile.team.button')}
</Button>
</Field.Field>
</Field.Set>
</Field.Group>
<style>
:global(.dark) {
--cp-text-color: #fff;
--cp-border-color: white;
--cp-text-color: white;
--cp-input-color: #555;
--cp-button-hover-color: #777;
}
:global(.dark .alpha) {
background: #333 !important;
}
</style>
@@ -0,0 +1,168 @@
<script lang="ts">
import { page } from '$app/state';
import type { TLocale } from '@cio/db/types';
import { profile } from '$lib/utils/store/user';
import { CircleCheckBig } from '@lucide/svelte';
import { profileApi } from '$lib/features/auth/api/profile.svelte';
import { t } from '$lib/utils/functions/translations';
import LanguagePicker from '../components/language-picker.svelte';
import { Input } from '@cio/ui/base/input';
import { Button } from '@cio/ui/base/button';
import UploadImage from '$lib/components/UploadImage/index.svelte';
import UnsavedChanges from '$lib/components/UnsavedChanges/index.svelte';
import * as Field from '@cio/ui/base/field';
let avatar = $state<string | File | undefined>();
let hasLangChanged = $state(false);
let locale = $derived<TLocale | undefined>($profile.locale || undefined);
let hasUnsavedChanges = $state(false);
let email = $derived($profile.email || '');
let isChangingEmail = $state(false);
let emailChangeInitiated = $state(false);
export async function handleUpdate() {
await profileApi.submit(
{
fullname: $profile.fullname,
username: $profile.username,
locale,
avatar
},
$profile,
hasLangChanged,
locale
);
if (profileApi.success) {
hasUnsavedChanges = false;
avatar = undefined;
}
}
async function handleEmailChange() {
if (email === $profile.email) {
return;
}
const url = new URL(window.location.href);
url.searchParams.set('trigger', 'app');
await profileApi.changeEmail({
newEmail: email,
callbackURL: url.toString()
});
if (profileApi.success) {
isChangingEmail = false;
emailChangeInitiated = true;
// Reset email field to current email after successful change request
email = $profile.email || '';
}
}
const isVerificationSent = $derived(page.url.searchParams.get('trigger') === 'app');
</script>
<UnsavedChanges bind:hasUnsavedChanges />
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Legend>{$t('settings.profile.profile_picture.heading')}</Field.Legend>
<Field.Field>
<UploadImage
bind:avatar
src={$profile.avatarUrl}
widthHeight="w-16 h-16 lg:w-24 lg:h-24"
isDisabled={profileApi.isLoading}
change={() => (hasUnsavedChanges = true)}
/>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.profile.personal_information.heading')}</Field.Legend>
{#if isVerificationSent}
<div class="flex items-center gap-2 rounded-md border border-gray-200 p-2 text-green-500">
<CircleCheckBig class="size-8" />
<p class="text-sm">{$t('settings.profile.personal_information.email_change_verification_sent')}</p>
</div>
{/if}
<Field.Group>
<Field.Field>
<Field.Label>{$t('settings.profile.personal_information.full_name')}</Field.Label>
<Input bind:value={$profile.fullname} class="" oninput={() => (hasUnsavedChanges = true)} />
{#if profileApi.errors.fullname}
<Field.Error>{$t(profileApi.errors.fullname)}</Field.Error>
{/if}
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.profile.personal_information.username')}</Field.Label>
<Input bind:value={$profile.username} oninput={() => (hasUnsavedChanges = true)} />
{#if profileApi.errors.username}
<Field.Error>{$t(profileApi.errors.username)}</Field.Error>
{/if}
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.profile.personal_information.email')}</Field.Label>
<Input
bind:value={email}
class="w-full"
type="email"
disabled={profileApi.isLoading}
oninput={() => {
if (email !== $profile.email) {
isChangingEmail = true;
emailChangeInitiated = false;
}
}}
/>
{#if profileApi.errors.newEmail}
<Field.Error>{$t(profileApi.errors.newEmail)}</Field.Error>
{/if}
{#if isChangingEmail && email !== $profile.email}
<div class="mt-2 flex gap-2">
<Button
variant="default"
class="text-sm"
loading={profileApi.isLoading}
disabled={profileApi.isLoading}
onclick={handleEmailChange}
>
{$t('settings.profile.personal_information.confirm')}
</Button>
<Button
variant="ghost"
class="text-primary-700 text-sm"
disabled={profileApi.isLoading}
onclick={() => {
email = $profile.email || '';
isChangingEmail = false;
emailChangeInitiated = false;
profileApi.errors.newEmail = '';
}}
>
{$t('settings.profile.personal_information.cancel')}
</Button>
</div>
{/if}
{#if emailChangeInitiated}
<div class="mt-2 flex items-center gap-2 rounded-md border border-gray-200 p-2 text-amber-500">
<CircleCheckBig class="size-8" />
<p class="text-sm">{$t('settings.profile.personal_information.email_change_verification_note')}</p>
</div>
{/if}
</Field.Field>
<Field.Field>
<LanguagePicker
change={() => (hasUnsavedChanges = true)}
bind:hasLangChanged
bind:value={locale}
className=""
/>
</Field.Field>
</Field.Group>
</Field.Set>
</Field.Group>
@@ -0,0 +1,158 @@
<script lang="ts">
import * as Select from '@cio/ui/base/select';
import { Spinner } from '@cio/ui/base/spinner';
import { profile } from '$lib/utils/store/user';
import { isFreePlan, currentOrg } from '$lib/utils/store/org';
import { orgApi } from '$lib/features/org/api/org.svelte';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$lib/components/Snackbar/store';
import type { OrgTeamMember } from '$lib/utils/types/org';
import { ROLE } from '@cio/utils/constants';
import { ROLE_LABEL } from '$lib/utils/constants/roles';
import { validateEmailInString } from '$lib/utils/functions/validator';
import { Badge } from '@cio/ui/base/badge';
import { Input } from '@cio/ui/base/input';
import { Button } from '@cio/ui/base/button';
import { ComingSoon, UpgradeBanner } from '$lib/features/ui';
import * as Field from '@cio/ui/base/field';
let emailsStr = $state('');
let errorMessage = $state('');
let role = $state(ROLE.TUTOR.toString());
let isRemoving: number | null = $state(null);
async function onSendInvite() {
const { hasError, error: _error, emails } = validateEmailInString(emailsStr);
if (hasError) {
errorMessage = _error;
return;
}
errorMessage = '';
await orgApi.inviteTeamMembers($currentOrg.id, emails, parseInt(role));
if (orgApi.success) {
snackbar.success('snackbar.team_members.invite_sent');
emailsStr = '';
} else {
// Errors are handled by orgApi, but check for field-specific errors
if (orgApi.errors.emails) {
errorMessage = orgApi.errors.emails;
} else if (orgApi.errors.general) {
snackbar.error(orgApi.errors.general);
}
}
}
async function onRemove(id: number) {
isRemoving = id;
await orgApi.removeTeamMember($currentOrg.id, id);
if (orgApi.success) {
snackbar.success('snackbar.team_members.remove_success');
} else {
snackbar.error('snackbar.team_members.remove_fail');
}
isRemoving = null;
}
const isTeamMemberAdmin = (members: OrgTeamMember[], profileId: string | undefined) => {
return members.some((member) => member.profileId === profileId && member.isAdmin);
};
$effect(() => {
orgApi.getOrgTeam($currentOrg.id);
});
</script>
<UpgradeBanner>{$t('upgrade.team')}</UpgradeBanner>
<Field.Group class="max-w-md! w-full px-2">
<Field.Set>
<Field.Legend>{$t('course.navItem.people.teams.add')}</Field.Legend>
<Field.Description class="mb-5">{$t('course.navItem.people.teams.add_team')}</Field.Description>
<Field.Group>
<Field.Field>
<Field.Label>{$t('course.navItem.people.teams.invite')}</Field.Label>
<Input
placeholder={$t('course.navItem.people.teams.placeholder')}
bind:value={emailsStr}
class="w-full"
disabled={$isFreePlan}
/>
{#if errorMessage}
<Field.Error>{errorMessage}</Field.Error>
{/if}
</Field.Field>
<Field.Field>
<Field.Label>{$t('course.navItem.people.teams.role')}</Field.Label>
<Select.Root type="single" bind:value={role} disabled={$isFreePlan}>
<Select.Trigger id="invite-role" class="w-40">
<p>{role ? $t(ROLE_LABEL[role]) : $t('course.navItem.people.teams.select_role')}</p>
</Select.Trigger>
<Select.Content>
<Select.Item value={ROLE.ADMIN.toString()}>{$t(ROLE_LABEL[ROLE.ADMIN])}</Select.Item>
<Select.Item value={ROLE.TUTOR.toString()}>{$t(ROLE_LABEL[ROLE.TUTOR])}</Select.Item>
</Select.Content>
</Select.Root>
</Field.Field>
<Button
variant="default"
onclick={onSendInvite}
loading={orgApi.isLoading}
disabled={orgApi.isLoading || $isFreePlan}
>
{$t('course.navItem.people.teams.send_invite')}
</Button>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('course.navItem.people.teams.members')}</Field.Legend>
<Field.Group>
{#if orgApi.isLoading && orgApi.teamMembers.length === 0}
<Spinner class="size-10! text-blue-700!" />
{:else if orgApi.teamMembers.length > 0}
{#each orgApi.teamMembers as teamMember}
<Field.Field>
<div class="flex items-center justify-between">
<div class="flex items-center">
<p class="mr-3 text-sm text-gray-500 dark:text-white">
{teamMember.email}
</p>
<Badge variant="secondary" class="mr-3 text-xs">{$t(teamMember.role)}</Badge>
{#if !teamMember.verified}
<Badge variant="outline" class="bg-yellow-200 text-xs text-yellow-700">
{$t('course.navItem.people.teams.invite_sent')}
</Badge>
{:else if teamMember.profileId === $profile.id}
<ComingSoon label={$t('course.navItem.people.teams.you')} />
{/if}
</div>
{#if teamMember.profileId !== $profile.id && isTeamMemberAdmin(orgApi.teamMembers, $profile.id)}
<Button
variant="ghost"
onclick={() => onRemove(teamMember.id)}
loading={isRemoving === teamMember.id}
disabled={isRemoving === teamMember.id}
class="text-red-500 hover:text-red-700"
>
{$t('course.navItem.people.teams.remove')}
</Button>
{/if}
</div>
</Field.Field>
{/each}
{/if}
</Field.Group>
</Field.Set>
</Field.Group>