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:
@@ -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>
|
||||
Reference in New Issue
Block a user