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,41 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
sm?: number;
|
||||
md?: number;
|
||||
lg?: number;
|
||||
class?: string;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { sm = 8, md = 8, lg = 8, class: className = '', children }: Props = $props();
|
||||
|
||||
const getColSpan = (cols: number) => {
|
||||
const spanMap = {
|
||||
1: 'col-span-1',
|
||||
2: 'col-span-2',
|
||||
3: 'col-span-3',
|
||||
4: 'col-span-4',
|
||||
5: 'col-span-5',
|
||||
6: 'col-span-6',
|
||||
7: 'col-span-7',
|
||||
8: 'col-span-8',
|
||||
9: 'col-span-9',
|
||||
10: 'col-span-10',
|
||||
11: 'col-span-11',
|
||||
12: 'col-span-12',
|
||||
13: 'col-span-13',
|
||||
14: 'col-span-14',
|
||||
15: 'col-span-15',
|
||||
16: 'col-span-16'
|
||||
};
|
||||
return spanMap[cols] || 'col-span-full';
|
||||
};
|
||||
|
||||
const smClass = getColSpan(sm);
|
||||
const mdClass = `md:${getColSpan(md)}`;
|
||||
const lgClass = `lg:${getColSpan(lg)}`;
|
||||
</script>
|
||||
|
||||
<div class="{smClass} {mdClass} {lgClass} {className} grid">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
class?: string;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="w-full {className}">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
id?: string;
|
||||
class?: string;
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { id, class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div {id} class="lg:grid-cols-16 grid grid-cols-1 gap-5 px-3 {className}">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as Grid } from './Grid.svelte';
|
||||
export { default as Row } from './Row.svelte';
|
||||
export { default as Column } from './Column.svelte';
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as LanguagePicker } from './language-picker.svelte';
|
||||
export * from './Layout';
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
|
||||
import type { TLocale } from '@cio/db/types';
|
||||
import { LANGUAGES } from '$lib/utils/constants/translation';
|
||||
import { t, initialized } from '$lib/utils/functions/translations';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
value?: TLocale;
|
||||
hasLangChanged?: boolean;
|
||||
change?: () => void;
|
||||
}
|
||||
|
||||
let { className = '', value = $bindable('en'), hasLangChanged = $bindable(false), change }: Props = $props();
|
||||
|
||||
function handleSelect(selectedValue: string) {
|
||||
value = selectedValue as TLocale;
|
||||
hasLangChanged = true;
|
||||
change?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $initialized}
|
||||
<div class={className}>
|
||||
<Label class="mb-2 block">{$t('content.toggle_label')}</Label>
|
||||
<Select.Root type="single" bind:value onValueChange={handleSelect}>
|
||||
<Select.Trigger class="w-full">
|
||||
<p>{value ? LANGUAGES.find((lang) => lang.id === value)?.text : 'Pick a Language'}</p>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
{#each LANGUAGES as language}
|
||||
<Select.Item value={language.id}>{language.text}</Select.Item>
|
||||
{/each}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { OrgLandingPageJson } from '$lib/utils/types/org';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const landingPageSettings: Writable<OrgLandingPageJson> = writable({
|
||||
header: {
|
||||
title: 'Web Development Courses for',
|
||||
titleHighlight: 'Everyone',
|
||||
subtitle: 'Practical project-based courses that are easy to understand and straight to the point',
|
||||
action: {
|
||||
label: 'Start learning Today',
|
||||
link: '#contact',
|
||||
redirect: false
|
||||
},
|
||||
banner: {
|
||||
video: 'https://www.youtube.com/watch?v=qqAYW7uxErI',
|
||||
image: '',
|
||||
type: 'video',
|
||||
show: true
|
||||
},
|
||||
background: {
|
||||
image: '',
|
||||
show: true
|
||||
},
|
||||
show: true
|
||||
},
|
||||
aboutUs: {
|
||||
title: 'Our Story',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.',
|
||||
imageUrl: 'https://pgrest.classroomio.com/storage/v1/object/public/avatars/landingpage/riverside.jpeg',
|
||||
show: true
|
||||
},
|
||||
courses: {
|
||||
title: 'Explore our',
|
||||
titleHighlight: 'Courses',
|
||||
subtitle: 'Find courses you will love from best teachers all over the world🌎.',
|
||||
show: true
|
||||
},
|
||||
faq: {
|
||||
title: 'Frequently Asked Questions.',
|
||||
questions: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Lorem ipsum dolor sit, amet con',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Lorem ipsum dolor sit, amet con',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Lorem ipsum dolor sit, amet con',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
|
||||
}
|
||||
],
|
||||
show: true
|
||||
},
|
||||
contact: {
|
||||
title: 'Have you got any',
|
||||
titleHighlight: 'Question?',
|
||||
subtitle: 'Reach out to us with any question you might have.',
|
||||
address: 'Ashwoodfield House Farm, Kidderminster Rd, Stourbridge, Kingswinford DY6 0AA',
|
||||
phone: '+4407339904995',
|
||||
email: 'help@lms.com',
|
||||
show: true
|
||||
},
|
||||
mailinglist: {
|
||||
title: 'Join our mailing list',
|
||||
subtitle:
|
||||
'We are constantly releasing new courses and sharing them with our email list. Subscribe to get notified once we release a new course',
|
||||
buttonLabel: 'Subscribe',
|
||||
show: true
|
||||
},
|
||||
customLinks: {
|
||||
show: true,
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
label: 'About Us',
|
||||
url: '#aboutus',
|
||||
openInNewTab: false
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: 'Courses',
|
||||
url: '#courses',
|
||||
openInNewTab: false
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: 'FAQ',
|
||||
url: '#faq',
|
||||
openInNewTab: false
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
label: 'Contact Us',
|
||||
url: '#contact',
|
||||
openInNewTab: false
|
||||
}
|
||||
]
|
||||
},
|
||||
footer: {
|
||||
facebook: '#',
|
||||
instagram: '#',
|
||||
twitter: '#',
|
||||
linkedin: '#',
|
||||
show: true
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user