feat: enhance API and middleware functionality: add public API routes, implement program and course member checks, and improve rate limiting for invite acceptance. Update OpenAPI spec generation to focus on public API endpoints and add new middleware for automation key scopes. Include new documentation for lesson AI features and public API tests.

This commit is contained in:
Rotimi Best
2026-04-12 08:58:41 +01:00
parent 5a8f50f67b
commit d6e812d08b
573 changed files with 31964 additions and 8708 deletions
@@ -5,10 +5,9 @@
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';
import { ExternalLinkIcon, HoverableItem } from '@cio/ui/custom/moving-icons';
let isRedirecting = $state(false);
@@ -28,7 +27,7 @@
if ($currentOrgPlan?.provider === 'lmz') {
isRedirecting = true;
const url = new URL('/api/lmz/subscription', window.location.origin);
url.searchParams.set('subscriptionId', $currentOrgPlan?.subscriptionId);
url.searchParams.set('subscriptionId', $currentOrgPlan?.subscriptionId || '');
const response = await fetch(url.toString());
const result = await response.json();
@@ -70,10 +69,14 @@
<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>
<HoverableItem>
{#snippet children(isHovered)}
<Button size="sm" variant="outline" onclick={onOpenBilling} loading={isRedirecting}>
<ExternalLinkIcon {isHovered} size={16} ariaHidden={true} />
{$t('settings.billing.open_billing')}
</Button>
{/snippet}
</HoverableItem>
</Item.Actions>
</Item.Root>
{/if}
@@ -7,7 +7,6 @@
import isValidDomain from 'is-valid-domain';
import { goto } from '$app/navigation';
import { onMount, untrack } from 'svelte';
import { parse } from 'tldts';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
@@ -157,6 +156,7 @@
}
const sanitizedDomain = sanitizeDomain(customDomain);
const { parse } = await import('tldts/dist/es6/index.js');
const details = parse(sanitizedDomain);
if (!details.subdomain) {
@@ -338,7 +338,7 @@
{#if domainSetup?.dnsRecords?.length}
<Field.Field>
<div class="space-y-3 rounded-md border px-4 py-3">
{#each domainSetup.dnsRecords as record}
{#each domainSetup.dnsRecords as record (record.name)}
<div class="grid gap-3 sm:grid-cols-[80px,1fr,1fr] sm:items-center">
<div>
<Field.Label class="font-light">{$t('components.settings.domains.dns_type')}</Field.Label>
@@ -4,6 +4,7 @@ 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 LandingpageEditor } from './landingpage-editor.svelte';
export { default as BillingPage } from './billing.svelte';
export { default as IntegrationsPage } from './integrations.svelte';
export { default as AuthPage } from './auth.svelte';
@@ -0,0 +1,163 @@
<script lang="ts">
import type { Component } from 'svelte';
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import { CloseButton, UnsavedChanges } from '$features/ui';
import * as Sidebar from '@cio/ui/base/sidebar';
import { useSidebar } from '@cio/ui/base/sidebar';
import { IconButton } from '@cio/ui/custom/icon-button';
import type { OrgLandingPageJson } from '$lib/utils/types/org';
import { ContentIcon, ExternalLinkIcon, GoalIcon, HeaderIcon, SettingsIcon } from '@cio/ui/custom/moving-icons';
import HeroSection from './landingpage-editor/hero-section.svelte';
import NavigationSection from './landingpage-editor/navigation-section.svelte';
import CalloutSection from './landingpage-editor/callout-section.svelte';
import EmbedSection from './landingpage-editor/embed-section.svelte';
import FooterSection from './landingpage-editor/footer-section.svelte';
interface Props {
settings: OrgLandingPageJson;
onSave: () => Promise<void> | void;
onClose: () => void;
}
type SectionKey = 'hero' | 'navigation' | 'callout' | 'embed' | 'footer';
interface SectionDefinition {
key: SectionKey;
title: string;
icon: typeof HeaderIcon;
component: Component;
}
let { settings = $bindable(), onSave, onClose }: Props = $props();
const sidebar = useSidebar();
let selectedSection = $state<SectionKey | null>(null);
let isSaving = $state(false);
let hasUnsavedChanges = $state(false);
const sections: SectionDefinition[] = [
{
key: 'navigation',
title: t.get('settings.landing_page.editor.sections.navigation'),
icon: ContentIcon,
component: NavigationSection
},
{
key: 'hero',
title: t.get('settings.landing_page.editor.sections.hero'),
icon: HeaderIcon,
component: HeroSection
},
{
key: 'embed',
title: t.get('settings.landing_page.editor.sections.embed'),
icon: ExternalLinkIcon,
component: EmbedSection
},
{
key: 'callout',
title: t.get('settings.landing_page.editor.sections.callout'),
icon: GoalIcon,
component: CalloutSection
},
{
key: 'footer',
title: t.get('settings.landing_page.editor.sections.footer'),
icon: SettingsIcon,
component: FooterSection
}
];
const selectedSectionDefinition = $derived(sections.find((section) => section.key === selectedSection) ?? null);
function markDirty() {
hasUnsavedChanges = true;
}
function handleClose() {
if (selectedSection) {
selectedSection = null;
return;
}
onClose();
}
async function handleSaveClick() {
isSaving = true;
try {
await onSave();
hasUnsavedChanges = false;
} finally {
isSaving = false;
}
}
</script>
<UnsavedChanges bind:hasUnsavedChanges />
<Sidebar.Header
class="flex flex-row! items-center {sidebar.open ? 'justify-between' : 'justify-center'} border-b px-2 py-2"
>
{#if !selectedSection}
{#if sidebar.open}
<CloseButton onClick={handleClose} />
<div class="flex items-center gap-2">
<Button type="button" variant="outline" onclick={handleSaveClick} loading={isSaving} disabled={isSaving}>
{$t('settings.landing_page.editor.save')}
</Button>
</div>
{:else}
<CloseButton onClick={handleClose} />
{/if}
{:else if sidebar.open || !sidebar.isMobile}
<div class="flex items-center gap-2">
<IconButton onclick={() => (selectedSection = null)}>
<ArrowLeftIcon size={16} />
</IconButton>
<h3 class="text-sm font-semibold">
{selectedSectionDefinition?.title}
</h3>
</div>
{:else}
<IconButton onclick={() => (selectedSection = null)} tooltip={selectedSectionDefinition?.title ?? ''}>
<ArrowLeftIcon size={16} />
</IconButton>
{/if}
</Sidebar.Header>
<Sidebar.Content class="flex-1 overflow-y-auto">
{#if !selectedSection}
<Sidebar.Group>
<Sidebar.GroupLabel class="px-2">
{$t('settings.landing_page.editor.page_builder')}
</Sidebar.GroupLabel>
<Sidebar.GroupContent>
<Sidebar.Menu>
{#each sections as section (section.key)}
<Sidebar.MenuItem>
<Sidebar.MenuButton onclick={() => (selectedSection = section.key)} tooltipContent={section.title}>
{@const SectionIcon = section.icon}
<SectionIcon size={16} />
<span>{section.title}</span>
<ChevronRightIcon size={16} />
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
{:else if selectedSectionDefinition}
{@const SectionComponent = selectedSectionDefinition.component}
<div class="p-4">
<SectionComponent bind:settings {markDirty} />
</div>
{/if}
</Sidebar.Content>
@@ -0,0 +1,58 @@
<script lang="ts">
import TrashIcon from '@lucide/svelte/icons/trash';
import { Button } from '@cio/ui/base/button';
import * as Field from '@cio/ui/base/field';
import { Input } from '@cio/ui/base/input';
interface Props {
label: string;
href: string;
labelFieldLabel: string;
hrefFieldLabel: string;
labelPlaceholder?: string;
hrefPlaceholder?: string;
className?: string;
removeAriaLabel?: string;
onLabelInput: (value: string) => void;
onHrefInput: (value: string) => void;
onRemove?: () => void;
}
let {
label,
href,
labelFieldLabel,
hrefFieldLabel,
labelPlaceholder,
hrefPlaceholder,
className = 'space-y-4',
removeAriaLabel,
onLabelInput,
onHrefInput,
onRemove
}: Props = $props();
</script>
<div class={className}>
{#if onRemove}
<Button
type="button"
variant="secondary"
class="absolute! -top-3! right-2!"
aria-label={removeAriaLabel}
onclick={() => onRemove()}
>
<TrashIcon size={16} />
</Button>
{/if}
<Field.Field>
<Field.Label>{labelFieldLabel}</Field.Label>
<Input value={label} placeholder={labelPlaceholder} oninput={(event) => onLabelInput(event.currentTarget.value)} />
</Field.Field>
<Field.Field>
<Field.Label>{hrefFieldLabel}</Field.Label>
<Input value={href} placeholder={hrefPlaceholder} oninput={(event) => onHrefInput(event.currentTarget.value)} />
</Field.Field>
</div>
@@ -0,0 +1,119 @@
<script lang="ts">
import cloneDeep from 'lodash/cloneDeep';
import set from 'lodash/set';
import { t } from '$lib/utils/functions/translations';
import * as Field from '@cio/ui/base/field';
import { Input } from '@cio/ui/base/input';
import { Switch } from '@cio/ui/base/switch';
import { Textarea } from '@cio/ui/base/textarea';
import type { OrgLandingPageCallout, OrgLandingPageJson } from '$lib/utils/types/org';
interface Props {
settings: OrgLandingPageJson;
markDirty: () => void;
}
let { settings = $bindable(), markDirty }: Props = $props();
function setter(value: unknown, setterKey: string) {
if (typeof value === 'undefined') {
return;
}
const nextSettings = cloneDeep(settings);
set(nextSettings, setterKey, value);
settings = nextSettings;
markDirty();
}
function ensureCallout(): OrgLandingPageCallout {
if (settings.callout) {
return settings.callout;
}
const callout: OrgLandingPageCallout = {
heading: t.get('settings.landing_page.editor.callout.heading_default'),
description: '',
action: {
label: t.get('settings.landing_page.editor.callout.action_label_default'),
href: '#'
}
};
settings = {
...settings,
callout
};
markDirty();
return callout;
}
function setCalloutEnabled(enabled: boolean) {
if (!enabled) {
settings = {
...settings,
callout: undefined
};
markDirty();
return;
}
setter(ensureCallout(), 'callout');
}
</script>
<Field.Group class="space-y-6">
<Field.Set>
<Field.Legend>{$t('settings.landing_page.editor.sections.callout')}</Field.Legend>
<div class="space-y-6">
<Field.Field orientation="horizontal">
<Switch
checked={Boolean(settings.callout)}
onCheckedChange={(checked) => setCalloutEnabled(checked === true)}
/>
<Field.Label>{$t('settings.landing_page.editor.callout.toggle')}</Field.Label>
</Field.Field>
{#if settings.callout}
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.callout.heading')}</Field.Label>
<Input
value={settings.callout.heading}
placeholder={$t('settings.landing_page.editor.callout.heading_placeholder')}
oninput={(event) => setter(event.currentTarget.value, 'callout.heading')}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.callout.description')}</Field.Label>
<Textarea
class="ui:[field-sizing:fixed] ui:min-w-0 ui:w-full ui:max-w-full ui:box-border ui:resize-y"
value={settings.callout.description}
placeholder={$t('settings.landing_page.editor.callout.description_placeholder')}
oninput={(event) => setter(event.currentTarget.value, 'callout.description')}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.callout.action_label')}</Field.Label>
<Input
value={settings.callout.action.label}
placeholder={$t('settings.landing_page.editor.callout.action_label_placeholder')}
oninput={(event) => setter(event.currentTarget.value, 'callout.action.label')}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.callout.action_href')}</Field.Label>
<Input
value={settings.callout.action.href}
placeholder={$t('settings.landing_page.editor.callout.action_href_placeholder')}
oninput={(event) => setter(event.currentTarget.value, 'callout.action.href')}
/>
</Field.Field>
{/if}
</div>
</Field.Set>
</Field.Group>
@@ -0,0 +1,133 @@
<script lang="ts">
import cloneDeep from 'lodash/cloneDeep';
import set from 'lodash/set';
import { t } from '$lib/utils/functions/translations';
import * as Field from '@cio/ui/base/field';
import { Switch } from '@cio/ui/base/switch';
import { Textarea } from '@cio/ui/base/textarea';
import type { OrgLandingPageEmbed, OrgLandingPageJson } from '$lib/utils/types/org';
import ButtonLinkFields from './button-link-fields.svelte';
import { Input } from '@cio/ui/base/input';
interface Props {
settings: OrgLandingPageJson;
markDirty: () => void;
}
let { settings = $bindable(), markDirty }: Props = $props();
function setter(value: unknown, setterKey: string) {
if (typeof value === 'undefined') {
return;
}
const nextSettings = cloneDeep(settings);
set(nextSettings, setterKey, value);
settings = nextSettings;
markDirty();
}
function ensureEmbed(): OrgLandingPageEmbed {
if (settings.embed) {
return settings.embed;
}
const embed = {
title: t.get('settings.landing_page.editor.embed.title'),
description: '',
code: '',
secondaryAction: undefined
};
settings = {
...settings,
embed
};
markDirty();
return embed;
}
function setEmbedEnabled(enabled: boolean) {
if (!enabled) {
settings = {
...settings,
embed: undefined
};
markDirty();
return;
}
setter(ensureEmbed(), 'embed');
}
function updateSecondaryAction(label: string, href: string) {
if (!settings.embed) {
return;
}
setter(
{
label,
href
},
'embed.secondaryAction'
);
}
</script>
<Field.Group class="space-y-6">
<Field.Set>
<Field.Legend>{$t('settings.landing_page.editor.sections.embed')}</Field.Legend>
<div class="space-y-6">
<Field.Field orientation="horizontal">
<Switch checked={Boolean(settings.embed)} onCheckedChange={(checked) => setEmbedEnabled(checked === true)} />
<Field.Label>{$t('settings.landing_page.editor.embed.toggle')}</Field.Label>
</Field.Field>
{#if settings.embed}
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.embed.title')}</Field.Label>
<Input
value={settings.embed.title}
placeholder={$t('settings.landing_page.editor.embed.title_placeholder')}
oninput={(event) => setter(event.currentTarget.value, 'embed.title')}
/>
</Field.Field>
<Field.Field class="min-w-0">
<Field.Label>{$t('settings.landing_page.editor.embed.description')}</Field.Label>
<Textarea
class="ui:[field-sizing:fixed] ui:min-w-0 ui:w-full ui:max-w-full ui:box-border ui:resize-y"
value={settings.embed.description ?? ''}
placeholder={$t('settings.landing_page.editor.embed.description_placeholder')}
oninput={(event) => setter(event.currentTarget.value, 'embed.description')}
/>
</Field.Field>
<Field.Field class="min-w-0">
<Field.Label>{$t('settings.landing_page.editor.embed.code')}</Field.Label>
<Field.Description>{$t('settings.landing_page.editor.embed.code_description')}</Field.Description>
<Textarea
class="ui:[field-sizing:fixed] ui:min-w-0 ui:w-full ui:max-w-full ui:box-border ui:resize-y"
value={settings.embed.code}
placeholder={$t('settings.landing_page.editor.embed.code_placeholder')}
oninput={(event) => setter(event.currentTarget.value, 'embed.code')}
/>
</Field.Field>
<ButtonLinkFields
label={settings.embed.secondaryAction?.label ?? ''}
href={settings.embed.secondaryAction?.href ?? ''}
labelFieldLabel={$t('settings.landing_page.editor.embed.secondary_label')}
hrefFieldLabel={$t('settings.landing_page.editor.embed.secondary_href')}
labelPlaceholder={$t('settings.landing_page.editor.embed.secondary_label')}
hrefPlaceholder={$t('settings.landing_page.editor.embed.secondary_href')}
onLabelInput={(value) => updateSecondaryAction(value, settings.embed.secondaryAction?.href ?? '')}
onHrefInput={(value) => updateSecondaryAction(settings.embed.secondaryAction?.label ?? '', value)}
/>
{/if}
</div>
</Field.Set>
</Field.Group>
@@ -0,0 +1,92 @@
<script lang="ts">
import cloneDeep from 'lodash/cloneDeep';
import set from 'lodash/set';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import * as Field from '@cio/ui/base/field';
import { Textarea } from '@cio/ui/base/textarea';
import type { OrgLandingPageJson } from '$lib/utils/types/org';
import ButtonLinkFields from './button-link-fields.svelte';
interface Props {
settings: OrgLandingPageJson;
markDirty: () => void;
}
let { settings = $bindable(), markDirty }: Props = $props();
function setter(value: unknown, setterKey: string) {
if (typeof value === 'undefined') {
return;
}
const nextSettings = cloneDeep(settings);
set(nextSettings, setterKey, value);
settings = nextSettings;
markDirty();
}
function addFooterLink() {
settings = {
...settings,
footerLinks: [
...settings.footerLinks,
{
label: t.get('settings.landing_page.editor.footer.default_label'),
href: '#'
}
]
};
markDirty();
}
function removeFooterLink(index: number) {
settings = {
...settings,
footerLinks: settings.footerLinks.filter((_, linkIndex) => linkIndex !== index)
};
markDirty();
}
</script>
<Field.Group class="space-y-6">
<Field.Set>
<Field.Legend>{$t('settings.landing_page.editor.sections.footer')}</Field.Legend>
<div class="space-y-4">
<Field.Field class="mb-6">
<Field.Label>{$t('settings.landing_page.editor.footer.text')}</Field.Label>
<Textarea
value={settings.footerText}
oninput={(event) => {
settings = {
...settings,
footerText: event.currentTarget.value
};
markDirty();
}}
/>
</Field.Field>
<div class="space-y-4">
{#each settings.footerLinks as footerLink, index (index)}
<ButtonLinkFields
label={footerLink.label}
href={footerLink.href}
labelFieldLabel={$t('settings.landing_page.editor.footer.link_label')}
hrefFieldLabel={$t('settings.landing_page.editor.footer.link_href')}
className="group ui:border-border relative space-y-3 border-b pb-5"
removeAriaLabel={$t('settings.landing_page.editor.footer.remove')}
onLabelInput={(value) => setter(value, `footerLinks.${index}.label`)}
onHrefInput={(value) => setter(value, `footerLinks.${index}.href`)}
onRemove={() => removeFooterLink(index)}
/>
{/each}
<Button type="button" variant="outline" onclick={addFooterLink}>
{$t('settings.landing_page.editor.footer.add')}
</Button>
</div>
</div>
</Field.Set>
</Field.Group>
@@ -0,0 +1,148 @@
<script lang="ts">
import cloneDeep from 'lodash/cloneDeep';
import set from 'lodash/set';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import * as Field from '@cio/ui/base/field';
import { Input } from '@cio/ui/base/input';
import { Switch } from '@cio/ui/base/switch';
import { Textarea } from '@cio/ui/base/textarea';
import { UploadWidget } from '$features/ui';
import { handleOpenWidget } from '$features/ui/course-landing-page/store';
import type { OrgLandingPageJson } from '$lib/utils/types/org';
import ButtonLinkFields from './button-link-fields.svelte';
interface Props {
settings: OrgLandingPageJson;
markDirty: () => void;
}
let { settings = $bindable(), markDirty }: Props = $props();
let activeWidget = $state<'hero-image' | ''>('');
function setter(value: unknown, setterKey: string) {
if (typeof value === 'undefined') {
return;
}
const nextSettings = cloneDeep(settings);
set(nextSettings, setterKey, value);
settings = nextSettings;
markDirty();
}
function toggleSecondaryAction(enabled: boolean) {
if (!enabled) {
settings = {
...settings,
hero: {
...settings.hero,
secondaryAction: undefined
}
};
markDirty();
return;
}
setter(
settings.hero.secondaryAction ?? {
label: t.get('settings.landing_page.editor.hero.secondary_label'),
href: '/courses'
},
'hero.secondaryAction'
);
}
</script>
<Field.Group class="space-y-6">
<Field.Set>
<Field.Legend>{$t('settings.landing_page.editor.sections.hero')}</Field.Legend>
<div class="space-y-4">
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.hero.heading')}</Field.Label>
<Input value={settings.hero.heading} oninput={(event) => setter(event.currentTarget.value, 'hero.heading')} />
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.hero.subheading')}</Field.Label>
<Textarea
value={settings.hero.subheading}
oninput={(event) => setter(event.currentTarget.value, 'hero.subheading')}
/>
</Field.Field>
<ButtonLinkFields
label={settings.hero.primaryAction.label}
href={settings.hero.primaryAction.href}
labelFieldLabel={$t('settings.landing_page.editor.hero.primary_label')}
hrefFieldLabel={$t('settings.landing_page.editor.hero.primary_href')}
labelPlaceholder={$t('settings.landing_page.editor.hero.primary_label')}
hrefPlaceholder={$t('settings.landing_page.editor.hero.primary_href')}
onLabelInput={(value) => setter(value, 'hero.primaryAction.label')}
onHrefInput={(value) => setter(value, 'hero.primaryAction.href')}
/>
<Field.Field orientation="horizontal">
<Switch
checked={Boolean(settings.hero.secondaryAction)}
onCheckedChange={(checked) => toggleSecondaryAction(checked === true)}
/>
<Field.Label>{$t('settings.landing_page.editor.hero.secondary_toggle')}</Field.Label>
</Field.Field>
{#if settings.hero.secondaryAction}
<ButtonLinkFields
label={settings.hero.secondaryAction.label}
href={settings.hero.secondaryAction.href}
labelFieldLabel={$t('settings.landing_page.editor.hero.secondary_label')}
hrefFieldLabel={$t('settings.landing_page.editor.hero.secondary_href')}
labelPlaceholder={$t('settings.landing_page.editor.hero.secondary_label')}
hrefPlaceholder={$t('settings.landing_page.editor.hero.secondary_href')}
onLabelInput={(value) => setter(value, 'hero.secondaryAction.label')}
onHrefInput={(value) => setter(value, 'hero.secondaryAction.href')}
/>
{/if}
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.hero.image')}</Field.Label>
{#if settings.hero.image}
<img src={settings.hero.image} alt="" class="border-border/60 mb-3 w-full rounded-lg border object-cover" />
{/if}
<div class="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onclick={() => {
activeWidget = 'hero-image';
$handleOpenWidget.open = true;
}}
>
{$t('settings.landing_page.editor.hero.upload')}
</Button>
<Button
type="button"
variant="ghost"
onclick={() => {
setter('', 'hero.image');
}}
>
{$t('settings.landing_page.editor.hero.clear')}
</Button>
</div>
{#if $handleOpenWidget.open && activeWidget === 'hero-image'}
<UploadWidget
imageURL={settings.hero.image}
onchange={(imageUrl) => {
setter(imageUrl, 'hero.image');
$handleOpenWidget.open = false;
}}
/>
{/if}
</Field.Field>
</div>
</Field.Set>
</Field.Group>
@@ -0,0 +1,70 @@
<script lang="ts">
import cloneDeep from 'lodash/cloneDeep';
import set from 'lodash/set';
import { t } from '$lib/utils/functions/translations';
import { Button } from '@cio/ui/base/button';
import * as Field from '@cio/ui/base/field';
import type { OrgLandingPageJson } from '$lib/utils/types/org';
import ButtonLinkFields from './button-link-fields.svelte';
interface Props {
settings: OrgLandingPageJson;
markDirty: () => void;
}
let { settings = $bindable(), markDirty }: Props = $props();
function setter(value: unknown, setterKey: string) {
if (typeof value === 'undefined') {
return;
}
const nextSettings = cloneDeep(settings);
set(nextSettings, setterKey, value);
settings = nextSettings;
markDirty();
}
function addNavigationItem() {
settings = {
...settings,
navItems: [
...settings.navItems,
{
label: t.get('settings.landing_page.editor.navigation.default_label'),
href: '#'
}
]
};
markDirty();
}
function removeNavigationItem(index: number) {
settings = {
...settings,
navItems: settings.navItems.filter((_, itemIndex) => itemIndex !== index)
};
markDirty();
}
</script>
<div class="space-y-7">
<Field.Legend>{$t('settings.landing_page.editor.sections.navigation')}</Field.Legend>
{#each settings.navItems as navItem, index (index)}
<ButtonLinkFields
label={navItem.label}
href={navItem.href}
labelFieldLabel={$t('settings.landing_page.editor.navigation.link_label')}
hrefFieldLabel={$t('settings.landing_page.editor.navigation.link_href')}
className="group ui:border-border relative space-y-3 border-b pb-5"
removeAriaLabel={$t('settings.landing_page.editor.navigation.remove')}
onLabelInput={(value) => setter(value, `navItems.${index}.label`)}
onHrefInput={(value) => setter(value, `navItems.${index}.href`)}
onRemove={() => removeNavigationItem(index)}
/>
{/each}
<Button type="button" variant="outline" onclick={addNavigationItem}>
{$t('settings.landing_page.editor.navigation.add')}
</Button>
</div>
@@ -1,761 +1,177 @@
<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 { resolve } from '$app/paths';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
import { openUpgradeModal } from '$lib/utils/functions/org';
import { t } from '$lib/utils/functions/translations';
import { orgApi } from '$features/org/api/org.svelte';
import { handleOpenWidget } from '$features/ui/course-landing-page/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, UnsavedChanges } from '$features/ui';
import * as Field from '@cio/ui/base/field';
import type { OrgLandingPageJson } from '$lib/utils/types/org';
import * as RadioGroup from '@cio/ui/base/radio-group';
import { UnsavedChanges } from '$features/ui';
import { orgApi } from '$features/org/api/org.svelte';
import {
createDefaultLandingPageSettings,
landingPageThemes,
normalizeLandingPageSettings
} from '$features/org/utils/landing-page';
import { landingPageSettings } from '../utils/store';
import { PremiumIcon } from '@cio/ui/custom/moving-icons';
import type { AccountOrg } from '$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 hasInitialized = $state(false);
let skipUnsavedPrompt = $state(false);
let { hasUnsavedChanges = $bindable(false) }: { hasUnsavedChanges?: boolean } = $props();
let newQuestion = $state({
title: '',
content: ''
});
const themeCards = [
{
value: 'minimal',
preview: 'https://assets.cdn.clsrio.com/www/templates/minimal.png',
titleKey: 'settings.landing_page.theme.cards.minimal.title',
descriptionKey: 'settings.landing_page.theme.cards.minimal.description'
},
{
value: 'bold',
preview: 'https://assets.cdn.clsrio.com/www/templates/bold.png',
titleKey: 'settings.landing_page.theme.cards.bold.title',
descriptionKey: 'settings.landing_page.theme.cards.bold.description'
},
{
value: 'classic',
preview: 'https://assets.cdn.clsrio.com/www/templates/classic.png',
titleKey: 'settings.landing_page.theme.cards.classic.title',
descriptionKey: 'settings.landing_page.theme.cards.classic.description'
}
] as const;
let newCustomLink = $state({
label: '',
url: '',
openInNewTab: false
});
function widgetControl(key: string) {
widgetKey = key;
$handleOpenWidget.open = true;
function isPaidTheme(theme: string): boolean {
return theme !== 'minimal';
}
function createNewFaq() {
newQuestion = {
title: '',
content: ''
function selectTheme(theme: (typeof landingPageThemes)[number]) {
if ($isFreePlan && isPaidTheme(theme)) {
openUpgradeModal();
return;
}
$landingPageSettings.theme = theme;
hasUnsavedChanges = true;
}
function isLandingPageTheme(value: string | null): value is (typeof landingPageThemes)[number] {
return value === 'minimal' || value === 'bold' || value === 'classic';
}
function getEditContentUrl() {
const query = new SvelteURLSearchParams(page.url.searchParams);
query.set('theme', $landingPageSettings.theme);
return `${$currentOrgPath}/settings/landingpage/edit?${query.toString()}`;
}
$effect(() => {
if (hasInitialized || !$currentOrg.id) {
return;
}
const normalizedLandingPage = normalizeLandingPageSettings($currentOrg.landingpage);
const orgDefaultLandingPageSettings = createDefaultLandingPageSettings();
const queryTheme = page.url.searchParams.get('theme');
$landingPageSettings = {
...orgDefaultLandingPageSettings,
...normalizedLandingPage
};
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;
if (isLandingPageTheme(queryTheme)) {
$landingPageSettings.theme = queryTheme;
hasUnsavedChanges = queryTheme !== normalizedLandingPage.theme;
} else {
hasUnsavedChanges = 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;
};
hasInitialized = true;
});
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) || '';
const updatedLandingPage = normalizeLandingPageSettings($landingPageSettings as AccountOrg['landingpage']);
await orgApi.update($currentOrg.id, {
landingpage: $landingPageSettings as AccountOrg['landingpage']
landingpage: updatedLandingPage as AccountOrg['landingpage']
});
if (orgApi.success) {
$currentOrg.landingpage = $landingPageSettings as AccountOrg['landingpage'];
$landingPageSettings = updatedLandingPage;
$currentOrg.landingpage = updatedLandingPage 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 />
<UnsavedChanges bind:hasUnsavedChanges skipPrompt={skipUnsavedPrompt} />
<Field.Group class="w-full max-w-md! px-2">
<Field.Group class="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>
<div class="flex items-start justify-between gap-4">
<div>
<Field.Legend>{$t('settings.landing_page.theme.heading')}</Field.Legend>
<Field.Description>{$t('settings.landing_page.theme.description')}</Field.Description>
</div>
<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>
<Button
variant="outline"
onclick={async () => {
skipUnsavedPrompt = true;
<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>
try {
await goto(resolve(getEditContentUrl(), {}));
} finally {
skipUnsavedPrompt = false;
}
}}
>
{$t('settings.landing_page.content.edit')}
</Button>
</div>
<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>
<Field.Field>
<RadioGroup.Root
bind:value={$landingPageSettings.theme}
class="grid gap-4 md:grid-cols-3"
onValueChange={() => {
hasUnsavedChanges = true;
}}
>
{#each themeCards as themeCard (themeCard.value)}
{@const isLocked = $isFreePlan && isPaidTheme(themeCard.value)}
<RadioGroup.Item value={themeCard.value} id={themeCard.value} class="sr-only" />
<button
type="button"
onclick={() => selectTheme(themeCard.value)}
aria-pressed={$landingPageSettings.theme === themeCard.value}
class={`group relative cursor-pointer rounded-xl border transition ${
isLocked
? 'border-border opacity-75 hover:border-blue-500/60'
: $landingPageSettings.theme === themeCard.value
? 'border-primary ring-primary/20 ring-2'
: 'border-border hover:border-primary/60'
}`}
>
{#if isLocked}
<div
class="absolute top-2 right-2 z-10 flex items-center gap-1 rounded-full bg-blue-600 px-2 py-0.5 text-xs font-medium text-white shadow-sm"
>
<PremiumIcon size={14} color="white" />
Upgrade
</div>
{/if}
<div class="bg-background mb-4 overflow-hidden rounded-xl">
<img src={themeCard.preview} alt={$t(themeCard.titleKey)} class="h-50 w-full object-cover object-top" />
</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 class="space-y-1 px-2 pb-2">
<p class="text-base font-semibold">{$t(themeCard.titleKey)}</p>
<p class="ui:text-muted-foreground h-[40px] text-sm">{$t(themeCard.descriptionKey)}</p>
</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>
</button>
{/each}
</RadioGroup.Root>
</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>
<!-- <InputField
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>
@@ -1,116 +1,7 @@
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: '',
image: '/images/learn-on-cio.jpg',
type: 'image',
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: '/images/riverside.jpeg',
show: true
},
courses: {
title: 'Explore our',
titleHighlight: 'Courses',
subtitle: 'Find courses you will love from best teachers all over the world🌎.',
show: true
},
faq: {
title: 'Frequently Asked Questions.',
questions: [
{
id: 1,
title: 'Lorem ipsum dolor sit, amet con',
content:
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
},
{
id: 2,
title: 'Lorem ipsum dolor sit, amet con',
content:
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
},
{
id: 3,
title: 'Lorem ipsum dolor sit, amet con',
content:
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
}
],
show: true
},
contact: {
title: 'Have you got any',
titleHighlight: 'Question?',
subtitle: 'Reach out to us with any question you might have.',
address: 'Ashwoodfield House Farm, Kidderminster Rd, Stourbridge, Kingswinford DY6 0AA',
phone: '+4407339904995',
email: '[email protected]',
show: true
},
mailinglist: {
title: 'Join our mailing list',
subtitle:
'We are constantly releasing new courses and sharing them with our email list. Subscribe to get notified once we release a new course',
buttonLabel: 'Subscribe',
show: true
},
customLinks: {
show: true,
links: [
{
id: 1,
label: 'About Us',
url: '#aboutus',
openInNewTab: false
},
{
id: 2,
label: 'Courses',
url: '/courses',
openInNewTab: false
},
{
id: 3,
label: 'FAQ',
url: '#faq',
openInNewTab: false
},
{
id: 4,
label: 'Contact Us',
url: '#contact',
openInNewTab: false
}
]
},
footer: {
facebook: '#',
instagram: '#',
twitter: '#',
linkedin: '#',
show: true
}
});
import type { OrgLandingPageJson } from '$lib/utils/types/org';
import { createDefaultLandingPageSettings } from '$features/org/utils/landing-page';
export const landingPageSettings: Writable<OrgLandingPageJson> = writable(createDefaultLandingPageSettings());