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
@@ -5,8 +5,6 @@ import type {
DeleteWidgetRequest,
GetWidgetDetailRequest,
GetWidgetsRequest,
PreviewWidgetInput,
PreviewWidgetRequest,
PublishWidgetRequest,
RollbackWidgetRequest,
UpdateWidgetInput,
@@ -50,6 +48,7 @@ class WidgetApi extends BaseApiWithErrors {
const result = ZCreateWidget.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error);
snackbar.error('widgets.notifications.validation_failed');
return null;
}
@@ -72,6 +71,7 @@ class WidgetApi extends BaseApiWithErrors {
const result = ZUpdateWidget.safeParse(fields);
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error);
snackbar.error('widgets.notifications.validation_failed');
return null;
}
@@ -84,6 +84,7 @@ class WidgetApi extends BaseApiWithErrors {
logContext: 'updating widget',
onSuccess: (response) => {
if (!silent) snackbar.success('widgets.notifications.saved');
this.errors = {};
this.widgets = this.widgets.map((widget) => (widget.id === widgetId ? response.data : widget));
}
});
@@ -91,19 +92,6 @@ class WidgetApi extends BaseApiWithErrors {
return response?.data ?? null;
}
async previewWidget(widgetId: string, fields: PreviewWidgetInput) {
const response = await this.execute<PreviewWidgetRequest>({
requestFn: () =>
classroomio.organization.widgets[':widgetId'].preview.$post({
param: { widgetId },
json: fields
}),
logContext: 'previewing widget'
});
return response?.data ?? null;
}
async publishWidget(widgetId: string) {
return this.execute<PublishWidgetRequest>({
requestFn: () =>
@@ -1,7 +1,6 @@
<script lang="ts">
import type { WidgetDetail } from '../utils/types';
import { widgetEditorStore } from '../store/widget-editor.store.svelte';
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import { IconButton } from '@cio/ui/custom/icon-button';
import WidgetIframePreview from '../components/widget-iframe-preview.svelte';
@@ -21,12 +20,6 @@
$effect(() => store.syncFromInitial(initialDetail));
$effect(() => {
if (!store.detail || !$currentOrg.id) return;
return store.startPreviewEffect();
});
function navButtonVariant(id: typeof store.activePanel): 'default' | 'ghost' {
return store.activePanel === id ? 'default' : 'ghost';
}
@@ -146,6 +139,7 @@
canUseCustomColors={store.detail.planGatedFields.canUseCustomColors}
canUseCustomCss={store.detail.planGatedFields.canUseCustomCss}
isBrandingForced={store.detail.planGatedFields.isBrandingForced}
errors={store.validationErrors}
/>
{:else}
<EmbedPanel
@@ -14,6 +14,8 @@
canUseCustomColors: boolean;
canUseCustomCss: boolean;
isBrandingForced: boolean;
/** Zod-keyed validation errors from the last save attempt (e.g. `config.colors.primaryColor`). */
errors?: Record<string, string>;
}
let {
@@ -21,8 +23,11 @@
availableThemes,
canUseCustomColors,
canUseCustomCss,
isBrandingForced
isBrandingForced,
errors = {}
}: Props = $props();
const errFor = (path: string) => errors[path] ?? '';
</script>
<div class="space-y-6">
@@ -40,32 +45,58 @@
</Select.Content>
</Select.Root>
</Field.Field>
<InputField label={$t('widgets.form.primary_color')} bind:value={draftConfig.colors.primaryColor} />
<InputField
label={$t('widgets.form.primary_color')}
bind:value={draftConfig.colors.primaryColor}
maxLength={64}
errorMessage={errFor('config.colors.primaryColor')}
/>
<InputField
label={$t('widgets.form.background_color')}
bind:value={draftConfig.colors.backgroundColor}
isDisabled={!canUseCustomColors}
maxLength={64}
errorMessage={errFor('config.colors.backgroundColor')}
/>
<InputField
label={$t('widgets.form.text_color')}
bind:value={draftConfig.colors.textColor}
isDisabled={!canUseCustomColors}
maxLength={64}
errorMessage={errFor('config.colors.textColor')}
/>
<InputField
label={$t('widgets.form.border_color')}
bind:value={draftConfig.colors.borderColor}
isDisabled={!canUseCustomColors}
maxLength={64}
errorMessage={errFor('config.colors.borderColor')}
/>
<InputField
type="number"
label={$t('widgets.form.border_radius')}
bind:value={draftConfig.content.borderRadius}
min={0}
max={32}
step={1}
helperMessage={errFor('config.content.borderRadius') ? '' : $t('widgets.form.border_radius_hint')}
errorMessage={errFor('config.content.borderRadius')}
/>
<InputField
label={$t('widgets.form.font_family')}
bind:value={draftConfig.typography.fontFamily}
maxLength={120}
errorMessage={errFor('config.typography.fontFamily')}
/>
<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}
min={0.8}
max={1.4}
step={0.1}
helperMessage={errFor('config.typography.fontSizeScale') ? '' : $t('widgets.form.font_scale_hint')}
errorMessage={errFor('config.typography.fontSizeScale')}
/>
<Field.Field orientation="horizontal">
<Switch bind:checked={draftConfig.branding.showPoweredBy} disabled={isBrandingForced} />
@@ -75,8 +106,14 @@
label={$t('widgets.form.custom_css')}
bind:value={draftConfig.advanced.customCss}
rows={6}
maxlength={5000}
disabled={!canUseCustomCss}
helperMessage={!canUseCustomCss ? $t('widgets.form.custom_css_locked') : ''}
helperMessage={!canUseCustomCss
? $t('widgets.form.custom_css_locked')
: errFor('config.advanced.customCss')
? ''
: $t('widgets.form.custom_css_hint')}
errorMessage={errFor('config.advanced.customCss')}
/>
</Field.Set>
</Field.Group>
@@ -1,5 +1,4 @@
import { getDefaultWidgetConfig } from '@cio/utils/validation/widget';
import type { TWidgetPayload } from '@cio/utils/validation/widget';
import { buildWidgetPayload, getDefaultWidgetConfig } from '@cio/utils/validation/widget';
import type { WidgetDetail } from '../utils/types';
import { widgetApi } from '../api/widget.svelte';
import { deepPlain, buildCourseFilterPredicate, type FilterPublished } from '../utils/widget-editor-utils';
@@ -29,10 +28,6 @@ class WidgetEditorStore {
filterCourseType = $state('all');
filterTagSlugs = $state<string[]>([]);
// ── preview ────────────────────────────────────────────────────────────────
previewPayload = $state<TWidgetPayload | null>(null);
#previewRequestSeq = 0;
// ── sync guard ─────────────────────────────────────────────────────────────
#syncedDetailSignature = $state('');
@@ -88,6 +83,37 @@ class WidgetEditorStore {
(this.filterPublished !== 'all' ? 1 : 0) + (this.filterCourseType !== 'all' ? 1 : 0) + this.filterTagSlugs.length
);
/**
* Field-keyed validation errors from the last save attempt, keyed by Zod path
* (e.g. `config.typography.fontSizeScale`). Cleared when the user edits the draft
* or successfully saves.
*/
readonly validationErrors = $derived<Record<string, string>>(widgetApi.errors);
readonly previewPayload = $derived.by(() => {
if (!this.detail) return null;
try {
return buildWidgetPayload({
widget: {
id: this.detail.widget.id,
publicKey: this.detail.widget.publicKey,
layoutType: this.draftLayoutType,
selectionMode: this.draftSelectionMode,
config: deepPlain(this.draftConfig)
},
organization: this.detail.organization,
orgBaseUrl: this.detail.orgBaseUrl,
allCourses: this.detail.availableCourses,
selectedCourseIds: [...this.selectedCourseIds],
planGatedFields: this.detail.planGatedFields,
planName: this.detail.planName
});
} catch (error) {
console.warn('Failed to build widget preview payload:', error);
return null;
}
});
readonly isDirty = $derived(
this.detail !== null &&
JSON.stringify({
@@ -154,6 +180,8 @@ class WidgetEditorStore {
async saveWidget(silent = false): Promise<boolean> {
if (!this.detail) return false;
widgetApi.resetErrors();
const updatedWidget = await widgetApi.updateWidget(
this.detail.widget.id,
{
@@ -207,40 +235,6 @@ class WidgetEditorStore {
await widgetApi.deleteWidget(this.detail.widget.id);
goto(resolve(`${get(currentOrgPath)}/widgets`, {}));
}
/** Kicks off a debounced preview fetch. Call inside a `$effect`. */
startPreviewEffect() {
if (!this.detail) return;
// Subscribing to JSON.stringify forces deep reactivity on nested $state mutations.
void JSON.stringify({
config: this.draftConfig,
layoutType: this.draftLayoutType,
selectionMode: this.draftSelectionMode,
selectedCourseIds: this.selectedCourseIds
});
const snapshot = {
config: deepPlain(this.draftConfig),
layoutType: this.draftLayoutType,
selectionMode: this.draftSelectionMode,
selectedCourseIds: [...this.selectedCourseIds]
};
const widgetId = this.detail.widget.id;
const seq = ++this.#previewRequestSeq;
let cancelled = false;
void (async () => {
const payload = await widgetApi.previewWidget(widgetId, snapshot);
if (cancelled || seq !== this.#previewRequestSeq || !payload) return;
this.previewPayload = payload;
})();
return () => {
cancelled = true;
};
}
}
export const widgetEditorStore = new WidgetEditorStore();
@@ -1,7 +1,6 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
import type {
TCreateWidget,
TPreviewWidget,
TUpdateWidget,
TWidgetConfig,
TWidgetDetail,
@@ -12,7 +11,6 @@ export type GetWidgetsRequest = (typeof classroomio.organization)['widgets']['$g
export type GetWidgetDetailRequest = (typeof classroomio.organization)['widgets'][':widgetId']['$get'];
export type CreateWidgetRequest = (typeof classroomio.organization)['widgets']['$post'];
export type UpdateWidgetRequest = (typeof classroomio.organization)['widgets'][':widgetId']['$put'];
export type PreviewWidgetRequest = (typeof classroomio.organization)['widgets'][':widgetId']['preview']['$post'];
export type PublishWidgetRequest = (typeof classroomio.organization)['widgets'][':widgetId']['publish']['$post'];
export type RollbackWidgetRequest = (typeof classroomio.organization)['widgets'][':widgetId']['rollback']['$post'];
export type DeleteWidgetRequest = (typeof classroomio.organization)['widgets'][':widgetId']['$delete'];
@@ -31,4 +29,3 @@ export type WidgetConfig = TWidgetConfig;
export type WidgetPayload = TWidgetPayload;
export type CreateWidgetInput = TCreateWidget;
export type UpdateWidgetInput = TUpdateWidget;
export type PreviewWidgetInput = TPreviewWidget;