fix(widget): powered toggle persistence, copy buttons, version dates, showLessonsCount, color pickers, and loading spinner

This commit is contained in:
Rotimi Best
2026-06-13 11:24:20 +01:00
parent 094683b551
commit 0975f82c8b
14 changed files with 153 additions and 74 deletions
+11 -4
View File
@@ -23,7 +23,7 @@ import {
type TUpdateWidget type TUpdateWidget
} from '@cio/utils/validation/widget'; } from '@cio/utils/validation/widget';
import { getOrganizationById } from '@cio/db/queries/organization'; import { getActiveOrganizationPlan, getOrganizationById } from '@cio/db/queries/organization';
import { import {
buildWidgetPayload, buildWidgetPayload,
generateWidgetPublicKey, generateWidgetPublicKey,
@@ -48,13 +48,14 @@ export async function listOrganizationWidgets(orgId: string) {
export async function createOrganizationWidget(orgId: string, userId: string, data: TCreateWidget) { export async function createOrganizationWidget(orgId: string, userId: string, data: TCreateWidget) {
try { try {
const activePlan = await getActiveOrganizationPlan(orgId);
const widget = await createWidget({ const widget = await createWidget({
organizationId: orgId, organizationId: orgId,
name: data.name, name: data.name,
layoutType: data.layoutType, layoutType: data.layoutType,
selectionMode: data.selectionMode, selectionMode: data.selectionMode,
publicKey: generateWidgetPublicKey(), publicKey: generateWidgetPublicKey(),
config: normalizeWidgetConfig(data.config ?? getDefaultWidgetConfig()), config: normalizeWidgetConfig(data.config ?? getDefaultWidgetConfig(), activePlan?.planName ?? null),
createdByUserId: userId, createdByUserId: userId,
updatedByUserId: userId updatedByUserId: userId
}); });
@@ -132,7 +133,10 @@ export async function getOrganizationWidgetDetail(orgId: string, widgetId: strin
export async function updateOrganizationWidget(orgId: string, widgetId: string, userId: string, data: TUpdateWidget) { export async function updateOrganizationWidget(orgId: string, widgetId: string, userId: string, data: TUpdateWidget) {
try { try {
const existingWidget = await getWidgetById(orgId, widgetId); const [existingWidget, activePlan] = await Promise.all([
getWidgetById(orgId, widgetId),
getActiveOrganizationPlan(orgId)
]);
if (!existingWidget) { if (!existingWidget) {
throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404); throw new AppError('Widget not found', ErrorCodes.WIDGET_NOT_FOUND, 404);
} }
@@ -141,7 +145,10 @@ export async function updateOrganizationWidget(orgId: string, widgetId: string,
name: data.name ?? existingWidget.name, name: data.name ?? existingWidget.name,
layoutType: data.layoutType ?? existingWidget.layoutType, layoutType: data.layoutType ?? existingWidget.layoutType,
selectionMode: data.selectionMode ?? existingWidget.selectionMode, selectionMode: data.selectionMode ?? existingWidget.selectionMode,
config: normalizeWidgetConfig((data.config ?? existingWidget.config) as Record<string, unknown>), config: normalizeWidgetConfig(
(data.config ?? existingWidget.config) as Record<string, unknown>,
activePlan?.planName ?? null
),
hasUnpublishedChanges: true, hasUnpublishedChanges: true,
updatedByUserId: userId updatedByUserId: userId
}); });
+1
View File
@@ -43,6 +43,7 @@ export const handle: Handle = async (args) => {
const cookieString = event.cookies.getAll().map((c) => `${c.name}=${c.value}`); const cookieString = event.cookies.getAll().map((c) => `${c.name}=${c.value}`);
console.log('event.cookies', cookieString); console.log('event.cookies', cookieString);
console.log('page path', event.url.pathname);
const sessionData = await getSessionData(event.cookies); const sessionData = await getSessionData(event.cookies);
if (sessionData) { if (sessionData) {
@@ -49,14 +49,15 @@
checkoutURL.searchParams.set('products', isYearlyPlan ? plan.CTA.PRODUCT_ID_YEARLY : plan.CTA.PRODUCT_ID); checkoutURL.searchParams.set('products', isYearlyPlan ? plan.CTA.PRODUCT_ID_YEARLY : plan.CTA.PRODUCT_ID);
checkoutURL.searchParams.set('orgId', $currentOrg.id); checkoutURL.searchParams.set('orgId', $currentOrg.id);
window.location.href = checkoutURL.toString(); requestAnimationFrame(() => {
window.location.href = checkoutURL.toString();
});
} catch (error) { } catch (error) {
isLoadingPlan = null;
console.error('Error subscribing', error); console.error('Error subscribing', error);
snackbar.error('snackbar.upgrade.failed'); snackbar.error('snackbar.upgrade.failed');
} }
isLoadingPlan = null;
} }
function onUpgrade() { function onUpgrade() {
@@ -15,6 +15,8 @@
isDirty: boolean; isDirty: boolean;
layoutValidationError: string | null; layoutValidationError: string | null;
editingName: boolean; editingName: boolean;
saving: boolean;
publishing: boolean;
onSave: () => void; onSave: () => void;
onPublish: () => void; onPublish: () => void;
onDiscard: () => void; onDiscard: () => void;
@@ -29,6 +31,8 @@
isDirty, isDirty,
layoutValidationError, layoutValidationError,
editingName, editingName,
saving,
publishing,
onSave, onSave,
onPublish, onPublish,
onDiscard, onDiscard,
@@ -103,11 +107,12 @@
size="sm" size="sm"
type="button" type="button"
disabled={!isDirty || Boolean(layoutValidationError)} disabled={!isDirty || Boolean(layoutValidationError)}
loading={saving}
onclick={onSave} onclick={onSave}
> >
{$t('widgets.editor.save_changes')} {$t('widgets.editor.save_changes')}
</Button> </Button>
<Button size="sm" type="button" disabled={Boolean(layoutValidationError)} onclick={onPublish}> <Button size="sm" type="button" disabled={Boolean(layoutValidationError)} loading={publishing} onclick={onPublish}>
{$t('widgets.actions.publish')} {$t('widgets.actions.publish')}
</Button> </Button>
</div> </div>
@@ -34,6 +34,8 @@
isDirty={store.isDirty} isDirty={store.isDirty}
layoutValidationError={store.layoutValidationError} layoutValidationError={store.layoutValidationError}
editingName={store.editingName} editingName={store.editingName}
saving={store.saving}
publishing={store.publishing}
onSave={() => store.saveWidget()} onSave={() => store.saveWidget()}
onPublish={() => store.publishWidget()} onPublish={() => store.publishWidget()}
onDiscard={() => store.resetDraft()} onDiscard={() => store.resetDraft()}
@@ -45,33 +45,65 @@
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
</Field.Field> </Field.Field>
<InputField <Field.Field>
label={$t('widgets.form.primary_color')} <Field.Label>{$t('widgets.form.primary_color')}</Field.Label>
bind:value={draftConfig.colors.primaryColor} <div class="ui:flex ui:items-center ui:gap-2">
maxLength={64} <input
errorMessage={errFor('config.colors.primaryColor')} type="color"
/> bind:value={draftConfig.colors.primaryColor}
<InputField class="ui:h-9 ui:w-9 ui:cursor-pointer ui:rounded ui:border ui:bg-transparent ui:p-0.5"
label={$t('widgets.form.background_color')} />
bind:value={draftConfig.colors.backgroundColor} <span class="ui:text-muted-foreground ui:font-mono ui:text-sm">{draftConfig.colors.primaryColor}</span>
isDisabled={!canUseCustomColors} </div>
maxLength={64} {#if errFor('config.colors.primaryColor')}
errorMessage={errFor('config.colors.backgroundColor')} <Field.Error>{errFor('config.colors.primaryColor')}</Field.Error>
/> {/if}
<InputField </Field.Field>
label={$t('widgets.form.text_color')} <Field.Field>
bind:value={draftConfig.colors.textColor} <Field.Label>{$t('widgets.form.background_color')}</Field.Label>
isDisabled={!canUseCustomColors} <div class="ui:flex ui:items-center ui:gap-2">
maxLength={64} <input
errorMessage={errFor('config.colors.textColor')} type="color"
/> bind:value={draftConfig.colors.backgroundColor}
<InputField disabled={!canUseCustomColors}
label={$t('widgets.form.border_color')} class="ui:h-9 ui:w-9 ui:cursor-pointer ui:rounded ui:border ui:bg-transparent ui:p-0.5 disabled:ui:cursor-not-allowed disabled:ui:opacity-50"
bind:value={draftConfig.colors.borderColor} />
isDisabled={!canUseCustomColors} <span class="ui:text-muted-foreground ui:font-mono ui:text-sm">{draftConfig.colors.backgroundColor}</span>
maxLength={64} </div>
errorMessage={errFor('config.colors.borderColor')} {#if errFor('config.colors.backgroundColor')}
/> <Field.Error>{errFor('config.colors.backgroundColor')}</Field.Error>
{/if}
</Field.Field>
<Field.Field>
<Field.Label>{$t('widgets.form.text_color')}</Field.Label>
<div class="ui:flex ui:items-center ui:gap-2">
<input
type="color"
bind:value={draftConfig.colors.textColor}
disabled={!canUseCustomColors}
class="ui:h-9 ui:w-9 ui:cursor-pointer ui:rounded ui:border ui:bg-transparent ui:p-0.5 disabled:ui:cursor-not-allowed disabled:ui:opacity-50"
/>
<span class="ui:text-muted-foreground ui:font-mono ui:text-sm">{draftConfig.colors.textColor}</span>
</div>
{#if errFor('config.colors.textColor')}
<Field.Error>{errFor('config.colors.textColor')}</Field.Error>
{/if}
</Field.Field>
<Field.Field>
<Field.Label>{$t('widgets.form.border_color')}</Field.Label>
<div class="ui:flex ui:items-center ui:gap-2">
<input
type="color"
bind:value={draftConfig.colors.borderColor}
disabled={!canUseCustomColors}
class="ui:h-9 ui:w-9 ui:cursor-pointer ui:rounded ui:border ui:bg-transparent ui:p-0.5 disabled:ui:cursor-not-allowed disabled:ui:opacity-50"
/>
<span class="ui:text-muted-foreground ui:font-mono ui:text-sm">{draftConfig.colors.borderColor}</span>
</div>
{#if errFor('config.colors.borderColor')}
<Field.Error>{errFor('config.colors.borderColor')}</Field.Error>
{/if}
</Field.Field>
<InputField <InputField
type="number" type="number"
label={$t('widgets.form.border_radius')} label={$t('widgets.form.border_radius')}
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import { CopyButton } from '@cio/ui/base/copy-button';
import * as Tabs from '@cio/ui/base/tabs'; import * as Tabs from '@cio/ui/base/tabs';
import { TextareaField } from '@cio/ui/custom/textarea-field'; import { TextareaField } from '@cio/ui/custom/textarea-field';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
@@ -26,9 +27,9 @@
<Tabs.Content value="html" class="mt-4 space-y-3"> <Tabs.Content value="html" class="mt-4 space-y-3">
<TextareaField label={$t('widgets.form.embed_code')} value={detail.widget.embedCode} rows={6} readonly /> <TextareaField label={$t('widgets.form.embed_code')} value={detail.widget.embedCode} rows={6} readonly />
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<Button variant="outline" onclick={() => navigator.clipboard.writeText(detail.widget.embedCode)}> <CopyButton text={detail.widget.embedCode} variant="outline">
{$t('widgets.actions.copy_embed')} {$t('widgets.actions.copy_embed')}
</Button> </CopyButton>
</div> </div>
</Tabs.Content> </Tabs.Content>
@@ -36,9 +37,9 @@
<TextareaField label={$t('widgets.embed.url_label')} value={detail.widget.hostedEmbedUrl} rows={2} readonly /> <TextareaField label={$t('widgets.embed.url_label')} value={detail.widget.hostedEmbedUrl} rows={2} readonly />
<p class="ui:text-muted-foreground text-xs">{$t('widgets.embed.url_helper')}</p> <p class="ui:text-muted-foreground text-xs">{$t('widgets.embed.url_helper')}</p>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<Button variant="outline" onclick={() => navigator.clipboard.writeText(detail.widget.hostedEmbedUrl)}> <CopyButton text={detail.widget.hostedEmbedUrl} variant="outline">
{$t('widgets.actions.copy_url')} {$t('widgets.actions.copy_url')}
</Button> </CopyButton>
</div> </div>
</Tabs.Content> </Tabs.Content>
</Tabs.Root> </Tabs.Root>
@@ -50,7 +51,15 @@
<div class="ui:border-border ui:bg-card/50 flex items-center justify-between rounded-2xl border p-3"> <div class="ui:border-border ui:bg-card/50 flex items-center justify-between rounded-2xl border p-3">
<div> <div>
<div class="font-medium">v{version.version}</div> <div class="font-medium">v{version.version}</div>
<div class="ui:text-muted-foreground text-xs">{version.publishedAt}</div> <div class="ui:text-muted-foreground text-xs">
{new Date(version.publishedAt).toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})}
</div>
</div> </div>
<Button variant="outline" size="sm" onclick={() => onRollback(version.id)}> <Button variant="outline" size="sm" onclick={() => onRollback(version.id)}>
{$t('widgets.actions.restore')} {$t('widgets.actions.restore')}
@@ -21,6 +21,8 @@ class WidgetEditorStore {
selectedCourseIds = $state<string[]>([]); selectedCourseIds = $state<string[]>([]);
// ── UI state ─────────────────────────────────────────────────────────────── // ── UI state ───────────────────────────────────────────────────────────────
saving = $state(false);
publishing = $state(false);
activePanel = $state<PanelId>('select-courses'); activePanel = $state<PanelId>('select-courses');
editingName = $state(false); editingName = $state(false);
courseSearch = $state(''); courseSearch = $state('');
@@ -180,43 +182,53 @@ class WidgetEditorStore {
async saveWidget(silent = false): Promise<boolean> { async saveWidget(silent = false): Promise<boolean> {
if (!this.detail) return false; if (!this.detail) return false;
this.saving = true;
widgetApi.resetErrors(); widgetApi.resetErrors();
const updatedWidget = await widgetApi.updateWidget( try {
this.detail.widget.id, const updatedWidget = await widgetApi.updateWidget(
{ this.detail.widget.id,
name: this.draftName, {
layoutType: this.draftLayoutType, name: this.draftName,
selectionMode: this.draftSelectionMode, layoutType: this.draftLayoutType,
config: this.draftConfig, selectionMode: this.draftSelectionMode,
selectedCourseIds: this.selectedCourseIds config: this.draftConfig,
}, selectedCourseIds: this.selectedCourseIds
silent },
); silent
);
if (!updatedWidget) return false; if (!updatedWidget) return false;
await widgetApi.getWidget(this.detail.widget.id); await widgetApi.getWidget(this.detail.widget.id);
if (widgetApi.widgetDetail) { if (widgetApi.widgetDetail) {
this.applyDetail(widgetApi.widgetDetail); this.applyDetail(widgetApi.widgetDetail);
}
return true;
} finally {
this.saving = false;
} }
return true;
} }
async publishWidget() { async publishWidget() {
const saved = await this.saveWidget(true); this.publishing = true;
if (!saved || !this.detail) return; try {
const saved = await this.saveWidget(true);
if (!saved || !this.detail) return;
const published = await widgetApi.publishWidget(this.detail.widget.id); const published = await widgetApi.publishWidget(this.detail.widget.id);
if (!published) return; if (!published) return;
await widgetApi.getWidget(this.detail.widget.id); await widgetApi.getWidget(this.detail.widget.id);
if (widgetApi.widgetDetail) { if (widgetApi.widgetDetail) {
this.applyDetail(widgetApi.widgetDetail); this.applyDetail(widgetApi.widgetDetail);
}
this.activePanel = 'embed';
} finally {
this.publishing = false;
} }
this.activePanel = 'embed';
} }
async rollback(versionId: string) { async rollback(versionId: string) {
@@ -50,7 +50,7 @@
{:else} {:else}
<span></span> <span></span>
{/if} {/if}
<span class="cio-cg-meta">{formatLessonsLine(course)}</span> <span class="cio-cg-meta">{formatLessonsLine(course, payload.design.content.showLessonsCount)}</span>
</div> </div>
</div> </div>
</a> </a>
@@ -91,7 +91,7 @@
<TypeBadge courseType={course.courseType} /> <TypeBadge courseType={course.courseType} />
</div> </div>
{/if} {/if}
<div class="cio-cr-meta">{formatLessonsLine(course)}</div> <div class="cio-cr-meta">{formatLessonsLine(course, payload.design.content.showLessonsCount)}</div>
</div> </div>
</a> </a>
{/each} {/each}
@@ -3,6 +3,7 @@
import Stars from './atoms/stars.svelte'; import Stars from './atoms/stars.svelte';
import TypeBadge from './atoms/type-badge.svelte'; import TypeBadge from './atoms/type-badge.svelte';
import Thumb from './atoms/thumb.svelte'; import Thumb from './atoms/thumb.svelte';
import { formatLessonsLine } from './utils';
let { payload }: { payload: TWidgetPayload } = $props(); let { payload }: { payload: TWidgetPayload } = $props();
@@ -25,7 +26,7 @@
<div class="cio-cl-top"> <div class="cio-cl-top">
<span class="cio-cl-title">{course.title}</span> <span class="cio-cl-title">{course.title}</span>
{#if payload.design.content.showLessonsCount} {#if payload.design.content.showLessonsCount}
<span class="cio-cl-lessons">{course.lessonCount} lessons</span> <span class="cio-cl-lessons">{formatLessonsLine(course, payload.design.content.showLessonsCount)}</span>
{/if} {/if}
</div> </div>
<div class="cio-cl-mid"> <div class="cio-cl-mid">
@@ -22,15 +22,23 @@
if (!payload || !rootEl) return; if (!payload || !rootEl) return;
ensureWidgetGoogleFontsLoaded(rootEl, payload.design); ensureWidgetGoogleFontsLoaded(rootEl, payload.design);
}); });
$effect(() => {
if (!rootEl) return;
const existing = rootEl.querySelector<HTMLStyleElement>('[data-cio-widget-custom-css]');
if (existing) existing.remove();
if (!customCss) return;
const styleEl = document.createElement('style');
styleEl.setAttribute('data-cio-widget-custom-css', '');
styleEl.textContent = customCss;
rootEl.appendChild(styleEl);
});
</script> </script>
{#if !payload} {#if !payload}
<div class="cio-widget cio-widget--empty">No widget payload provided.</div> <div class="cio-widget cio-widget--empty">No widget payload provided.</div>
{:else} {:else}
<div bind:this={rootEl} class="cio-widget" style={cssVars} data-cio-layout={payload.layoutType}> <div bind:this={rootEl} class="cio-widget" style={cssVars} data-cio-layout={payload.layoutType}>
{#if customCss}
<svelte:element this={'style'} data-cio-widget-custom-css="">{customCss}</svelte:element>
{/if}
{#if payload.layoutType === 'card_grid'} {#if payload.layoutType === 'card_grid'}
<CardGrid {payload} /> <CardGrid {payload} />
{:else if payload.layoutType === 'tag_filter'} {:else if payload.layoutType === 'tag_filter'}
@@ -31,7 +31,7 @@
{/if} {/if}
<span class="cio-ph-sep">·</span> <span class="cio-ph-sep">·</span>
{/if} {/if}
<span class="cio-ph-meta">{formatLessonsLine(featured)}</span> <span class="cio-ph-meta">{formatLessonsLine(featured, payload.design.content.showLessonsCount)}</span>
</div> </div>
{#if payload.design.content.showCourseTypeBadge || featured.tags.length > 0} {#if payload.design.content.showCourseTypeBadge || featured.tags.length > 0}
<div class="cio-ph-badges"> <div class="cio-ph-badges">
@@ -56,7 +56,8 @@ export function getCourseExcerpt(course: TWidgetPayloadCourse, maxLength = 160):
return `${flat.slice(0, maxLength - 1).trim()}…`; return `${flat.slice(0, maxLength - 1).trim()}…`;
} }
export function formatLessonsLine(course: TWidgetPayloadCourse): string { export function formatLessonsLine(course: TWidgetPayloadCourse, showLessonsCount?: boolean): string {
if (showLessonsCount === false) return '';
const parts: string[] = []; const parts: string[] = [];
if (course.lessonCount) parts.push(`${course.lessonCount} lessons`); if (course.lessonCount) parts.push(`${course.lessonCount} lessons`);
if (course.exerciseCount) parts.push(`${course.exerciseCount} exercises`); if (course.exerciseCount) parts.push(`${course.exerciseCount} exercises`);