feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user