feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -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>
|
||||
|
||||
+15
-2
@@ -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}
|
||||
|
||||
+15
-2
@@ -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 ?? ''}
|
||||
|
||||
+385
-42
@@ -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>
|
||||
|
||||
+105
-3
@@ -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}
|
||||
|
||||
+265
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user