feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
@@ -1,7 +1,7 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { currentOrgPlan, isFreePlan, currentOrg } from '$lib/utils/store/org';
import { currentOrgPlan, isFreePlan, currentOrg, isSecondaryWorkspace } 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';
@@ -48,7 +48,18 @@
<div class="flex w-full max-w-lg! flex-col gap-4 px-2">
<p class="text-md font-medium">{$t('settings.billing.manage')}</p>
{#if $isFreePlan}
{#if $isSecondaryWorkspace}
<Item.Root variant="outline">
<Item.Media variant="icon">
<ReceiptIcon />
</Item.Media>
<Item.Content>
<Item.Description>
{$t('account.workspaces.secondary_billing_notice', { primaryName: $currentOrg.name })}
</Item.Description>
</Item.Content>
</Item.Root>
{:else if $isFreePlan}
<Item.Root variant="outline">
<Item.Media variant="icon">
<HourglassIcon />
@@ -1,7 +1,7 @@
<script lang="ts">
import { Switch } from '@cio/ui/base/switch';
import { currentOrg } from '$lib/utils/store/org';
import { currentOrg, isFreePlan } from '$lib/utils/store/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';
@@ -18,6 +18,19 @@
$handleOpenWidget.open = true;
}
function authBackgroundWidgetControl() {
if ($isFreePlan) {
return;
}
widgetKey = 'auth-background';
$handleOpenWidget.open = true;
}
function deleteAuthBackgroundImage() {
$currentOrg.customization.auth.backgroundImage = '';
}
export async function handleSave() {
await orgApi.update($currentOrg.id, {
customization: $currentOrg.customization
@@ -74,6 +87,45 @@
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('components.settings.customize_lms.auth_background.title')}</Field.Legend>
<Field.Description>{$t('components.settings.customize_lms.auth_background.description')}</Field.Description>
{#if $isFreePlan}
<Field.Description class="ui:text-muted-foreground">
{$t('components.settings.customize_lms.auth_background.paid_plan_note')}
</Field.Description>
{/if}
<Field.Group>
<Field.Field>
<div class="flex items-center gap-2">
<Button onclick={authBackgroundWidgetControl} disabled={$isFreePlan}>
{$t('course.navItem.settings.replace')}
</Button>
<Button variant="outline" onclick={deleteAuthBackgroundImage} disabled={$isFreePlan}>
{$t('ai.reset')}
</Button>
</div>
{#if $handleOpenWidget.open && widgetKey === 'auth-background'}
<UploadWidget bind:imageURL={$currentOrg.customization.auth.backgroundImage} />
{/if}
</Field.Field>
<Field.Field>
<div class="relative w-fit">
<img
style="min-width:280px; min-height:200px"
alt={$t('components.settings.customize_lms.auth_background.preview_alt')}
src={$currentOrg.customization.auth.backgroundImage
? $currentOrg.customization.auth.backgroundImage
: '/images/classroomio-course-img-template.jpg'}
class="relative mt-2 h-[200px] w-[280px] rounded-md object-cover md:mt-0"
/>
</div>
</Field.Field>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('components.settings.customize_lms.course.title')}</Field.Legend>
<Field.Group>
@@ -9,10 +9,20 @@
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 type { LandingSectionKey } from '@cio/ui/custom/org-landing-page';
import { landingPageEditorSelection } from '$features/settings/utils/store';
import {
ContentIcon,
ExploreIcon,
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 LinksSection from './landingpage-editor/links-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';
@@ -23,7 +33,7 @@
onClose: () => void;
}
type SectionKey = 'hero' | 'navigation' | 'callout' | 'embed' | 'footer';
type SectionKey = LandingSectionKey;
interface SectionDefinition {
key: SectionKey;
@@ -36,7 +46,6 @@
const sidebar = useSidebar();
let selectedSection = $state<SectionKey | null>(null);
let isSaving = $state(false);
let hasUnsavedChanges = $state(false);
@@ -53,6 +62,12 @@
icon: HeaderIcon,
component: HeroSection
},
{
key: 'links',
title: t.get('settings.landing_page.editor.sections.links'),
icon: ExploreIcon,
component: LinksSection
},
{
key: 'embed',
title: t.get('settings.landing_page.editor.sections.embed'),
@@ -73,15 +88,17 @@
}
];
const selectedSectionDefinition = $derived(sections.find((section) => section.key === selectedSection) ?? null);
const selectedSectionDefinition = $derived(
sections.find((section) => section.key === $landingPageEditorSelection) ?? null
);
function markDirty() {
hasUnsavedChanges = true;
}
function handleClose() {
if (selectedSection) {
selectedSection = null;
if ($landingPageEditorSelection) {
$landingPageEditorSelection = null;
return;
}
@@ -105,7 +122,7 @@
<Sidebar.Header
class="flex flex-row! items-center {sidebar.open ? 'justify-between' : 'justify-center'} border-b px-2 py-2"
>
{#if !selectedSection}
{#if !$landingPageEditorSelection}
{#if sidebar.open}
<CloseButton onClick={handleClose} />
@@ -119,7 +136,7 @@
{/if}
{:else if sidebar.open || !sidebar.isMobile}
<div class="flex items-center gap-2">
<IconButton onclick={() => (selectedSection = null)}>
<IconButton onclick={() => ($landingPageEditorSelection = null)}>
<ArrowLeftIcon size={16} />
</IconButton>
<h3 class="text-sm font-semibold">
@@ -127,14 +144,14 @@
</h3>
</div>
{:else}
<IconButton onclick={() => (selectedSection = null)} tooltip={selectedSectionDefinition?.title ?? ''}>
<IconButton onclick={() => ($landingPageEditorSelection = null)} tooltip={selectedSectionDefinition?.title ?? ''}>
<ArrowLeftIcon size={16} />
</IconButton>
{/if}
</Sidebar.Header>
<Sidebar.Content class="flex-1 overflow-y-auto">
{#if !selectedSection}
{#if !$landingPageEditorSelection}
<Sidebar.Group>
<Sidebar.GroupLabel class="px-2">
{$t('settings.landing_page.editor.page_builder')}
@@ -143,7 +160,10 @@
<Sidebar.Menu>
{#each sections as section (section.key)}
<Sidebar.MenuItem>
<Sidebar.MenuButton onclick={() => (selectedSection = section.key)} tooltipContent={section.title}>
<Sidebar.MenuButton
onclick={() => ($landingPageEditorSelection = section.key)}
tooltipContent={section.title}
>
{@const SectionIcon = section.icon}
<SectionIcon size={16} />
<span>{section.title}</span>
@@ -6,6 +6,7 @@
import { Input } from '@cio/ui/base/input';
import { Switch } from '@cio/ui/base/switch';
import { Textarea } from '@cio/ui/base/textarea';
import AIGenerateButton from '$features/agent/components/ai-generate-button.svelte';
import type { OrgLandingPageCallout, OrgLandingPageJson } from '$lib/utils/types/org';
interface Props {
@@ -78,7 +79,13 @@
{#if settings.callout}
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.callout.heading')}</Field.Label>
<div class="flex items-center justify-between">
<Field.Label>{$t('settings.landing_page.editor.callout.heading')}</Field.Label>
<AIGenerateButton
context="the callout/CTA section heading on an organization's course platform landing page"
onInsert={(text) => setter(text, 'callout.heading')}
/>
</div>
<Input
value={settings.callout.heading}
placeholder={$t('settings.landing_page.editor.callout.heading_placeholder')}
@@ -87,7 +94,13 @@
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.callout.description')}</Field.Label>
<div class="flex items-center justify-between">
<Field.Label>{$t('settings.landing_page.editor.callout.description')}</Field.Label>
<AIGenerateButton
context="the callout/CTA section description on an organization's course platform landing page"
onInsert={(text) => setter(text, 'callout.description')}
/>
</div>
<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}
@@ -5,6 +5,7 @@
import * as Field from '@cio/ui/base/field';
import { Switch } from '@cio/ui/base/switch';
import { Textarea } from '@cio/ui/base/textarea';
import AIGenerateButton from '$features/agent/components/ai-generate-button.svelte';
import type { OrgLandingPageEmbed, OrgLandingPageJson } from '$lib/utils/types/org';
import ButtonLinkFields from './button-link-fields.svelte';
import { Input } from '@cio/ui/base/input';
@@ -88,7 +89,13 @@
{#if settings.embed}
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.embed.title')}</Field.Label>
<div class="flex items-center justify-between">
<Field.Label>{$t('settings.landing_page.editor.embed.title')}</Field.Label>
<AIGenerateButton
context="the embed section title on an organization's course platform landing page"
onInsert={(text) => setter(text, 'embed.title')}
/>
</div>
<Input
value={settings.embed.title}
placeholder={$t('settings.landing_page.editor.embed.title_placeholder')}
@@ -97,7 +104,13 @@
</Field.Field>
<Field.Field class="min-w-0">
<Field.Label>{$t('settings.landing_page.editor.embed.description')}</Field.Label>
<div class="flex items-center justify-between">
<Field.Label>{$t('settings.landing_page.editor.embed.description')}</Field.Label>
<AIGenerateButton
context="the embed section description on an organization's course platform landing page"
onInsert={(text) => setter(text, 'embed.description')}
/>
</div>
<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 ?? ''}
@@ -1,13 +1,25 @@
<script lang="ts">
import cloneDeep from 'lodash/cloneDeep';
import set from 'lodash/set';
import TrashIcon from '@lucide/svelte/icons/trash';
import { t } from '$lib/utils/functions/translations';
import { currentOrg } from '$lib/utils/store/org';
import * as Accordion from '@cio/ui/base/accordion';
import { Button } from '@cio/ui/base/button';
import * as Field from '@cio/ui/base/field';
import { Input } from '@cio/ui/base/input';
import * as Select from '@cio/ui/base/select';
import { Switch } from '@cio/ui/base/switch';
import { Textarea } from '@cio/ui/base/textarea';
import type { OrgLandingPageJson } from '$lib/utils/types/org';
import { IconButton } from '@cio/ui/custom/icon-button';
import FooterSocialIcon from '@cio/ui/custom/org-landing-page/footer-social-icon.svelte';
import { FOOTER_SOCIAL_PLATFORMS } from '@cio/ui/custom/org-landing-page/footer-social-platform';
import type { FooterSocialPlatform, OrgLandingPageJson } from '$lib/utils/types/org';
import ButtonLinkFields from './button-link-fields.svelte';
const MAX_COLUMNS = 6;
const MAX_LINKS_PER_COLUMN = 10;
interface Props {
settings: OrgLandingPageJson;
markDirty: () => void;
@@ -15,6 +27,8 @@
let { settings = $bindable(), markDirty }: Props = $props();
let openSections = $state(['brand']);
function setter(value: unknown, setterKey: string) {
if (typeof value === 'undefined') {
return;
@@ -26,67 +40,396 @@
markDirty();
}
function addFooterLink() {
function platformTranslationKey(platform: FooterSocialPlatform): string {
const key = platform === 'x' ? 'x' : platform;
return `settings.landing_page.editor.footer.brand.platforms.${key}`;
}
function addSocial() {
const nextSettings = cloneDeep(settings);
nextSettings.footer.brand.socials.push({
platform: 'linkedin',
href: 'https://'
});
settings = nextSettings;
markDirty();
}
function removeSocial(index: number) {
settings = {
...settings,
footerLinks: [
...settings.footerLinks,
footer: {
...settings.footer,
brand: {
...settings.footer.brand,
socials: settings.footer.brand.socials.filter((_, socialIndex) => socialIndex !== index)
}
}
};
markDirty();
}
function addColumn() {
const nextSettings = cloneDeep(settings);
nextSettings.footer.columns.push({
id: crypto.randomUUID(),
heading: '',
links: [
{
id: crypto.randomUUID(),
label: t.get('settings.landing_page.editor.footer.default_label'),
href: '#'
}
]
};
});
settings = nextSettings;
markDirty();
}
function removeFooterLink(index: number) {
function removeColumn(columnIndex: number) {
settings = {
...settings,
footerLinks: settings.footerLinks.filter((_, linkIndex) => linkIndex !== index)
footer: {
...settings.footer,
columns: settings.footer.columns.filter((_, index) => index !== columnIndex)
}
};
markDirty();
}
function addColumnLink(columnIndex: number) {
const nextSettings = cloneDeep(settings);
const column = nextSettings.footer.columns[columnIndex];
if (!column || column.links.length >= MAX_LINKS_PER_COLUMN) {
return;
}
column.links.push({
id: crypto.randomUUID(),
label: t.get('settings.landing_page.editor.footer.default_label'),
href: '#'
});
settings = nextSettings;
markDirty();
}
function removeColumnLink(columnIndex: number, linkIndex: number) {
const nextSettings = cloneDeep(settings);
const column = nextSettings.footer.columns[columnIndex];
if (!column || column.links.length <= 1) {
return;
}
column.links = column.links.filter((_, index) => index !== linkIndex);
settings = nextSettings;
markDirty();
}
function setColumnCtaEnabled(columnIndex: number, enabled: boolean) {
const nextSettings = cloneDeep(settings);
const column = nextSettings.footer.columns[columnIndex];
if (!column) {
return;
}
if (enabled) {
column.cta = {
label: t.get('settings.landing_page.editor.footer.columns.cta_default_label'),
href: '#'
};
} else {
delete column.cta;
}
settings = nextSettings;
markDirty();
}
function addBottomLink() {
const nextSettings = cloneDeep(settings);
nextSettings.footer.bottom ??= { text: '', links: [] };
nextSettings.footer.bottom.links.push({
id: crypto.randomUUID(),
label: t.get('settings.landing_page.editor.footer.default_label'),
href: '#'
});
settings = nextSettings;
markDirty();
}
function removeBottomLink(linkIndex: number) {
const nextSettings = cloneDeep(settings);
nextSettings.footer.bottom ??= { text: '', links: [] };
nextSettings.footer.bottom.links = nextSettings.footer.bottom.links.filter((_, index) => index !== linkIndex);
settings = nextSettings;
markDirty();
}
const bottomLinks = $derived(settings.footer.bottom?.links ?? []);
</script>
<Field.Group class="space-y-6">
<Field.Set>
<Field.Legend>{$t('settings.landing_page.editor.sections.footer')}</Field.Legend>
<Accordion.Root
type="multiple"
bind:value={openSections}
class="border-border divide-border divide-y rounded-lg border"
>
<!-- Brand -->
<Accordion.Item value="brand" class="border-none px-4 last:border-none">
<Accordion.Trigger class="hover:no-underline">
{$t('settings.landing_page.editor.footer.brand.legend')}
</Accordion.Trigger>
<Accordion.Content>
<div class="space-y-4 pb-2">
<div class="flex items-center gap-2.5 text-sm">
{#if $currentOrg.avatarUrl}
<img src={$currentOrg.avatarUrl} alt="" class="size-7 rounded object-cover" />
{/if}
<span class="font-medium">{$currentOrg.name}</span>
</div>
<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)}
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.footer.brand.tagline')}</Field.Label>
<Input
value={settings.footer.brand.tagline ?? ''}
placeholder={$t('settings.landing_page.editor.footer.brand.tagline_placeholder')}
oninput={(event) => setter(event.currentTarget.value || undefined, 'footer.brand.tagline')}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.footer.brand.copyright')}</Field.Label>
<Input
value={settings.footer.brand.copyright ?? ''}
placeholder={$t('settings.landing_page.editor.footer.brand.copyright_placeholder')}
oninput={(event) => setter(event.currentTarget.value || undefined, 'footer.brand.copyright')}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.footer.brand.social_label')}</Field.Label>
<div class="space-y-2">
{#each settings.footer.brand.socials as social, socialIndex (socialIndex)}
<div class="flex items-center gap-2">
<Select.Root
type="single"
value={social.platform}
onValueChange={(value) => {
if (value) {
setter(value as FooterSocialPlatform, `footer.brand.socials.${socialIndex}.platform`);
}
}}
>
<Select.Trigger class="w-36 shrink-0">
<span class="flex items-center gap-1.5">
<FooterSocialIcon platform={social.platform} class="size-3.5" />
{$t(platformTranslationKey(social.platform))}
</span>
</Select.Trigger>
<Select.Content>
{#each FOOTER_SOCIAL_PLATFORMS as platformOption (platformOption)}
<Select.Item value={platformOption}>
<span class="flex items-center gap-2">
<FooterSocialIcon platform={platformOption} class="size-4" />
{$t(platformTranslationKey(platformOption))}
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<Input
value={social.href}
placeholder="https://"
class="flex-1"
oninput={(event) => setter(event.currentTarget.value, `footer.brand.socials.${socialIndex}.href`)}
/>
<IconButton
aria-label={$t('settings.landing_page.editor.footer.brand.remove_social')}
onclick={() => removeSocial(socialIndex)}
>
<TrashIcon size={14} />
</IconButton>
</div>
{/each}
<Button type="button" variant="outline" size="sm" onclick={addSocial}>
{$t('settings.landing_page.editor.footer.brand.add_social')}
</Button>
</div>
</Field.Field>
</div>
</Accordion.Content>
</Accordion.Item>
<!-- Link columns -->
<Accordion.Item value="columns" class="border-none px-4 last:border-none">
<Accordion.Trigger class="hover:no-underline">
{$t('settings.landing_page.editor.footer.columns.legend')}
{#if settings.footer.columns.length > 0}
<span class="ui:text-muted-foreground ml-1 text-xs font-normal">({settings.footer.columns.length})</span>
{/if}
</Accordion.Trigger>
<Accordion.Content>
<div class="space-y-3 pb-2">
{#if settings.footer.columns.length === 0}
<p class="ui:text-muted-foreground text-sm">{$t('settings.landing_page.editor.footer.columns.empty_hint')}</p>
{/if}
{#each settings.footer.columns as column, columnIndex (column.id)}
<div class="border-border rounded-md border bg-white p-3 dark:bg-transparent">
<div class="mb-3 flex items-center justify-between gap-2">
<Input
value={column.heading}
placeholder={$t('settings.landing_page.editor.footer.columns.heading_placeholder')}
class="h-8 text-sm font-medium"
oninput={(event) => setter(event.currentTarget.value, `footer.columns.${columnIndex}.heading`)}
/>
<Button
type="button"
variant="ghost"
size="sm"
class="text-destructive hover:text-destructive shrink-0"
onclick={() => removeColumn(columnIndex)}
>
<TrashIcon size={14} />
</Button>
</div>
<div class="space-y-2">
{#each column.links as columnLink, linkIndex (linkIndex)}
<div class="flex items-center gap-2">
<Input
value={columnLink.label}
placeholder={$t('settings.landing_page.editor.footer.link_label')}
class="h-7 flex-1 text-xs"
oninput={(event) =>
setter(event.currentTarget.value, `footer.columns.${columnIndex}.links.${linkIndex}.label`)}
/>
<Input
value={columnLink.href}
placeholder="#"
class="h-7 flex-1 text-xs"
oninput={(event) =>
setter(event.currentTarget.value, `footer.columns.${columnIndex}.links.${linkIndex}.href`)}
/>
{#if column.links.length > 1}
<IconButton
aria-label={$t('settings.landing_page.editor.footer.columns.remove_link')}
onclick={() => removeColumnLink(columnIndex, linkIndex)}
>
<TrashIcon size={12} />
</IconButton>
{/if}
</div>
{/each}
</div>
<div class="mt-2 flex items-center justify-between">
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 text-xs"
onclick={() => addColumnLink(columnIndex)}
disabled={column.links.length >= MAX_LINKS_PER_COLUMN}
>
+ {$t('settings.landing_page.editor.footer.columns.add_link')}
</Button>
<Field.Field orientation="horizontal" class="gap-1.5">
<Switch
checked={Boolean(column.cta)}
onCheckedChange={(checked) => setColumnCtaEnabled(columnIndex, checked === true)}
/>
<Field.Label class="text-xs">{$t('settings.landing_page.editor.footer.columns.cta_toggle')}</Field.Label
>
</Field.Field>
</div>
{#if column.cta}
<div class="border-border mt-2 flex items-center gap-2 border-t pt-2">
<Input
value={column.cta.label}
placeholder={$t('settings.landing_page.editor.footer.columns.cta_label')}
class="h-7 flex-1 text-xs"
oninput={(event) => setter(event.currentTarget.value, `footer.columns.${columnIndex}.cta.label`)}
/>
<Input
value={column.cta.href}
placeholder="#"
class="h-7 flex-1 text-xs"
oninput={(event) => setter(event.currentTarget.value, `footer.columns.${columnIndex}.cta.href`)}
/>
</div>
{/if}
</div>
{/each}
<Button type="button" variant="outline" onclick={addFooterLink}>
{$t('settings.landing_page.editor.footer.add')}
<Button
type="button"
variant="outline"
size="sm"
onclick={addColumn}
disabled={settings.footer.columns.length >= MAX_COLUMNS}
>
{$t('settings.landing_page.editor.footer.columns.add')}
</Button>
</div>
</div>
</Field.Set>
</Field.Group>
</Accordion.Content>
</Accordion.Item>
<!-- Bottom bar -->
<Accordion.Item value="bottom" class="border-none px-4 last:border-none">
<Accordion.Trigger class="hover:no-underline">
{$t('settings.landing_page.editor.footer.bottom.legend')}
</Accordion.Trigger>
<Accordion.Content>
<div class="space-y-4 pb-2">
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.footer.text')}</Field.Label>
<Textarea
value={settings.footer.bottom?.text ?? ''}
class="ui:[field-sizing:fixed] ui:min-w-0 ui:w-full ui:max-w-full ui:box-border ui:resize-y"
oninput={(event) => {
const nextSettings = cloneDeep(settings);
nextSettings.footer.bottom ??= { text: '', links: [] };
nextSettings.footer.bottom.text = event.currentTarget.value;
settings = nextSettings;
markDirty();
}}
/>
</Field.Field>
<div class="space-y-2">
{#each bottomLinks as bottomLink, bottomLinkIndex (bottomLinkIndex)}
<div class="flex items-center gap-2">
<Input
value={bottomLink.label}
placeholder={$t('settings.landing_page.editor.footer.link_label')}
class="h-8 flex-1 text-sm"
oninput={(event) => setter(event.currentTarget.value, `footer.bottom.links.${bottomLinkIndex}.label`)}
/>
<Input
value={bottomLink.href}
placeholder="#"
class="h-8 flex-1 text-sm"
oninput={(event) => setter(event.currentTarget.value, `footer.bottom.links.${bottomLinkIndex}.href`)}
/>
<IconButton
aria-label={$t('settings.landing_page.editor.footer.remove')}
onclick={() => removeBottomLink(bottomLinkIndex)}
>
<TrashIcon size={14} />
</IconButton>
</div>
{/each}
<Button type="button" variant="outline" size="sm" onclick={addBottomLink}>
{$t('settings.landing_page.editor.footer.bottom.add_link')}
</Button>
</div>
</div>
</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
@@ -7,10 +7,13 @@
import { Input } from '@cio/ui/base/input';
import { Switch } from '@cio/ui/base/switch';
import { Textarea } from '@cio/ui/base/textarea';
import AIGenerateButton from '$features/agent/components/ai-generate-button.svelte';
import { UploadWidget } from '$features/ui';
import { handleOpenWidget } from '$features/ui/course-landing-page/store';
import type { OrgLandingPageJson } from '$lib/utils/types/org';
import type { OrgLandingPageHeroStat, OrgLandingPageJson } from '$lib/utils/types/org';
import ButtonLinkFields from './button-link-fields.svelte';
import TrashIcon from '@lucide/svelte/icons/trash-2';
import PlusIcon from '@lucide/svelte/icons/plus';
interface Props {
settings: OrgLandingPageJson;
@@ -21,6 +24,36 @@
let activeWidget = $state<'hero-image' | ''>('');
const MAX_STATS = 4;
function updateStats(next: OrgLandingPageHeroStat[]) {
settings = {
...settings,
hero: {
...settings.hero,
stats: next.length > 0 ? next : undefined
}
};
markDirty();
}
function addStat() {
const current = settings.hero.stats ?? [];
if (current.length >= MAX_STATS) return;
updateStats([...current, { label: '', value: '' }]);
}
function removeStat(index: number) {
const current = settings.hero.stats ?? [];
updateStats(current.filter((_, i) => i !== index));
}
function updateStatField(index: number, field: 'label' | 'value', nextValue: string) {
const current = settings.hero.stats ?? [];
const next = current.map((stat, i) => (i === index ? { ...stat, [field]: nextValue } : stat));
updateStats(next);
}
function setter(value: unknown, setterKey: string) {
if (typeof value === 'undefined') {
return;
@@ -61,12 +94,24 @@
<div class="space-y-4">
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.hero.heading')}</Field.Label>
<div class="flex items-center justify-between">
<Field.Label>{$t('settings.landing_page.editor.hero.heading')}</Field.Label>
<AIGenerateButton
context="the main hero heading on an organization's course platform landing page"
onInsert={(text) => setter(text, 'hero.heading')}
/>
</div>
<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>
<div class="flex items-center justify-between">
<Field.Label>{$t('settings.landing_page.editor.hero.subheading')}</Field.Label>
<AIGenerateButton
context="the hero subheading/tagline on an organization's course platform landing page"
onInsert={(text) => setter(text, 'hero.subheading')}
/>
</div>
<Textarea
value={settings.hero.subheading}
oninput={(event) => setter(event.currentTarget.value, 'hero.subheading')}
@@ -105,6 +150,63 @@
/>
{/if}
<Field.Field>
<div class="flex items-center justify-between">
<Field.Label>{$t('settings.landing_page.editor.hero.stats.label')}</Field.Label>
<Button
type="button"
variant="ghost"
size="sm"
class="gap-1"
disabled={(settings.hero.stats?.length ?? 0) >= MAX_STATS}
onclick={addStat}
>
<PlusIcon class="size-3.5" />
{$t('settings.landing_page.editor.hero.stats.add')}
</Button>
</div>
<Field.Description>{$t('settings.landing_page.editor.hero.stats.description')}</Field.Description>
{#if settings.hero.stats && settings.hero.stats.length > 0}
<div class="space-y-3">
{#each settings.hero.stats as stat, index (index)}
<div class="flex items-end gap-2">
<div class="flex-1">
<Field.Label class="text-muted-foreground mb-1 text-xs">
{$t('settings.landing_page.editor.hero.stats.label_field')}
</Field.Label>
<Input
value={stat.label}
placeholder={$t('settings.landing_page.editor.hero.stats.label_placeholder')}
oninput={(event) => updateStatField(index, 'label', event.currentTarget.value)}
/>
</div>
<div class="w-32">
<Field.Label class="text-muted-foreground mb-1 text-xs">
{$t('settings.landing_page.editor.hero.stats.value_field')}
</Field.Label>
<Input
value={stat.value}
placeholder={$t('settings.landing_page.editor.hero.stats.value_placeholder')}
oninput={(event) => updateStatField(index, 'value', event.currentTarget.value)}
/>
</div>
<Button
type="button"
variant="ghost"
size="icon"
class="text-muted-foreground hover:text-destructive"
onclick={() => removeStat(index)}
aria-label={$t('settings.landing_page.editor.hero.stats.remove')}
>
<TrashIcon class="size-4" />
</Button>
</div>
{/each}
</div>
{/if}
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.hero.image')}</Field.Label>
{#if settings.hero.image}
@@ -0,0 +1,265 @@
<script lang="ts">
import cloneDeep from 'lodash/cloneDeep';
import set from 'lodash/set';
import TrashIcon from '@lucide/svelte/icons/trash';
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 * as Select from '@cio/ui/base/select';
import { Switch } from '@cio/ui/base/switch';
import { Textarea } from '@cio/ui/base/textarea';
import { IconButton } from '@cio/ui/custom/icon-button';
import { landingPageLinkIconMap } from '@cio/ui/custom/org-landing-page/landing-page-link-icons';
import type { OrgLandingPageJson, OrgLandingPageLinkIcon } from '$lib/utils/types/org';
const LINK_ICONS: OrgLandingPageLinkIcon[] = [
'help-circle',
'life-buoy',
'book-open',
'video',
'users',
'message-circle',
'newspaper',
'rocket',
'calendar',
'mail'
];
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 iconLabel(icon: OrgLandingPageLinkIcon) {
return t.get(`settings.landing_page.editor.links.icons.${icon.replace(/-/g, '_')}`);
}
function ensureLinks(): NonNullable<OrgLandingPageJson['links']> {
if (settings.links) {
return settings.links;
}
const links: NonNullable<OrgLandingPageJson['links']> = {
heading: t.get('settings.landing_page.editor.links.heading_default'),
description: '',
boldVisitLabel: t.get('settings.landing_page.editor.links.bold_visit_default'),
classicLearnMoreLabel: t.get('settings.landing_page.editor.links.classic_learn_more_default'),
cards: [
{
icon: 'help-circle',
title: t.get('settings.landing_page.editor.links.card_placeholder_help'),
description: '',
href: 'https://'
},
{
icon: 'video',
title: t.get('settings.landing_page.editor.links.card_placeholder_webinars'),
description: '',
href: 'https://'
},
{
icon: 'users',
title: t.get('settings.landing_page.editor.links.card_placeholder_community'),
description: '',
href: 'https://'
}
]
};
settings = {
...settings,
links
};
markDirty();
return links;
}
function setLinksEnabled(enabled: boolean) {
if (!enabled) {
settings = {
...settings,
links: undefined
};
markDirty();
return;
}
ensureLinks();
}
function addCard() {
if (!settings.links || settings.links.cards.length >= 3) {
return;
}
const nextSettings = cloneDeep(settings);
nextSettings.links!.cards.push({
icon: 'book-open',
title: t.get('settings.landing_page.editor.navigation.default_label'),
description: '',
href: 'https://'
});
settings = nextSettings;
markDirty();
}
function removeCard(index: number) {
if (!settings.links || settings.links.cards.length <= 1) {
return;
}
const nextSettings = cloneDeep(settings);
nextSettings.links!.cards = nextSettings.links!.cards.filter((_, cardIndex) => cardIndex !== index);
settings = nextSettings;
markDirty();
}
</script>
<Field.Group class="space-y-6">
<Field.Set>
<Field.Legend>{$t('settings.landing_page.editor.sections.links')}</Field.Legend>
<Field.Description>{$t('settings.landing_page.editor.sections.links_desc')}</Field.Description>
<div class="space-y-6">
<Field.Field orientation="horizontal">
<Switch checked={Boolean(settings.links)} onCheckedChange={(checked) => setLinksEnabled(checked === true)} />
<Field.Label>{$t('settings.landing_page.editor.links.toggle')}</Field.Label>
</Field.Field>
{#if settings.links}
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.links.heading')}</Field.Label>
<Input
value={settings.links.heading}
placeholder={$t('settings.landing_page.editor.links.heading_placeholder')}
oninput={(event) => setter(event.currentTarget.value, 'links.heading')}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.links.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.links.description ?? ''}
placeholder={$t('settings.landing_page.editor.links.description_placeholder')}
oninput={(event) => setter(event.currentTarget.value, 'links.description')}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.links.bold_visit_label')}</Field.Label>
<Input
value={settings.links.boldVisitLabel ?? ''}
placeholder={$t('settings.landing_page.editor.links.bold_visit_default')}
oninput={(event) => setter(event.currentTarget.value, 'links.boldVisitLabel')}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.links.classic_learn_more_label')}</Field.Label>
<Input
value={settings.links.classicLearnMoreLabel ?? ''}
placeholder={$t('settings.landing_page.editor.links.classic_learn_more_default')}
oninput={(event) => setter(event.currentTarget.value, 'links.classicLearnMoreLabel')}
/>
</Field.Field>
{#each settings.links.cards as card, index (index)}
<div class="group ui:border-border relative space-y-3 border-b pb-5">
{#if settings.links.cards.length > 1}
<IconButton
class="absolute! -top-3! right-2!"
aria-label={$t('settings.landing_page.editor.links.remove_card')}
onclick={() => removeCard(index)}
>
<TrashIcon size={16} />
</IconButton>
{/if}
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.links.card_icon')}</Field.Label>
<Select.Root
type="single"
value={card.icon}
onValueChange={(value) => {
if (value) {
setter(value, `links.cards.${index}.icon`);
}
}}
>
<Select.Trigger class="w-full">
{@const PreviewIcon = landingPageLinkIconMap[card.icon]}
<span class="flex items-center gap-2">
<PreviewIcon class="size-4" />
{iconLabel(card.icon)}
</span>
</Select.Trigger>
<Select.Content>
{#each LINK_ICONS as iconOption (iconOption)}
{@const OptionIcon = landingPageLinkIconMap[iconOption]}
<Select.Item value={iconOption}>
<span class="flex items-center gap-2">
<OptionIcon class="size-4" />
{iconLabel(iconOption)}
</span>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.links.card_title')}</Field.Label>
<Input
value={card.title}
placeholder={$t('settings.landing_page.editor.links.card_title_placeholder')}
oninput={(event) => setter(event.currentTarget.value, `links.cards.${index}.title`)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.links.card_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={card.description}
placeholder={$t('settings.landing_page.editor.links.card_description_placeholder')}
oninput={(event) => setter(event.currentTarget.value, `links.cards.${index}.description`)}
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('settings.landing_page.editor.links.card_href')}</Field.Label>
<Input
value={card.href}
placeholder={$t('settings.landing_page.editor.links.card_href_placeholder')}
oninput={(event) => setter(event.currentTarget.value, `links.cards.${index}.href`)}
/>
</Field.Field>
</div>
{/each}
<Button type="button" variant="outline" onclick={addCard} disabled={settings.links.cards.length >= 3}>
{$t('settings.landing_page.editor.links.add_card')}
</Button>
{#if settings.links.cards.length >= 3}
<p class="text-muted-foreground text-xs">{$t('settings.landing_page.editor.links.max_reached')}</p>
{/if}
{/if}
</div>
</Field.Set>
</Field.Group>
@@ -1,28 +1,42 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
import LayoutTemplateIcon from '@lucide/svelte/icons/layout-template';
import ZapIcon from '@lucide/svelte/icons/zap';
import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
import { openUpgradeModal } from '$lib/utils/functions/org';
import { t } from '$lib/utils/functions/translations';
import { user } from '$lib/utils/store/user';
import { basePath } from '$lib/utils/store/app';
import { Button } from '@cio/ui/base/button';
import * as Field from '@cio/ui/base/field';
import * as RadioGroup from '@cio/ui/base/radio-group';
import { UnsavedChanges } from '$features/ui';
import { orgApi } from '$features/org/api/org.svelte';
import { Badge } from '@cio/ui/base/badge';
import * as Card from '@cio/ui/base/card';
import { PremiumIcon } from '@cio/ui/custom/moving-icons';
import {
createDefaultLandingPageSettings,
landingPageThemes,
normalizeLandingPageSettings
normalizeLandingPageSettings,
buildOrgLandingPageProps
} 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';
import { landingPageThemeComponents } from '$features/org/utils/landing-page-components';
type LandingPageTheme = (typeof landingPageThemes)[number];
let normalized = $state(createDefaultLandingPageSettings());
let hasInitialized = $state(false);
let skipUnsavedPrompt = $state(false);
let { hasUnsavedChanges = $bindable(false) }: { hasUnsavedChanges?: boolean } = $props();
$effect(() => {
if (hasInitialized || !$currentOrg.id) return;
normalized = normalizeLandingPageSettings($currentOrg.landingpage);
hasInitialized = true;
});
const currentTheme = $derived(normalized.theme as LandingPageTheme);
const themeCards = [
{
@@ -42,136 +56,204 @@
preview: '/templates/classic.png',
titleKey: 'settings.landing_page.theme.cards.classic.title',
descriptionKey: 'settings.landing_page.theme.cards.classic.description'
},
{
value: 'saas',
preview: '/templates/saas.png',
titleKey: 'settings.landing_page.theme.cards.saas.title',
descriptionKey: 'settings.landing_page.theme.cards.saas.description'
},
{
value: 'tech',
preview: '/templates/tech.png',
titleKey: 'settings.landing_page.theme.cards.tech.title',
descriptionKey: 'settings.landing_page.theme.cards.tech.description'
},
{
value: 'studio',
preview: '/templates/studio.png',
titleKey: 'settings.landing_page.theme.cards.studio.title',
descriptionKey: 'settings.landing_page.theme.cards.studio.description'
},
{
value: 'corporate',
preview: '/templates/corporate.png',
titleKey: 'settings.landing_page.theme.cards.corporate.title',
descriptionKey: 'settings.landing_page.theme.cards.corporate.description'
},
{
value: 'terminal',
preview: '/templates/terminal.png',
titleKey: 'settings.landing_page.theme.cards.terminal.title',
descriptionKey: 'settings.landing_page.theme.cards.terminal.description'
}
] as const;
] as const satisfies ReadonlyArray<{
value: LandingPageTheme;
preview: string;
titleKey: string;
descriptionKey: string;
}>;
function isPaidTheme(theme: string): boolean {
const currentThemeCard = $derived(themeCards.find((card) => card.value === currentTheme) ?? themeCards[0]);
const otherThemeCards = $derived(themeCards.filter((card) => card.value !== currentTheme));
function isPaidTheme(theme: LandingPageTheme): boolean {
return theme !== 'minimal';
}
function selectTheme(theme: (typeof landingPageThemes)[number]) {
const authAction = $derived(
$user.isLoggedIn
? {
label: t.get($basePath === '/lms' || $basePath === '#' ? 'navigation.goto_lms' : 'navigation.goto_dashboard'),
href: resolve(`${$basePath !== '#' ? $basePath : '/lms'}`, {})
}
: { label: t.get('navigation.login'), href: '/login' }
);
const previewProps = $derived(buildOrgLandingPageProps($currentOrg, normalized, [], false, authAction));
const ThemeComponent = $derived(landingPageThemeComponents[currentTheme] ?? landingPageThemeComponents.minimal);
const customizeHref = $derived(resolve(`${$currentOrgPath}/settings/landingpage/edit`, {}));
function handleAddTheme(theme: LandingPageTheme) {
if ($isFreePlan && isPaidTheme(theme)) {
openUpgradeModal();
return;
}
$landingPageSettings.theme = theme;
hasUnsavedChanges = true;
goto(resolve(`${$currentOrgPath}/settings/landingpage/edit?theme=${theme}`, {}));
}
function isLandingPageTheme(value: string | null): value is (typeof landingPageThemes)[number] {
return value === 'minimal' || value === 'bold' || value === 'classic';
}
const navCount = $derived(normalized.navItems?.length ?? 0);
const heroHeading = $derived(normalized.hero?.heading?.trim() || t.get('settings.landing_page.current.hero_empty'));
const navCountLabel = $derived(
t.get(
navCount === 1
? 'settings.landing_page.current.field_nav_count_one'
: 'settings.landing_page.current.field_nav_count_other',
{ count: navCount }
)
);
function getEditContentUrl() {
const query = new SvelteURLSearchParams(page.url.searchParams);
query.set('theme', $landingPageSettings.theme);
return `${$currentOrgPath}/settings/landingpage/edit?${query.toString()}`;
}
// Disabled if the current theme is paid and the org is on a free plan (legacy state).
const customizeLocked = $derived($isFreePlan && isPaidTheme(currentTheme));
$effect(() => {
if (hasInitialized || !$currentOrg.id) {
return;
}
const normalizedLandingPage = normalizeLandingPageSettings($currentOrg.landingpage);
const orgDefaultLandingPageSettings = createDefaultLandingPageSettings();
const queryTheme = page.url.searchParams.get('theme');
$landingPageSettings = {
...orgDefaultLandingPageSettings,
...normalizedLandingPage
};
if (isLandingPageTheme(queryTheme)) {
$landingPageSettings.theme = queryTheme;
hasUnsavedChanges = queryTheme !== normalizedLandingPage.theme;
} else {
hasUnsavedChanges = false;
}
hasInitialized = true;
});
export async function handleSave() {
const updatedLandingPage = normalizeLandingPageSettings($landingPageSettings as AccountOrg['landingpage']);
await orgApi.update($currentOrg.id, {
landingpage: updatedLandingPage as AccountOrg['landingpage']
});
if (orgApi.success) {
$landingPageSettings = updatedLandingPage;
$currentOrg.landingpage = updatedLandingPage as AccountOrg['landingpage'];
hasUnsavedChanges = false;
}
}
const PREVIEW_RENDER_WIDTH = 1440;
let previewWidth = $state(0);
const previewScale = $derived(previewWidth > 0 ? previewWidth / PREVIEW_RENDER_WIDTH : 0.5);
</script>
<UnsavedChanges bind:hasUnsavedChanges skipPrompt={skipUnsavedPrompt} />
<Field.Group class="w-full px-2">
<Field.Set>
<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 class="w-full space-y-10 px-2 pb-12">
<section>
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_320px]">
<div
class="ui:border-border bg-background relative aspect-[16/10] w-full overflow-hidden rounded-xl border select-none"
bind:clientWidth={previewWidth}
>
<div
class="pointer-events-none absolute top-0 left-0 origin-top-left"
style="width: {PREVIEW_RENDER_WIDTH}px; height: {PREVIEW_RENDER_WIDTH *
0.625}px; transform: scale({previewScale});"
>
{#if $currentOrg.id}
<ThemeComponent {...previewProps} disableCourseLinks={true} />
{/if}
</div>
<div
class="from-background pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t to-transparent"
></div>
</div>
<Button
variant="outline"
onclick={async () => {
skipUnsavedPrompt = true;
<Card.Root class="flex flex-col">
<Card.Header>
<Card.Title class="text-base">{$t('settings.landing_page.current.title')}</Card.Title>
<Card.Description>{$t('settings.landing_page.current.description')}</Card.Description>
</Card.Header>
<Card.Content class="space-y-3 pb-4">
<div class="flex items-center justify-between gap-3">
<span class="ui:text-muted-foreground text-xs tracking-wider uppercase">
{$t('settings.landing_page.current.field_theme')}
</span>
<Badge variant="secondary" class="text-[10px] uppercase">
{$t(currentThemeCard.titleKey)}
</Badge>
</div>
<div class="flex items-start justify-between gap-3">
<span class="ui:text-muted-foreground shrink-0 text-xs tracking-wider uppercase">
{$t('settings.landing_page.current.field_hero')}
</span>
<span class="line-clamp-1 text-right text-xs font-medium">{heroHeading}</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="ui:text-muted-foreground text-xs tracking-wider uppercase">
{$t('settings.landing_page.current.field_nav')}
</span>
<span class="text-xs font-medium">{navCountLabel}</span>
</div>
</Card.Content>
<Card.Footer class="mt-auto">
<Button
class="w-full justify-center"
href={customizeLocked ? undefined : customizeHref}
disabled={!$currentOrg.id}
onclick={customizeLocked ? () => openUpgradeModal() : undefined}
>
{#if customizeLocked}
<ZapIcon class="size-4" />
{/if}
{$t('settings.landing_page.current.customize')}
<ArrowRightIcon class="size-4" />
</Button>
</Card.Footer>
</Card.Root>
</div>
</section>
try {
await goto(resolve(getEditContentUrl(), {}));
} finally {
skipUnsavedPrompt = false;
}
}}
>
{$t('settings.landing_page.content.edit')}
</Button>
<section>
<div class="mb-6 flex items-start gap-4">
<div class="ui:bg-primary/10 ui:text-primary flex size-12 shrink-0 items-center justify-center rounded-lg">
<LayoutTemplateIcon class="size-6" />
</div>
<div>
<h2 class="text-lg font-semibold">{$t('settings.landing_page.gallery.heading')}</h2>
<p class="ui:text-muted-foreground text-sm">{$t('settings.landing_page.gallery.description')}</p>
</div>
</div>
<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-t-xl">
<img src={themeCard.preview} alt={$t(themeCard.titleKey)} class="h-50 w-full object-cover object-top" />
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{#each otherThemeCards as themeCard (themeCard.value)}
{@const isLocked = $isFreePlan && isPaidTheme(themeCard.value)}
<div class="ui:border-border bg-background relative overflow-hidden rounded-xl border">
{#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" />
{$t('settings.landing_page.gallery.upgrade')}
</div>
<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>
{/if}
<div class="bg-background aspect-[4/3] w-full overflow-hidden">
<img
src={themeCard.preview}
alt={$t(themeCard.titleKey)}
class="h-full w-full object-cover object-top"
loading="lazy"
/>
</div>
<div class="flex items-center justify-between gap-3 px-4 py-3">
<div class="min-w-0">
<p class="ui:text-primary truncate text-sm font-semibold">{$t(themeCard.titleKey)}</p>
<p class="ui:text-muted-foreground truncate text-xs">
{$t('settings.landing_page.gallery.by_classroomio')}
</p>
</div>
</button>
{/each}
</RadioGroup.Root>
</Field.Field>
</Field.Set>
</Field.Group>
<Button variant="outline" size="sm" onclick={() => handleAddTheme(themeCard.value)}>
{$t('settings.landing_page.gallery.add')}
</Button>
</div>
</div>
{/each}
</div>
</section>
</div>
@@ -2,6 +2,15 @@ import type { Writable } from 'svelte/store';
import { writable } from 'svelte/store';
import type { OrgLandingPageJson } from '$lib/utils/types/org';
import type { LandingSectionKey } from '@cio/ui/custom/org-landing-page';
import { createDefaultLandingPageSettings } from '$features/org/utils/landing-page';
export const landingPageSettings: Writable<OrgLandingPageJson> = writable(createDefaultLandingPageSettings());
/**
* Section currently selected in the landing-page editor preview. Shared between
* the left rail (which switches its panel based on this) and the preview wrappers
* (which render the outline + cap when their sectionKey matches). `null` means
* no section is selected — the editor shows the section list.
*/
export const landingPageEditorSelection: Writable<LandingSectionKey | null> = writable(null);