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
+58
View File
@@ -128,3 +128,61 @@ pnpm build && npx serve . -p 5190
1. Add `src/widgets/<name>/widget.svelte` and a bootstrap entry (same pattern as `question-type-picker`).
2. Add a dedicated `build.lib.entry` or Vite config if you need a separate publishable script.
## Course widget host split
The course widget uses three different URL concerns:
1. **Embed script host**
- this is where the browser downloads `course-widget.js`
- production example: `https://assets.cdn.clsrio.com/embeds/course-widget/course-widget.js`
- configured on the API side with `PUBLIC_EMBED_BASE_URL`
2. **Payload API host**
- this is where the widget fetches `GET /widgets/:publicKey/payload`
- it reuses the existing API public URL via `PUBLIC_SERVER_URL`
- the generated embed snippet includes that host as `data-api-base-url`
3. **Course destination host**
- this is where end users are sent when they click a course
- it comes from the organization public site or custom domain, not the API host
These are intentionally separate. The widget fetches data from the API host, but links users to the organization’s public course pages.
## Course widget local development
Local development should not depend on deployed CDN assets.
### Expected local hosts
- embed script host: `PUBLIC_EMBED_BASE_URL`
- payload API host: existing `PUBLIC_SERVER_URL`
When `PUBLIC_EMBED_BASE_URL` is unset and the API runs in development, widget embed code falls back to:
```text
http://localhost:5180
```
That matches the `@cio/embeds` Vite dev server.
When `PUBLIC_SERVER_URL` is set to your local API URL, the generated snippet will look like:
```html
<div data-cio-widget="course-widget" data-widget-key="wgt_xxx" data-api-base-url="http://localhost:3002"></div>
<script async type="module" src="http://localhost:5180/embeds/course-widget/course-widget.js"></script>
```
### Dev server path shape
The embeds Vite dev server rewrites these stable production-style paths:
- `/embeds/question-type-picker/question-type-picker.js`
- `/embeds/course-widget/course-widget.js`
to the underlying source entry files during development.
That means dashboard-generated widget embed snippets can use the same path shape in local development and production without requiring the CDN to exist first.
+10 -2
View File
@@ -13,12 +13,20 @@
}
</style>
</head>
<body>
<p style="max-width: 56rem; margin: 0 auto 1rem; color: #52525b; font-size: 0.875rem">
<!-- <p style="max-width: 56rem; margin: 0 auto 1rem; color: #52525b; font-size: 0.875rem">
Serve this directory over HTTP (e.g. <code>npx serve .</code> from <code>apps/embeds</code> after
<code>pnpm build</code>). File URLs will not load the script correctly.
</p>
<div style="max-width: 56rem; margin: 0 auto" data-cio-widget="question-type-picker"></div>
<script type="module" src="./dist/question-type-picker.js"></script>
<script type="module" src="./dist/question-type-picker.js"></script> -->
<div
data-cio-widget="course-widget"
data-widget-key="wgt_UG2yRrPmQC8mbKLL"
data-api-base-url="http://localhost:3002"
></div>
<script async type="module" src="http://localhost:5180/embeds/course-widget/course-widget.js"></script>
</body>
</html>
+4 -1
View File
@@ -6,7 +6,9 @@
"scripts": {
"dev": "vite dev --port 5180",
"prebuild": "pnpm --filter @cio/ui run build",
"build": "rm -rf dist && vite build",
"build:question-type-picker": "vite build --mode question-type-picker",
"build:course-widget": "vite build --mode course-widget",
"build": "rm -rf dist && pnpm build:question-type-picker && pnpm build:course-widget",
"format": "prettier --write .",
"preview": "vite preview --port 4180",
"upload:embeds": "tsx scripts/upload-embeds.ts",
@@ -26,6 +28,7 @@
},
"dependencies": {
"@cio/ui": "workspace:*",
"@cio/utils": "workspace:*",
"@cio/question-types": "workspace:*",
"@lucide/svelte": "^0.562.0"
}
+53 -35
View File
@@ -16,8 +16,16 @@ import { readFileSync, readdirSync, statSync } from 'node:fs';
const BUCKET = 'assets';
/** R2 object key prefix (no trailing slash). */
const EMBED_WIDGET_PREFIX = 'embeds/question-type-picker';
const EMBED_WIDGET_TARGETS = [
{
buildDirName: 'question-type-picker',
prefix: 'embeds/question-type-picker'
},
{
buildDirName: 'course-widget',
prefix: 'embeds/course-widget'
}
] as const;
/** Cloudflare allows a limited number of URLs per purge request. */
const PURGE_URL_BATCH = 30;
@@ -88,10 +96,10 @@ async function purgeCloudflareCdnForUrls(urls: string[]): Promise<void> {
class EmbedsUploader {
private s3Client: S3Client | null = null;
private readonly buildDir: string;
private readonly buildRootDir: string;
constructor() {
this.buildDir = join(process.cwd(), 'dist');
this.buildRootDir = join(process.cwd(), 'dist');
this.initializeS3Client();
}
@@ -138,7 +146,7 @@ class EmbedsUploader {
if (filePath.match(/\.(js|mjs)$/)) {
const baseName = filePath.split(/[/\\]/).pop() ?? filePath;
// Stable bootstrap URL must revalidate quickly so new chunk hashes roll out after deploy.
if (baseName === 'question-type-picker.js') {
if (baseName === 'question-type-picker.js' || baseName === 'course-widget.js') {
return 'public, max-age=300, must-revalidate';
}
return 'public, max-age=31536000, immutable';
@@ -167,17 +175,17 @@ class EmbedsUploader {
return files;
}
async uploadFile(relativePath: string): Promise<void> {
async uploadFile(targetPrefix: string, buildDir: string, relativePath: string): Promise<void> {
if (!this.s3Client) {
throw new Error('S3 client not initialized');
}
const fullPath = join(this.buildDir, relativePath);
const fullPath = join(buildDir, relativePath);
const content = readFileSync(fullPath);
const contentType = this.getContentType(relativePath);
const cacheControl = this.getCacheControl(relativePath);
const key = `${EMBED_WIDGET_PREFIX}/${relativePath.split('\\').join('/')}`;
const normalizedRelativePath = relativePath.split('\\').join('/');
const key = `${targetPrefix}/${normalizedRelativePath}`;
const command = new PutObjectCommand({
Bucket: BUCKET,
Key: key,
@@ -191,36 +199,45 @@ class EmbedsUploader {
}
async uploadAll(): Promise<void> {
try {
statSync(this.buildDir);
} catch {
throw new Error(`Build directory not found: ${this.buildDir}. Run pnpm build in apps/embeds first.`);
}
console.log('Uploading embeds to R2...');
console.log(`Build directory: ${this.buildDir}`);
console.log(`Key prefix: ${EMBED_WIDGET_PREFIX}/`);
const files = this.getAllFiles(this.buildDir);
if (files.length === 0) {
throw new Error('No files in dist/. Run pnpm build first.');
}
console.log(`Found ${files.length} files to upload`);
console.log(`Build root directory: ${this.buildRootDir}`);
const purgeUrls: string[] = [];
const publicBase = normalizePublicBaseUrl(process.env.EMBED_CDN_BASE_URL);
const batchSize = 10;
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
await Promise.all(batch.map((file) => this.uploadFile(file)));
console.log(`Batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(files.length / batchSize)}`);
for (const target of EMBED_WIDGET_TARGETS) {
const buildDir = join(this.buildRootDir, target.buildDirName);
try {
statSync(buildDir);
} catch {
throw new Error(`Build directory not found: ${buildDir}. Run pnpm build in apps/embeds first.`);
}
console.log(`Target prefix: ${target.prefix}`);
const files = this.getAllFiles(buildDir);
if (files.length === 0) {
throw new Error(`No files found in ${buildDir}. Run pnpm build first.`);
}
console.log(`Found ${files.length} file(s) for ${target.buildDirName}`);
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
await Promise.all(batch.map((file) => this.uploadFile(target.prefix, buildDir, file)));
console.log(
`Batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(files.length / batchSize)} for ${target.buildDirName}`
);
}
purgeUrls.push(
...files.map((relativePath) => `${publicBase}/${target.prefix}/${relativePath.split('\\').join('/')}`)
);
}
console.log('Embeds upload completed.');
const publicBase = normalizePublicBaseUrl(process.env.EMBED_CDN_BASE_URL);
const purgeUrls = files.map(
(relativePath) => `${publicBase}/${EMBED_WIDGET_PREFIX}/${relativePath.split('\\').join('/')}`
);
await purgeCloudflareCdnForUrls(purgeUrls);
}
}
@@ -232,7 +249,8 @@ async function main(): Promise<void> {
console.log(`
Usage: tsx scripts/upload-embeds.ts
Uploads apps/embeds/dist/* to R2 under ${EMBED_WIDGET_PREFIX}/
Uploads widget-specific embed builds under:
${EMBED_WIDGET_TARGETS.map((target) => ` - dist/${target.buildDirName} -> ${target.prefix}`).join('\n')}
Environment (same as Storybook R2 upload):
CLOUDFLARE_ACCESS_KEY
@@ -264,4 +282,4 @@ if (isMain) {
});
}
export { EmbedsUploader, EMBED_WIDGET_PREFIX };
export { EmbedsUploader, EMBED_WIDGET_TARGETS };
@@ -0,0 +1,127 @@
const WIDGET_ATTR = 'data-cio-widget';
const WIDGET_VALUE = 'course-widget';
const WIDGET_KEY_ATTR = 'data-widget-key';
const API_BASE_ATTR = 'data-api-base-url';
const LOADING_CLASS = 'cio-embed-loading';
const EMBED_MIN_HEIGHT_PX = 360;
export type CourseWidgetEmbedApi = {
mount: (element: HTMLElement, options: { publicKey: string; apiBaseUrl?: string }) => { destroy: () => void };
};
declare global {
interface Window {
CIO?: {
apiBaseUrl?: string;
courseWidget?: CourseWidgetEmbedApi;
};
}
}
let stylesInjected = false;
function injectSpinnerStyles(): void {
if (stylesInjected) return;
stylesInjected = true;
const css = `
@keyframes cio-embed-spin { to { transform: rotate(360deg); } }
.${LOADING_CLASS} {
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-height: ${EMBED_MIN_HEIGHT_PX}px;
padding: 1rem;
color: oklch(0.556 0 0);
}
.${LOADING_CLASS} svg {
animation: cio-embed-spin 0.8s linear infinite;
flex-shrink: 0;
}
`;
const styleEl = document.createElement('style');
styleEl.setAttribute('data-cio-embed', 'course-widget-loading');
styleEl.textContent = css;
document.head.appendChild(styleEl);
}
const LOADER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 2v4"/><path d="m16.24 7.76 2.83-2.83"/><path d="M22 12h-4"/><path d="m16.24 16.24 2.83 2.83"/><path d="M12 22v-4"/><path d="m7.76 16.24-2.83 2.83"/><path d="M2 12h4"/><path d="m7.76 7.76-2.83-2.83"/></svg>`;
function renderLoading(container: HTMLElement): void {
injectSpinnerStyles();
const root = document.createElement('div');
root.className = LOADING_CLASS;
root.setAttribute('role', 'status');
root.setAttribute('aria-live', 'polite');
root.setAttribute('aria-label', 'Loading');
root.innerHTML = LOADER_SVG;
container.replaceChildren(root);
}
function collectTargets(): Array<{ element: HTMLElement; publicKey: string; apiBaseUrl?: string }> {
return [...document.querySelectorAll<HTMLElement>(`[${WIDGET_ATTR}="${WIDGET_VALUE}"]`)]
.map((element) => ({
element,
publicKey: element.getAttribute(WIDGET_KEY_ATTR) ?? '',
apiBaseUrl: element.getAttribute(API_BASE_ATTR) ?? window.CIO?.apiBaseUrl
}))
.filter((target) => Boolean(target.publicKey));
}
function scheduleMount(targets: Array<{ element: HTMLElement; publicKey: string; apiBaseUrl?: string }>): void {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
void import('./embed-mount.ts').then((mod) => {
mod.mountAllAuto(targets);
});
});
});
}
function manualMount(
element: HTMLElement,
options: { publicKey: string; apiBaseUrl?: string }
): { destroy: () => void } {
renderLoading(element);
let cancelled = false;
let destroyWidget: (() => void) | null = null;
void import('./embed-mount.ts').then((mod) => {
if (cancelled) return;
destroyWidget = mod.mountWidget(element, options).destroy;
});
return {
destroy: () => {
cancelled = true;
destroyWidget?.();
element.replaceChildren();
}
};
}
function init(): void {
const targets = collectTargets();
for (const target of targets) {
renderLoading(target.element);
}
window.CIO = window.CIO ?? {};
window.CIO.courseWidget = {
mount: manualMount
};
scheduleMount(targets);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
@@ -0,0 +1,51 @@
import { mount, unmount } from 'svelte';
import { widgetStyles } from '@cio/ui/custom/widget-layouts';
import Widget from './widget.svelte';
import shellCss from './widget.css?raw';
function createShadowHost(target: HTMLElement): { shadowRoot: ShadowRoot; mountPoint: HTMLElement } {
target.replaceChildren();
const host = document.createElement('cio-course-widget');
target.appendChild(host);
const shadowRoot = host.attachShadow({ mode: 'open' });
const styleElement = document.createElement('style');
styleElement.textContent = `${shellCss}\n${widgetStyles}`;
shadowRoot.appendChild(styleElement);
const mountPoint = document.createElement('div');
shadowRoot.appendChild(mountPoint);
return { shadowRoot, mountPoint };
}
export function mountWidget(
target: HTMLElement,
options: { publicKey: string; apiBaseUrl?: string }
): { destroy: () => void } {
const { mountPoint } = createShadowHost(target);
const instance = mount(Widget, {
target: mountPoint,
props: options
});
return {
destroy: () => {
unmount(instance);
target.replaceChildren();
}
};
}
export function mountAllAuto(targets: Array<{ element: HTMLElement; publicKey: string; apiBaseUrl?: string }>): void {
for (const target of targets) {
mountWidget(target.element, {
publicKey: target.publicKey,
apiBaseUrl: target.apiBaseUrl
});
}
}
@@ -0,0 +1,44 @@
:host {
all: initial;
display: block;
width: 100%;
}
.cio-widget-shell {
font-family: 'DM Sans', Inter, ui-sans-serif, system-ui, sans-serif;
color: rgba(15, 23, 42, 0.8);
background: transparent;
display: block;
width: 100%;
box-sizing: border-box;
padding: 16px;
}
.cio-widget-loading,
.cio-widget-error {
min-height: 320px;
display: flex;
align-items: center;
justify-content: center;
}
.cio-widget-error p {
margin: 0;
font-size: 14px;
color: #b91c1c;
}
.cio-widget-spinner {
width: 24px;
height: 24px;
border: 2px solid rgba(15, 23, 42, 0.1);
border-top-color: rgba(15, 23, 42, 0.6);
border-radius: 999px;
animation: cio-widget-spin 0.8s linear infinite;
}
@keyframes cio-widget-spin {
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,47 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { TWidgetPayload } from '@cio/utils/validation/widget';
import { CourseWidget } from '@cio/ui/custom/widget-layouts';
const DEFAULT_API_BASE_URL = 'https://api.classroomio.com';
let { publicKey, apiBaseUrl = DEFAULT_API_BASE_URL }: { publicKey: string; apiBaseUrl?: string } = $props();
let payload = $state<TWidgetPayload | null>(null);
let errorMessage = $state('');
async function loadPayload() {
try {
errorMessage = '';
const response = await fetch(
`${(apiBaseUrl || DEFAULT_API_BASE_URL).replace(/\/$/, '')}/widgets/${publicKey}/payload`
);
const result = (await response.json()) as { success?: boolean; data?: TWidgetPayload; error?: string };
if (!response.ok || !result.success || !result.data) {
throw new Error(result.error || 'Failed to load widget');
}
payload = result.data;
} catch (error) {
errorMessage = error instanceof Error ? error.message : 'Failed to load widget';
}
}
onMount(() => {
void loadPayload();
});
</script>
{#if errorMessage}
<div class="cio-widget-shell cio-widget-error">
<p>{errorMessage}</p>
</div>
{:else if !payload}
<div class="cio-widget-shell cio-widget-loading">
<div class="cio-widget-spinner" aria-hidden="true"></div>
</div>
{:else}
<CourseWidget {payload} />
{/if}
+75 -37
View File
@@ -7,44 +7,82 @@ import { defineConfig } from 'vite';
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
const dirname = path.dirname(fileURLToPath(import.meta.url));
const embedEntries = {
'question-type-picker': path.resolve(dirname, 'src/widgets/question-type-picker/bootstrap.ts'),
'course-widget': path.resolve(dirname, 'src/widgets/course-widget/bootstrap.ts')
} 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'
} as const;
export default defineConfig(({ command }) => ({
root: dirname,
plugins: [tailwindcss(), svelte(), cssInjectedByJsPlugin()],
resolve: {
alias: {
'@cio/ui': path.resolve(dirname, 'node_modules/@cio/ui/src'),
'@cio/ui/*': path.resolve(dirname, 'node_modules/@cio/ui/src/*'),
$src: path.resolve(dirname, 'node_modules/@cio/ui/src')
function embedDevEntryPlugin() {
return {
name: 'embed-dev-entry-plugin',
configureServer(server: {
middlewares: { use: (handler: (req: { url?: string }, res: unknown, next: () => void) => void) => void };
}) {
server.middlewares.use((req, _res, next) => {
const requestUrl = req.url ?? '';
const [pathname, search = ''] = requestUrl.split('?');
const rewrittenPath = embedDevEntrypoints[pathname as keyof typeof embedDevEntrypoints];
if (rewrittenPath) {
req.url = search ? `${rewrittenPath}?${search}` : rewrittenPath;
}
next();
});
}
};
}
export default defineConfig(({ command, mode }) => {
const buildTarget = mode in embedEntries ? (mode as keyof typeof embedEntries) : null;
const buildEntry = buildTarget ? { [buildTarget]: embedEntries[buildTarget] } : embedEntries;
return {
root: dirname,
plugins: [embedDevEntryPlugin(), tailwindcss(), svelte(), cssInjectedByJsPlugin()],
resolve: {
alias: {
'@cio/ui': path.resolve(dirname, 'node_modules/@cio/ui/src'),
'@cio/ui/*': path.resolve(dirname, 'node_modules/@cio/ui/src/*'),
$src: path.resolve(dirname, 'node_modules/@cio/ui/src')
},
conditions: ['svelte', 'browser', 'import']
},
conditions: ['svelte', 'browser', 'import']
},
optimizeDeps: {
include: ['svelte-tiptap'],
exclude: ['@tiptap/core', '@tiptap/pm']
},
build:
command === 'build'
? {
outDir: 'dist',
emptyOutDir: true,
cssCodeSplit: false,
lib: {
entry: path.resolve(dirname, 'src/widgets/question-type-picker/bootstrap.ts'),
name: 'CIOEmbeds',
formats: ['es'],
fileName: () => 'question-type-picker.js'
},
rollupOptions: {
output: {
inlineDynamicImports: false,
// Hashed names so CDN/browser can cache chunks immutably; bootstrap entry stays `question-type-picker.js`.
chunkFileNames: 'question-type-picker-[name]-[hash].js'
optimizeDeps: {
include: ['svelte-tiptap'],
exclude: ['@tiptap/core', '@tiptap/pm']
},
build:
command === 'build'
? {
outDir: buildTarget ? `dist/${buildTarget}` : 'dist',
emptyOutDir: false,
cssCodeSplit: false,
lib: {
entry: buildEntry,
name: 'CIOEmbeds',
formats: ['es'],
fileName: (_format, entryName) => `${entryName}.js`
},
rollupOptions: {
output: {
inlineDynamicImports: false,
chunkFileNames: '[name]-[hash].js'
}
}
}
}
: {},
server: {
port: 5180
}
}));
: {},
server: {
port: 5180,
cors: true
},
preview: {
port: 4180,
cors: true
}
};
});