feat: widgets and ai chat

This commit is contained in:
Rotimi Best
2026-04-27 11:40:38 +01:00
parent ae1801f85f
commit 5717dd8004
391 changed files with 28177 additions and 22467 deletions
@@ -0,0 +1,83 @@
<script lang="ts">
import * as Field from '@cio/ui/base/field';
import * as Select from '@cio/ui/base/select';
import { Switch } from '@cio/ui/base/switch';
import { InputField } from '@cio/ui/custom/input-field';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { t } from '$lib/utils/functions/translations';
import type { WidgetDetail } from '../utils/types';
import type { WidgetConfig } from '../utils/types';
interface Props {
draftConfig: WidgetConfig;
availableThemes: WidgetDetail['planGatedFields']['availableThemes'];
canUseCustomColors: boolean;
canUseCustomCss: boolean;
isBrandingForced: boolean;
}
let {
draftConfig = $bindable(),
availableThemes,
canUseCustomColors,
canUseCustomCss,
isBrandingForced
}: Props = $props();
</script>
<div class="space-y-6">
<Field.Group>
<Field.Set>
<Field.Legend>{$t('widgets.editor.design')}</Field.Legend>
<Field.Field>
<Field.Label>{$t('widgets.form.theme_preset')}</Field.Label>
<Select.Root type="single" bind:value={draftConfig.themePreset}>
<Select.Trigger>{draftConfig.themePreset}</Select.Trigger>
<Select.Content>
{#each availableThemes as theme (theme)}
<Select.Item value={theme}>{theme}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</Field.Field>
<InputField label={$t('widgets.form.primary_color')} bind:value={draftConfig.colors.primaryColor} />
<InputField
label={$t('widgets.form.background_color')}
bind:value={draftConfig.colors.backgroundColor}
isDisabled={!canUseCustomColors}
/>
<InputField
label={$t('widgets.form.text_color')}
bind:value={draftConfig.colors.textColor}
isDisabled={!canUseCustomColors}
/>
<InputField
label={$t('widgets.form.border_color')}
bind:value={draftConfig.colors.borderColor}
isDisabled={!canUseCustomColors}
/>
<InputField
type="number"
label={$t('widgets.form.border_radius')}
bind:value={draftConfig.content.borderRadius}
/>
<InputField label={$t('widgets.form.font_family')} bind:value={draftConfig.typography.fontFamily} />
<InputField
type="number"
label={$t('widgets.form.font_scale')}
bind:value={draftConfig.typography.fontSizeScale}
/>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.branding.showPoweredBy} disabled={isBrandingForced} />
<Field.Label>{$t('widgets.form.show_powered_by')}</Field.Label>
</Field.Field>
<TextareaField
label={$t('widgets.form.custom_css')}
bind:value={draftConfig.advanced.customCss}
rows={6}
disabled={!canUseCustomCss}
helperMessage={!canUseCustomCss ? $t('widgets.form.custom_css_locked') : ''}
/>
</Field.Set>
</Field.Group>
</div>
@@ -0,0 +1,46 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { t } from '$lib/utils/functions/translations';
import type { WidgetDetail } from '../utils/types';
interface Props {
detail: WidgetDetail;
onRollback: (versionId: string) => void;
onDelete: () => void;
}
let { detail, onRollback, onDelete }: Props = $props();
</script>
<div class="space-y-6">
<TextareaField label={$t('widgets.form.embed_code')} value={detail.widget.embedCode} rows={6} readonly />
<div class="flex flex-wrap gap-2">
<Button variant="outline" onclick={() => navigator.clipboard.writeText(detail.widget.embedCode)}>
{$t('widgets.actions.copy_embed')}
</Button>
</div>
{#if detail.versions.length > 0}
<div class="space-y-2">
<h3 class="text-sm font-semibold">{$t('widgets.editor.version_history')}</h3>
{#each detail.versions as version (version.id)}
<div class="ui:border-border ui:bg-card/50 flex items-center justify-between rounded-2xl border p-3">
<div>
<div class="font-medium">v{version.version}</div>
<div class="ui:text-muted-foreground text-xs">{version.publishedAt}</div>
</div>
<Button variant="outline" size="sm" onclick={() => onRollback(version.id)}>
{$t('widgets.actions.restore')}
</Button>
</div>
{/each}
</div>
{/if}
<div class="ui:border-border border-t pt-4">
<Button variant="outline" class="w-full" onclick={onDelete}>
{$t('widgets.actions.archive')}
</Button>
</div>
</div>
@@ -0,0 +1,160 @@
<script lang="ts">
import * as Field from '@cio/ui/base/field';
import * as Select from '@cio/ui/base/select';
import * as Tooltip from '@cio/ui/base/tooltip';
import { Switch } from '@cio/ui/base/switch';
import { InputField } from '@cio/ui/custom/input-field';
import { cn } from '@cio/ui/tools';
import { t } from '$lib/utils/functions/translations';
import { WIDGET_LAYOUT_TYPE_VALUES } from '@cio/utils/validation/widget';
import WidgetLayoutThumbnail from '../components/widget-layout-thumbnail.svelte';
import LayoutOptions from '../components/layout-options.svelte';
import type { WidgetDetail } from '../utils/types';
import type { WidgetConfig } from '../utils/types';
interface Props {
draftLayoutType: WidgetDetail['widget']['layoutType'];
draftConfig: WidgetConfig;
featuredCourseOptions: { id: string; title: string }[];
tagOptions: WidgetDetail['availableTags'];
layoutValidationError: string | null;
onToggleCategoryTag: (tagId: string, checked: boolean) => void;
}
let {
draftLayoutType = $bindable(),
draftConfig = $bindable(),
featuredCourseOptions,
tagOptions,
layoutValidationError,
onToggleCategoryTag
}: Props = $props();
const layoutOptionIds = WIDGET_LAYOUT_TYPE_VALUES;
</script>
<div class="space-y-6">
<div>
<p class="ui:text-muted-foreground mb-2 text-xs font-medium tracking-wide uppercase">
{$t('widgets.form.layout_type')}
</p>
<Tooltip.Provider delayDuration={250}>
<div class="grid grid-cols-2 gap-3">
{#each layoutOptionIds as layoutId (layoutId)}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<div {...props} class={cn('space-y-1', props.class as string | undefined)}>
<p class="w-full text-sm">
{$t(`widgets.layout.${layoutId}`)}
</p>
<button
type="button"
class={cn(
'ui:bg-muted/40 group focus-visible:ui:ring-primary/40 flex w-full cursor-pointer flex-col overflow-hidden rounded-md border-2 p-2 text-left transition-all outline-none focus-visible:ring-2 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-zinc-950',
draftLayoutType === layoutId
? 'ui:border-primary shadow-sm'
: 'ui:border-border ui:hover:border-foreground/20'
)}
aria-label={$t(`widgets.layout.${layoutId}`)}
onclick={() => (draftLayoutType = layoutId)}
>
<WidgetLayoutThumbnail
layoutType={layoutId}
label={$t(`widgets.layout.${layoutId}`)}
objectFit="contain"
selected={draftLayoutType === layoutId}
class="h-auto"
/>
</button>
</div>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="right" sideOffset={16} class="ui:max-w-none h-50! w-60! p-3 text-left">
<div class="w-full space-y-1">
<p class="w-full text-center text-sm font-semibold">
{$t(`widgets.layout.${layoutId}`)}
</p>
<WidgetLayoutThumbnail
layoutType={layoutId}
label={$t(`widgets.layout.${layoutId}`)}
objectFit="contain"
class="ui:border-border ui:bg-card rounded-md border shadow-lg"
/>
</div>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div>
</Tooltip.Provider>
</div>
{#if layoutValidationError}
<div
class="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200"
>
{$t(layoutValidationError)}
</div>
{/if}
<Field.Group>
<Field.Set>
<Field.Legend>{$t('widgets.layoutOptions.section')}</Field.Legend>
<LayoutOptions {draftLayoutType} bind:draftConfig {featuredCourseOptions} {tagOptions} {onToggleCategoryTag} />
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('widgets.editor.layout')}</Field.Legend>
<Field.Field>
<Field.Label>{$t('widgets.form.sort_by')}</Field.Label>
<Select.Root type="single" bind:value={draftConfig.sortBy}>
<Select.Trigger>{$t(`widgets.sort.${draftConfig.sortBy}`)}</Select.Trigger>
<Select.Content>
<Select.Item value="manual">{$t('widgets.sort.manual')}</Select.Item>
<Select.Item value="newest">{$t('widgets.sort.newest')}</Select.Item>
<Select.Item value="title">{$t('widgets.sort.title')}</Select.Item>
</Select.Content>
</Select.Root>
</Field.Field>
<InputField label={$t('widgets.form.cta_label')} bind:value={draftConfig.content.ctaLabel} />
<InputField label={$t('widgets.form.load_more_label')} bind:value={draftConfig.content.loadMoreLabel} />
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('widgets.editor.content')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.content.showCourseImage} />
<Field.Label>{$t('widgets.form.show_course_image')}</Field.Label>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.content.showCourseTypeBadge} />
<Field.Label>{$t('widgets.form.show_type_badge')}</Field.Label>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.content.showLessonsCount} />
<Field.Label>{$t('widgets.form.show_lessons_count')}</Field.Label>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.content.showDescriptionExcerpt} />
<Field.Label>{$t('widgets.form.show_description')}</Field.Label>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.content.shortenLongDescription} />
<Field.Label>{$t('widgets.form.shorten_long_description')}</Field.Label>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.content.showPrice} />
<Field.Label>{$t('widgets.form.show_price')}</Field.Label>
</Field.Field>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.content.showRating} />
<Field.Label>{$t('widgets.form.show_rating')}</Field.Label>
</Field.Field>
</Field.Set>
</Field.Group>
</div>
@@ -0,0 +1,152 @@
<script lang="ts">
import * as Field from '@cio/ui/base/field';
import * as Select from '@cio/ui/base/select';
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
import { Search } from '@cio/ui/custom/search';
import { CheckboxOptionCardGroup, type CheckboxOptionCardOption } from '@cio/ui/custom/checkbox-option-card';
import { CourseTagsOverflow } from '$features/course/components';
import CourseFilterPopover from '../components/course-filter-popover.svelte';
import { t } from '$lib/utils/functions/translations';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import type { WidgetDetail } from '../utils/types';
import type { FilterPublished } from '../utils/widget-editor-utils';
interface Props {
detail: WidgetDetail;
draftSelectionMode: WidgetDetail['widget']['selectionMode'];
selectedCourseIds: string[];
courseSearch: string;
filterPublished: FilterPublished;
filterCourseType: string;
filterTagSlugs: string[];
filteredCourses: WidgetDetail['availableCourses'];
activeFilterCount: number;
onContinueToLayout: () => void;
onClearFilters: () => void;
}
let {
detail,
draftSelectionMode = $bindable(),
selectedCourseIds = $bindable(),
courseSearch = $bindable(),
filterPublished = $bindable(),
filterCourseType = $bindable(),
filterTagSlugs = $bindable(),
filteredCourses,
activeFilterCount,
onContinueToLayout,
onClearFilters
}: Props = $props();
const courseTypeLabels = $derived<Record<string, string>>({
LIVE_CLASS: $t('new_course_modal.live_class_label'),
SELF_PACED: $t('new_course_modal.self_paced_label'),
COMPLIANCE: $t('new_course_modal.compliance_label')
});
const courseTypeOptions = $derived([
{ value: 'LIVE_CLASS', label: $t('new_course_modal.live_class_label') },
{ value: 'SELF_PACED', label: $t('new_course_modal.self_paced_label') },
{ value: 'COMPLIANCE', label: $t('new_course_modal.compliance_label') }
]);
const courseOptions = $derived<CheckboxOptionCardOption[]>(
filteredCourses.map((course) => ({
id: course.id,
title: course.title,
value: course.id
}))
);
</script>
<div class="space-y-4">
<Button type="button" class="ui:justify-between w-full" onclick={onContinueToLayout}>
{$t('widgets.editor.continue_to_layout')}
<ChevronRightIcon class="size-4" />
</Button>
<Field.Group>
<Field.Set>
<Field.Legend>{$t('widgets.editor.sources')}</Field.Legend>
<Field.Field>
<Field.Label>{$t('widgets.form.selection_mode')}</Field.Label>
<Select.Root type="single" bind:value={draftSelectionMode}>
<Select.Trigger>
{draftSelectionMode === 'manual' ? $t('widgets.selection.manual') : $t('widgets.selection.published')}
</Select.Trigger>
<Select.Content>
<Select.Item value="manual">{$t('widgets.selection.manual')}</Select.Item>
<Select.Item value="published">{$t('widgets.selection.published')}</Select.Item>
</Select.Content>
</Select.Root>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('widgets.editor.available_courses')}</Field.Legend>
<div class="flex items-center gap-2">
<Search class="ui:max-w-none flex-1" placeholder={$t('widgets.form.course_search')} bind:value={courseSearch} />
<CourseFilterPopover
{activeFilterCount}
{filterPublished}
{filterCourseType}
{filterTagSlugs}
availableTags={detail.availableTags}
{courseTypeOptions}
onClearAll={onClearFilters}
onFilterPublishedChange={(val) => (filterPublished = val)}
onFilterCourseTypeChange={(val) => (filterCourseType = val)}
onFilterTagSlugsChange={(slugs) => (filterTagSlugs = slugs)}
/>
</div>
{#if courseOptions.length === 0}
<p class="ui:text-muted-foreground px-1 py-6 text-center text-xs">
{$t('widgets.preview.empty')}
</p>
{:else}
<CheckboxOptionCardGroup options={courseOptions} bind:value={selectedCourseIds} class="ui:grid-cols-1">
{#snippet titleSuffix(option)}
{@const course = filteredCourses.find((entry) => entry.id === option.id)}
{#if course?.courseType && courseTypeLabels[course.courseType]}
<Badge variant="outline" class="shrink-0 text-[10px]">{courseTypeLabels[course.courseType]}</Badge>
{/if}
{/snippet}
{#snippet footer(option)}
{@const course = filteredCourses.find((entry) => entry.id === option.id)}
{#if course}
<div class="space-y-1.5">
<div class="ui:text-muted-foreground flex items-center gap-2 text-xs">
{#if course.isPublished}
<span class="inline-flex items-center gap-1 font-medium text-emerald-600">
<span class="h-1.5 w-1.5 rounded-full bg-emerald-500"></span>
{$t('widgets.status.live')}
</span>
{:else}
<span class="ui:text-muted-foreground inline-flex items-center gap-1">
<span class="ui:bg-muted-foreground/50 h-1.5 w-1.5 rounded-full"></span>
{$t('widgets.filter.unpublished')}
</span>
{/if}
<span class="ui:text-muted-foreground/50">·</span>
<span>{course.lessonCount} {$t('widgets.preview.lessons_suffix')}</span>
{#if course.exerciseCount > 0}
<span class="ui:text-muted-foreground/50">·</span>
<span>{course.exerciseCount} {$t('widgets.preview.exercises_suffix')}</span>
{/if}
</div>
{#if course.tags.length > 0}
<CourseTagsOverflow tags={course.tags} variant="card" />
{/if}
</div>
{/if}
{/snippet}
</CheckboxOptionCardGroup>
{/if}
</Field.Set>
</Field.Group>
</div>