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