From 0f388ae0d31159ef2f591d3c27e984dbad580cf2 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Tue, 19 May 2026 16:32:42 +0100 Subject: [PATCH] feat: add hosted embed URL functionality and enhance widget embed options with tabbed interface --- apps/api/src/services/widget-payload.ts | 10 ++++ apps/api/src/services/widget.ts | 3 ++ .../ai-assistant/chat-message-list.svelte | 24 ++++++++-- .../features/widget/panels/embed-panel.svelte | 34 +++++++++++--- .../src/lib/utils/translations/da.json | 9 ++++ .../src/lib/utils/translations/de.json | 9 ++++ .../src/lib/utils/translations/en.json | 9 ++++ .../src/lib/utils/translations/es.json | 9 ++++ .../src/lib/utils/translations/fr.json | 9 ++++ .../src/lib/utils/translations/hi.json | 9 ++++ .../src/lib/utils/translations/pl.json | 9 ++++ .../src/lib/utils/translations/pt.json | 9 ++++ .../src/lib/utils/translations/ru.json | 9 ++++ .../src/lib/utils/translations/vi.json | 9 ++++ apps/embeds/package.json | 2 +- .../src/widgets/course-widget/embed.html | 47 +++++++++++++++++++ apps/embeds/vite.config.ts | 3 +- .../utils/src/validation/widget/widget.ts | 1 + 18 files changed, 203 insertions(+), 11 deletions(-) create mode 100644 apps/embeds/src/widgets/course-widget/embed.html diff --git a/apps/api/src/services/widget-payload.ts b/apps/api/src/services/widget-payload.ts index a8a4431b..90359627 100644 --- a/apps/api/src/services/widget-payload.ts +++ b/apps/api/src/services/widget-payload.ts @@ -171,6 +171,16 @@ export function getWidgetEmbedCode(publicKey: string): string { return `
\n`; } +export function getWidgetHostedEmbedUrl(publicKey: string): string { + const embedBaseUrl = ( + env.PUBLIC_EMBED_BASE_URL || + (env.NODE_ENV === 'development' ? DEFAULT_LOCAL_EMBED_BASE_URL : DEFAULT_EMBED_CDN_BASE_URL) + ).replace(/\/$/, ''); + const apiBaseUrl = getWidgetApiBaseUrl(); + + return `${embedBaseUrl}/embeds/course-widget/embed.html?key=${encodeURIComponent(publicKey)}&api=${encodeURIComponent(apiBaseUrl)}`; +} + type OrgCourse = Awaited>['items'][number]; export function formatCourseForWidget( diff --git a/apps/api/src/services/widget.ts b/apps/api/src/services/widget.ts index 4993e0f2..ef6b3afe 100644 --- a/apps/api/src/services/widget.ts +++ b/apps/api/src/services/widget.ts @@ -30,6 +30,7 @@ import { getCourseBaseUrl, getCourseWidgetScriptUrl, getWidgetEmbedCode, + getWidgetHostedEmbedUrl, listWidgetAvailableEditorData } from './widget-payload'; @@ -100,6 +101,7 @@ export async function getOrganizationWidgetDetail(orgId: string, widgetId: strin config: normalizeWidgetConfig(widget.config as Record, editorData.planName), selectedCourseIds: selectedCourses.map((course) => course.courseId), embedCode: getWidgetEmbedCode(widget.publicKey), + hostedEmbedUrl: getWidgetHostedEmbedUrl(widget.publicKey), publicScriptUrl: getCourseWidgetScriptUrl() }, organization: { @@ -227,6 +229,7 @@ export async function publishOrganizationWidget(orgId: string, widgetId: string, version, payload, embedCode: getWidgetEmbedCode(widget.publicKey), + hostedEmbedUrl: getWidgetHostedEmbedUrl(widget.publicKey), publicScriptUrl: getCourseWidgetScriptUrl() }; } catch (error) { diff --git a/apps/dashboard/src/lib/features/ai-assistant/chat-message-list.svelte b/apps/dashboard/src/lib/features/ai-assistant/chat-message-list.svelte index d040a8ae..ca9be2d3 100644 --- a/apps/dashboard/src/lib/features/ai-assistant/chat-message-list.svelte +++ b/apps/dashboard/src/lib/features/ai-assistant/chat-message-list.svelte @@ -66,6 +66,19 @@ let lastStepsCount = $state(0); let lastCurrentActionSig = $state(''); let lastStreamingSig = $state(0); + // True when the user is at (or very near) the bottom. Streaming auto-scroll is gated on this + // so scrolling up doesn't get clobbered by the next token. + let isPinnedToBottom = $state(true); + + const SCROLL_PIN_THRESHOLD = 60; + + function handleScroll() { + if (!messagesContainer) return; + + const distanceFromBottom = + messagesContainer.scrollHeight - messagesContainer.scrollTop - messagesContainer.clientHeight; + isPinnedToBottom = distanceFromBottom <= SCROLL_PIN_THRESHOLD; + } const isEmpty = $derived(messages.length === 0); @@ -101,6 +114,8 @@ if (messages.length === lastMessageCount) return; lastMessageCount = messages.length; + // A new message (typically the user's) — force scroll and re-pin. + isPinnedToBottom = true; scrollToBottom(); }); @@ -110,6 +125,9 @@ if (streamingContentSig === lastStreamingSig) return; lastStreamingSig = streamingContentSig; + + if (!isPinnedToBottom) return; + // Use 'auto' during streaming — smooth scrolls queue up per token and lag behind the cursor. scrollToBottom('auto'); }); @@ -120,7 +138,7 @@ const stepsCount = planExecutionState.steps.length; if (stepsCount !== lastStepsCount) { lastStepsCount = stepsCount; - scrollToBottom(); + if (isPinnedToBottom) scrollToBottom(); return; } @@ -128,12 +146,12 @@ if (actionSig !== lastCurrentActionSig) { lastCurrentActionSig = actionSig; - scrollToBottom(); + if (isPinnedToBottom) scrollToBottom(); } }); -
+
{#if isEmpty}
diff --git a/apps/dashboard/src/lib/features/widget/panels/embed-panel.svelte b/apps/dashboard/src/lib/features/widget/panels/embed-panel.svelte index bc7330c3..5db213d1 100644 --- a/apps/dashboard/src/lib/features/widget/panels/embed-panel.svelte +++ b/apps/dashboard/src/lib/features/widget/panels/embed-panel.svelte @@ -1,5 +1,6 @@
- -
- -
+ + + {$t('widgets.embed.tabs.html')} + {$t('widgets.embed.tabs.url')} + + + + +
+ +
+
+ + + +

{$t('widgets.embed.url_helper')}

+
+ +
+
+
{#if detail.versions.length > 0}
diff --git a/apps/dashboard/src/lib/utils/translations/da.json b/apps/dashboard/src/lib/utils/translations/da.json index 259ed745..6bb6241b 100644 --- a/apps/dashboard/src/lib/utils/translations/da.json +++ b/apps/dashboard/src/lib/utils/translations/da.json @@ -4010,9 +4010,18 @@ "archive": "Arkiv", "restore": "Gendan", "copy_embed": "Kopiér indlejring", + "copy_url": "Kopiér URL", "rename": "Omdøb", "delete": "Slet" }, + "embed": { + "tabs": { + "html": "HTML", + "url": "URL" + }, + "url_label": "Hostet embed-URL", + "url_helper": "Brug denne URL på platforme som Notion eller Confluence, der kun accepterer links." + }, "preview": { "heading": "Forhåndsvisning", "saved": "Gemt", diff --git a/apps/dashboard/src/lib/utils/translations/de.json b/apps/dashboard/src/lib/utils/translations/de.json index 9b9a65d5..188a00ec 100644 --- a/apps/dashboard/src/lib/utils/translations/de.json +++ b/apps/dashboard/src/lib/utils/translations/de.json @@ -4010,9 +4010,18 @@ "archive": "Archiv", "restore": "Wiederherstellen", "copy_embed": "Einbetten kopieren", + "copy_url": "URL kopieren", "rename": "Umbenennen", "delete": "Löschen" }, + "embed": { + "tabs": { + "html": "HTML", + "url": "URL" + }, + "url_label": "Gehostete Embed-URL", + "url_helper": "Verwenden Sie diese URL auf Plattformen wie Notion oder Confluence, die nur Links akzeptieren." + }, "preview": { "heading": "Vorschau", "saved": "Gespeichert", diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json index ae9c48e0..5188725c 100644 --- a/apps/dashboard/src/lib/utils/translations/en.json +++ b/apps/dashboard/src/lib/utils/translations/en.json @@ -1343,9 +1343,18 @@ "archive": "Archive", "restore": "Restore", "copy_embed": "Copy embed", + "copy_url": "Copy URL", "rename": "Rename", "delete": "Delete" }, + "embed": { + "tabs": { + "html": "HTML", + "url": "URL" + }, + "url_label": "Hosted embed URL", + "url_helper": "Use this URL on platforms like Notion or Confluence that only accept a link." + }, "create": { "description": "Give your widget a name to get started." }, diff --git a/apps/dashboard/src/lib/utils/translations/es.json b/apps/dashboard/src/lib/utils/translations/es.json index 5e519ea1..07de3b24 100644 --- a/apps/dashboard/src/lib/utils/translations/es.json +++ b/apps/dashboard/src/lib/utils/translations/es.json @@ -4010,9 +4010,18 @@ "archive": "Archivo", "restore": "Restaurar", "copy_embed": "Copiar incrustado", + "copy_url": "Copiar URL", "rename": "Cambiar nombre", "delete": "Eliminar" }, + "embed": { + "tabs": { + "html": "HTML", + "url": "URL" + }, + "url_label": "URL de incrustación alojada", + "url_helper": "Usa esta URL en plataformas como Notion o Confluence que solo aceptan enlaces." + }, "preview": { "heading": "Vista previa", "saved": "Guardado", diff --git a/apps/dashboard/src/lib/utils/translations/fr.json b/apps/dashboard/src/lib/utils/translations/fr.json index 6bf104a8..ec4c5c6f 100644 --- a/apps/dashboard/src/lib/utils/translations/fr.json +++ b/apps/dashboard/src/lib/utils/translations/fr.json @@ -4010,9 +4010,18 @@ "archive": "Archiver", "restore": "Restaurer", "copy_embed": "Copier l'intégration", + "copy_url": "Copier l'URL", "rename": "Renommer", "delete": "Supprimer" }, + "embed": { + "tabs": { + "html": "HTML", + "url": "URL" + }, + "url_label": "URL d'intégration hébergée", + "url_helper": "Utilisez cette URL sur des plateformes comme Notion ou Confluence qui n'acceptent que des liens." + }, "preview": { "heading": "Aperçu", "saved": "Enregistré", diff --git a/apps/dashboard/src/lib/utils/translations/hi.json b/apps/dashboard/src/lib/utils/translations/hi.json index 31d8f944..f7b367d0 100644 --- a/apps/dashboard/src/lib/utils/translations/hi.json +++ b/apps/dashboard/src/lib/utils/translations/hi.json @@ -4010,9 +4010,18 @@ "archive": "पुरालेख", "restore": "पुनर्स्थापित करें", "copy_embed": "एंबेड कॉपी करें", + "copy_url": "URL कॉपी करें", "rename": "नाम बदलें", "delete": "हटाएँ" }, + "embed": { + "tabs": { + "html": "HTML", + "url": "URL" + }, + "url_label": "होस्ट किया गया एम्बेड URL", + "url_helper": "Notion या Confluence जैसी प्लेटफ़ॉर्म पर इस URL का उपयोग करें जो केवल लिंक स्वीकार करती हैं।" + }, "preview": { "heading": "पूर्वावलोकन", "saved": "सहेजा गया", diff --git a/apps/dashboard/src/lib/utils/translations/pl.json b/apps/dashboard/src/lib/utils/translations/pl.json index a0d102bb..7333d8f4 100644 --- a/apps/dashboard/src/lib/utils/translations/pl.json +++ b/apps/dashboard/src/lib/utils/translations/pl.json @@ -4010,9 +4010,18 @@ "archive": "Archiwum", "restore": "Przywróć", "copy_embed": "Skopiuj osadzenie", + "copy_url": "Kopiuj URL", "rename": "Zmień nazwę", "delete": "Usuń" }, + "embed": { + "tabs": { + "html": "HTML", + "url": "URL" + }, + "url_label": "Hostowany URL osadzania", + "url_helper": "Użyj tego adresu URL na platformach takich jak Notion lub Confluence, które akceptują tylko linki." + }, "preview": { "heading": "Podgląd", "saved": "Zapisano", diff --git a/apps/dashboard/src/lib/utils/translations/pt.json b/apps/dashboard/src/lib/utils/translations/pt.json index 00efec64..a0a56121 100644 --- a/apps/dashboard/src/lib/utils/translations/pt.json +++ b/apps/dashboard/src/lib/utils/translations/pt.json @@ -4010,9 +4010,18 @@ "archive": "Arquivo", "restore": "Restaurar", "copy_embed": "Copiar incorporação", + "copy_url": "Copiar URL", "rename": "Renomear", "delete": "Excluir" }, + "embed": { + "tabs": { + "html": "HTML", + "url": "URL" + }, + "url_label": "URL de incorporação hospedada", + "url_helper": "Use esta URL em plataformas como Notion ou Confluence que só aceitam links." + }, "preview": { "heading": "Visualização", "saved": "Salvo", diff --git a/apps/dashboard/src/lib/utils/translations/ru.json b/apps/dashboard/src/lib/utils/translations/ru.json index 3b1c9ff6..a669bbd4 100644 --- a/apps/dashboard/src/lib/utils/translations/ru.json +++ b/apps/dashboard/src/lib/utils/translations/ru.json @@ -4010,9 +4010,18 @@ "archive": "Архив", "restore": "Восстановить", "copy_embed": "Копировать встроить", + "copy_url": "Копировать URL", "rename": "Переименовать", "delete": "Удалить" }, + "embed": { + "tabs": { + "html": "HTML", + "url": "URL" + }, + "url_label": "URL встраивания", + "url_helper": "Используйте этот URL на платформах вроде Notion или Confluence, которые принимают только ссылки." + }, "preview": { "heading": "Предварительный просмотр", "saved": "Сохранено", diff --git a/apps/dashboard/src/lib/utils/translations/vi.json b/apps/dashboard/src/lib/utils/translations/vi.json index 6e286126..b8e06cbc 100644 --- a/apps/dashboard/src/lib/utils/translations/vi.json +++ b/apps/dashboard/src/lib/utils/translations/vi.json @@ -4010,9 +4010,18 @@ "archive": "Lưu trữ", "restore": "Khôi phục", "copy_embed": "Sao chép nhúng", + "copy_url": "Sao chép URL", "rename": "Đổi tên", "delete": "Xóa" }, + "embed": { + "tabs": { + "html": "HTML", + "url": "URL" + }, + "url_label": "URL nhúng được lưu trữ", + "url_helper": "Sử dụng URL này trên các nền tảng như Notion hoặc Confluence chỉ chấp nhận liên kết." + }, "preview": { "heading": "Xem trước", "saved": "Đã lưu", diff --git a/apps/embeds/package.json b/apps/embeds/package.json index b1f66f6d..175120c3 100644 --- a/apps/embeds/package.json +++ b/apps/embeds/package.json @@ -7,7 +7,7 @@ "dev": "vite dev --port 5180", "prebuild": "pnpm --filter @cio/ui run build", "build:question-type-picker": "vite build --mode question-type-picker", - "build:course-widget": "vite build --mode course-widget", + "build:course-widget": "vite build --mode course-widget && cp src/widgets/course-widget/embed.html dist/course-widget/embed.html", "build": "rm -rf dist && pnpm build:question-type-picker && pnpm build:course-widget", "format": "prettier --write .", "preview": "vite preview --port 4180", diff --git a/apps/embeds/src/widgets/course-widget/embed.html b/apps/embeds/src/widgets/course-widget/embed.html new file mode 100644 index 00000000..ec3a585f --- /dev/null +++ b/apps/embeds/src/widgets/course-widget/embed.html @@ -0,0 +1,47 @@ + + + + + + + ClassroomIO Course Widget + + + +
+ + + diff --git a/apps/embeds/vite.config.ts b/apps/embeds/vite.config.ts index 275a0621..99bfbe2d 100644 --- a/apps/embeds/vite.config.ts +++ b/apps/embeds/vite.config.ts @@ -13,7 +13,8 @@ const embedEntries = { } as const; const embedDevEntrypoints = { '/embeds/question-type-picker/question-type-picker.js': '/src/widgets/question-type-picker/bootstrap.ts', - '/embeds/course-widget/course-widget.js': '/src/widgets/course-widget/bootstrap.ts' + '/embeds/course-widget/course-widget.js': '/src/widgets/course-widget/bootstrap.ts', + '/embeds/course-widget/embed.html': '/src/widgets/course-widget/embed.html' } as const; function embedDevEntryPlugin() { diff --git a/packages/utils/src/validation/widget/widget.ts b/packages/utils/src/validation/widget/widget.ts index ff507d32..20586e43 100644 --- a/packages/utils/src/validation/widget/widget.ts +++ b/packages/utils/src/validation/widget/widget.ts @@ -391,6 +391,7 @@ export const ZWidgetDetail = z.object({ config: ZWidgetConfig, selectedCourseIds: z.array(z.uuid()), embedCode: z.string(), + hostedEmbedUrl: z.string(), publicScriptUrl: z.string() }), organization: z.object({