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
@@ -0,0 +1,27 @@
<script lang="ts">
import { getStarSegments } from '../utils';
let {
rating = null,
ratingCount,
showCount = true
}: { rating?: number | null; ratingCount?: number | null; showCount?: boolean } = $props();
const segments = $derived(getStarSegments(rating));
const showStars = $derived(rating != null);
</script>
<span class="cio-stars">
{#if showStars}
{#each Array(segments.filled) as _, i (`f-${i}`)}
<span class="cio-star">★</span>
{/each}
{#each Array(segments.empty) as _, i (`e-${i}`)}
<span class="cio-star cio-star-empty">★</span>
{/each}
<span class="cio-rating-val">{rating?.toFixed(1)}</span>
{#if showCount && ratingCount}
<span class="cio-rating-count">({ratingCount})</span>
{/if}
{/if}
</span>
@@ -0,0 +1,9 @@
<script lang="ts">
let { src = null, alt = '', className = '' }: { src?: string | null; alt?: string; className?: string } = $props();
</script>
{#if src}
<img {src} {alt} class={`cio-img-cover ${className}`} loading="lazy" />
{:else}
<span class={`cio-thumb-fallback ${className}`} aria-hidden="true"></span>
{/if}
@@ -0,0 +1,10 @@
<script lang="ts">
import { getCourseTypeMeta } from '../utils';
let { courseType }: { courseType: string | null | undefined } = $props();
const meta = $derived(getCourseTypeMeta(courseType));
</script>
{#if meta}
<span class={meta.className}>{meta.label}</span>
{/if}
@@ -0,0 +1,59 @@
<script lang="ts">
import type { TWidgetPayload } from '@cio/utils/validation/widget';
import Stars from './atoms/stars.svelte';
import TypeBadge from './atoms/type-badge.svelte';
import Thumb from './atoms/thumb.svelte';
import { formatLessonsLine, getCourseExcerpt } from './utils';
let { payload }: { payload: TWidgetPayload } = $props();
const options = $derived(payload.design.layoutOptions.cardGrid);
const showRating = $derived(options.showRating);
const courses = $derived(payload.courses.slice(0, options.maxCount));
const cssVars = $derived(`--cio-cg-cols:${options.columns}`);
</script>
{#if courses.length === 0}
<div class="cio-empty">No courses to display.</div>
{:else}
<div class="cio-cg-grid" style={cssVars}>
{#each courses as course (course.id)}
<a class="cio-cg-card" href={course.url} target="_blank" rel="noreferrer noopener" aria-label={course.title}>
{#if payload.design.content.showCourseImage}
<div class="cio-cg-img">
<Thumb src={course.imageUrl} alt={course.title} className="cio-cg-img-art" />
{#if payload.design.content.showCourseTypeBadge}
<span style="position:relative;z-index:1">
<TypeBadge courseType={course.courseType} />
</span>
{/if}
</div>
{/if}
<div class="cio-cg-body">
<h3 class="cio-cg-title">{course.title}</h3>
{#if payload.design.content.showDescriptionExcerpt && course.description}
<p class="cio-cg-desc">{getCourseExcerpt(course)}</p>
{/if}
{#if course.tags.length > 0}
<div class="cio-cg-tags">
{#each course.tags.slice(0, 3) as tag (tag.id)}
<span class="cio-pill">{tag.name}</span>
{/each}
{#if course.tags.length > 3}
<span class="cio-pill">+{course.tags.length - 3}</span>
{/if}
</div>
{/if}
<div class="cio-cg-footer">
{#if showRating}
<Stars rating={course.rating} ratingCount={course.ratingCount} />
{:else}
<span></span>
{/if}
<span class="cio-cg-meta">{formatLessonsLine(course)}</span>
</div>
</div>
</a>
{/each}
</div>
{/if}
@@ -0,0 +1,169 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import type { TWidgetPayload } from '@cio/utils/validation/widget';
import Stars from './atoms/stars.svelte';
import TypeBadge from './atoms/type-badge.svelte';
import Thumb from './atoms/thumb.svelte';
import { formatLessonsLine } from './utils';
let { payload }: { payload: TWidgetPayload } = $props();
const options = $derived(payload.design.layoutOptions.carousel);
const courses = $derived(payload.courses.slice(0, options.maxCount));
const visible = $derived(Number(options.visibleCards));
const maxIndex = $derived(Math.max(courses.length - visible, 0));
let position = $state(0);
let trackEl = $state<HTMLDivElement | null>(null);
let timer: ReturnType<typeof setInterval> | null = null;
const cardBasis = $derived(`calc((100% - ${(visible - 1) * 12}px) / ${visible})`);
const translatePct = $derived(courses.length > 0 ? `calc(-${position} * (100% + 12px) / ${visible})` : '0');
function go(index: number) {
if (maxIndex === 0) {
position = 0;
return;
}
if (options.loop) {
position = ((index % (maxIndex + 1)) + (maxIndex + 1)) % (maxIndex + 1);
} else {
position = Math.max(0, Math.min(index, maxIndex));
}
}
function startAutoplay() {
if (!options.autoPlay || maxIndex === 0) return;
timer = setInterval(() => go(position + 1), Math.max(options.autoPlayIntervalMs, 3000));
}
function stopAutoplay() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
onMount(() => {
startAutoplay();
});
onDestroy(() => {
stopAutoplay();
});
$effect(() => {
if (position > maxIndex) position = maxIndex;
});
</script>
{#if courses.length === 0}
<div class="cio-empty">No courses to display.</div>
{:else}
<div
class="cio-cr-wrap"
onmouseenter={stopAutoplay}
onmouseleave={startAutoplay}
role="region"
aria-roledescription="carousel"
>
<div class="cio-cr-outer">
<div class="cio-cr-track" bind:this={trackEl} style={`transform: translateX(${translatePct})`}>
{#each courses as course (course.id)}
<a
class="cio-cr-card"
href={course.url}
target="_blank"
rel="noreferrer noopener"
style={`flex:0 0 ${cardBasis}`}
>
{#if payload.design.content.showCourseImage}
<div class="cio-cr-img">
<Thumb src={course.imageUrl} alt={course.title} />
</div>
{/if}
<div class="cio-cr-body">
<h3 class="cio-cr-title">{course.title}</h3>
<div class="cio-cr-rating-row">
<Stars rating={course.rating} ratingCount={course.ratingCount} showCount={false} />
</div>
{#if payload.design.content.showCourseTypeBadge}
<div class="cio-cr-tags-row">
<TypeBadge courseType={course.courseType} />
</div>
{/if}
<div class="cio-cr-meta">{formatLessonsLine(course)}</div>
</div>
</a>
{/each}
</div>
</div>
{#if maxIndex > 0 && (options.showDots || options.showArrows)}
<div class="cio-cr-nav">
{#if options.showDots}
<div class="cio-cr-dots">
{#each Array(maxIndex + 1) as _, i (i)}
<button
type="button"
class="cio-cr-dot"
class:cio-on={i === position}
aria-label={`Go to slide ${i + 1}`}
onclick={() => go(i)}
></button>
{/each}
</div>
{:else}
<span></span>
{/if}
{#if options.showArrows}
<div class="cio-cr-arrows">
<button
type="button"
class="cio-cr-btn"
onclick={() => go(position - 1)}
disabled={!options.loop && position === 0}
aria-label="Previous"
>
<svg
class="cio-cr-btn-icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m15 18-6-6 6-6" />
</svg>
</button>
<button
type="button"
class="cio-cr-btn"
onclick={() => go(position + 1)}
disabled={!options.loop && position === maxIndex}
aria-label="Next"
>
<svg
class="cio-cr-btn-icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="m9 18 6-6-6-6" />
</svg>
</button>
</div>
{:else}
<span></span>
{/if}
</div>
{/if}
</div>
{/if}
@@ -0,0 +1,96 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { TWidgetPayload, TWidgetPayloadCourse } from '@cio/utils/validation/widget';
import Stars from './atoms/stars.svelte';
import TypeBadge from './atoms/type-badge.svelte';
import Thumb from './atoms/thumb.svelte';
let { payload }: { payload: TWidgetPayload } = $props();
const options = $derived(payload.design.layoutOptions.categoryShelf);
const courseById = $derived(new Map(payload.courses.map((course) => [course.id, course])));
const categories = $derived(payload.categories);
const tabs = $derived(
options.showAllTab
? [
{ id: 'all', label: 'All courses', courseIds: payload.courses.map((c) => c.id) },
...categories.map((cat) => ({ id: cat.tagId, label: cat.name, courseIds: cat.courseIds }))
]
: categories.map((cat) => ({ id: cat.tagId, label: cat.name, courseIds: cat.courseIds }))
);
let activeId = $state<string>(
untrack(() => {
const initialOptions = payload.design.layoutOptions.categoryShelf;
const initialCategories = payload.categories;
return (
initialOptions.defaultCategoryTagId ?? (initialOptions.showAllTab ? 'all' : (initialCategories[0]?.tagId ?? ''))
);
})
);
const activeCourses = $derived.by<TWidgetPayloadCourse[]>(() => {
const tab = tabs.find((entry) => entry.id === activeId) ?? tabs[0];
if (!tab) return [];
return tab.courseIds
.map((id) => courseById.get(id))
.filter((course): course is TWidgetPayloadCourse => course !== undefined)
.slice(0, options.maxPerCategory);
});
$effect(() => {
if (tabs.length > 0 && !tabs.some((tab) => tab.id === activeId)) {
activeId = tabs[0].id;
}
});
</script>
{#if tabs.length === 0}
<div class="cio-empty">Add at least one category tag to display this layout.</div>
{:else}
<div class="cio-shelf-tabs" role="tablist">
{#each tabs as tab (tab.id)}
<button
type="button"
class="cio-shelf-tab"
class:cio-on={tab.id === activeId}
onclick={() => (activeId = tab.id)}
>
{tab.label}
</button>
{/each}
</div>
<div class="cio-shelf-pane">
{#if activeCourses.length === 0}
<div class="cio-empty">No courses in this category yet.</div>
{:else}
<div class="cio-shelf-grid">
{#each activeCourses as course (course.id)}
<a class="cio-shelf-card" href={course.url} target="_blank" rel="noreferrer noopener">
{#if payload.design.content.showCourseImage}
<div class="cio-shelf-img">
<Thumb src={course.imageUrl} alt={course.title} />
</div>
{/if}
<div class="cio-shelf-body">
<h3 class="cio-shelf-title">{course.title}</h3>
<div class="cio-shelf-meta-row">
{#if payload.design.content.showCourseTypeBadge}
<TypeBadge courseType={course.courseType} />
{/if}
<Stars rating={course.rating} ratingCount={course.ratingCount} />
</div>
{#if course.tags.length > 0}
<div class="cio-shelf-tags-row">
{#each course.tags.slice(0, 2) as tag (tag.id)}
<span class="cio-pill">{tag.name}</span>
{/each}
</div>
{/if}
</div>
</a>
{/each}
</div>
{/if}
</div>
{/if}
@@ -0,0 +1,48 @@
<script lang="ts">
import type { TWidgetPayload } from '@cio/utils/validation/widget';
import Stars from './atoms/stars.svelte';
import TypeBadge from './atoms/type-badge.svelte';
import Thumb from './atoms/thumb.svelte';
let { payload }: { payload: TWidgetPayload } = $props();
const options = $derived(payload.design.layoutOptions.compactList);
const courses = $derived(payload.courses.slice(0, options.maxCount));
</script>
{#if courses.length === 0}
<div class="cio-empty">No courses to display.</div>
{:else}
<div class="cio-cl-list" class:cio-density-compact={options.density === 'compact'}>
{#each courses as course (course.id)}
<a class="cio-cl-item" href={course.url} target="_blank" rel="noreferrer noopener" aria-label={course.title}>
{#if options.showThumbnail && payload.design.content.showCourseImage}
<div class="cio-cl-thumb">
<Thumb src={course.imageUrl} alt={course.title} />
</div>
{/if}
<div class="cio-cl-info">
<div class="cio-cl-top">
<span class="cio-cl-title">{course.title}</span>
{#if payload.design.content.showLessonsCount}
<span class="cio-cl-lessons">{course.lessonCount} lessons</span>
{/if}
</div>
<div class="cio-cl-mid">
{#if payload.design.content.showCourseTypeBadge}
<TypeBadge courseType={course.courseType} />
{/if}
<Stars rating={course.rating} ratingCount={course.ratingCount} />
</div>
{#if options.showTags && course.tags.length > 0}
<div class="cio-cl-bottom">
{#each course.tags.slice(0, 3) as tag (tag.id)}
<span class="cio-pill">{tag.name}</span>
{/each}
</div>
{/if}
</div>
</a>
{/each}
</div>
{/if}
@@ -0,0 +1,52 @@
<script lang="ts">
import type { TWidgetPayload } from '@cio/utils/validation/widget';
import CardGrid from './card-grid.svelte';
import TagFilter from './tag-filter.svelte';
import Carousel from './carousel.svelte';
import PrimaryCourse from './primary-course.svelte';
import CompactList from './compact-list.svelte';
import EditorialSpotlight from './editorial-spotlight.svelte';
import CategoryShelf from './category-shelf.svelte';
import { ensureWidgetGoogleFontsLoaded } from './ensure-google-fonts';
import { buildCssVarsFromDesign, buildPoweredByMarketingUrl } from './utils';
let { payload }: { payload: TWidgetPayload | null | undefined } = $props();
let rootEl: HTMLDivElement | null = null;
const cssVars = $derived(payload ? buildCssVarsFromDesign(payload.design) : '');
const poweredByHref = $derived(payload ? buildPoweredByMarketingUrl(payload) : '');
$effect(() => {
if (!payload || !rootEl) return;
ensureWidgetGoogleFontsLoaded(rootEl, payload.design);
});
</script>
{#if !payload}
<div class="cio-widget cio-widget--empty">No widget payload provided.</div>
{:else}
<div bind:this={rootEl} class="cio-widget" style={cssVars} data-cio-layout={payload.layoutType}>
{#if payload.layoutType === 'card_grid'}
<CardGrid {payload} />
{:else if payload.layoutType === 'tag_filter'}
<TagFilter {payload} />
{:else if payload.layoutType === 'carousel'}
<Carousel {payload} />
{:else if payload.layoutType === 'primary_course'}
<PrimaryCourse {payload} />
{:else if payload.layoutType === 'compact_list'}
<CompactList {payload} />
{:else if payload.layoutType === 'editorial_spotlight'}
<EditorialSpotlight {payload} />
{:else if payload.layoutType === 'category_shelf'}
<CategoryShelf {payload} />
{/if}
{#if payload.design.branding.showPoweredBy}
<a class="cio-branding" href={poweredByHref} target="_blank" rel="noopener noreferrer">
{payload.labels.poweredByLabel}
</a>
{/if}
</div>
{/if}
@@ -0,0 +1,75 @@
<script lang="ts">
import type { TWidgetPayload } from '@cio/utils/validation/widget';
import Stars from './atoms/stars.svelte';
import TypeBadge from './atoms/type-badge.svelte';
import Thumb from './atoms/thumb.svelte';
import { getCourseExcerpt } from './utils';
let { payload }: { payload: TWidgetPayload } = $props();
const options = $derived(payload.design.layoutOptions.editorialSpotlight);
const main = $derived(payload.courses[0] ?? null);
const secondary = $derived(payload.courses.slice(1, 1 + options.secondaryMaxCount));
</script>
{#if !main}
<div class="cio-empty">Pick a main course to display.</div>
{:else}
<div class="cio-ed-grid">
<a class="cio-ed-main" href={main.url} target="_blank" rel="noreferrer noopener" aria-label={main.title}>
{#if payload.design.content.showCourseImage}
<div class="cio-ed-img-wrap">
<Thumb src={main.imageUrl} alt={main.title} />
</div>
{/if}
<div class="cio-ed-body">
{#if payload.design.content.showCourseTypeBadge || main.tags.length > 0}
<div class="cio-ed-top-meta">
{#if payload.design.content.showCourseTypeBadge}
<TypeBadge courseType={main.courseType} />
{/if}
{#each main.tags.slice(0, 1) as tag (tag.id)}
<span class="cio-pill">{tag.name}</span>
{/each}
</div>
{/if}
<h2 class="cio-ed-title" class:cio-title-sans={options.titleStyle === 'sans'}>{main.title}</h2>
{#if payload.design.content.showDescriptionExcerpt && main.description}
<p class="cio-ed-desc">{getCourseExcerpt(main, 200)}</p>
{/if}
<div class="cio-ed-foot">
<Stars rating={main.rating} ratingCount={main.ratingCount} />
{#if main.tags.length > 0}
<div style="display:flex;gap:4px;flex-wrap:wrap">
{#each main.tags.slice(0, 3) as tag (tag.id)}
<span class="cio-pill">{tag.name}</span>
{/each}
</div>
{/if}
</div>
</div>
</a>
{#if secondary.length > 0}
<div class="cio-ed-stack">
{#each secondary as course (course.id)}
<a class="cio-ed-mini" href={course.url} target="_blank" rel="noreferrer noopener">
{#if payload.design.content.showCourseImage}
<div class="cio-ed-mini-img">
<Thumb src={course.imageUrl} alt={course.title} />
</div>
{/if}
<div>
<h3 class="cio-ed-mini-title">{course.title}</h3>
<div class="cio-ed-mini-meta">
{#if payload.design.content.showCourseTypeBadge}
<TypeBadge courseType={course.courseType} />
{/if}
<Stars rating={course.rating} ratingCount={course.ratingCount} showCount={false} />
</div>
</div>
</a>
{/each}
</div>
{/if}
</div>
{/if}
@@ -0,0 +1,48 @@
import { buildWidgetGoogleFontsStylesheetHref, type TWidgetConfig } from '@cio/utils/validation/widget';
const ATTR_PRECONNECT = 'data-cio-widget-font-preconnect';
function ensurePreconnect(head: HTMLHeadElement): void {
const doc = head.ownerDocument;
if (!head.querySelector(`link[${ATTR_PRECONNECT}="googleapis"]`)) {
const a = doc.createElement('link');
a.rel = 'preconnect';
a.href = 'https://fonts.googleapis.com';
a.setAttribute(ATTR_PRECONNECT, 'googleapis');
head.appendChild(a);
}
if (!head.querySelector(`link[${ATTR_PRECONNECT}="gstatic"]`)) {
const b = doc.createElement('link');
b.rel = 'preconnect';
b.href = 'https://fonts.gstatic.com';
b.setAttribute('crossorigin', '');
b.setAttribute(ATTR_PRECONNECT, 'gstatic');
head.appendChild(b);
}
}
function stylesheetAlreadyPresent(head: HTMLHeadElement, href: string): boolean {
for (const link of head.querySelectorAll('link[rel="stylesheet"]')) {
if (link.getAttribute('href') === href) return true;
}
return false;
}
/**
* Idempotently loads Google Fonts for the widget into `document.head` (embed shadow trees share the
* document’s font cache once the stylesheet is in the main document).
*/
export function ensureWidgetGoogleFontsLoaded(hostElement: HTMLElement, design: TWidgetConfig): void {
const head = hostElement.ownerDocument?.head;
if (!head) return;
ensurePreconnect(head);
const href = buildWidgetGoogleFontsStylesheetHref(design);
if (!href || stylesheetAlreadyPresent(head, href)) return;
const link = hostElement.ownerDocument.createElement('link');
link.rel = 'stylesheet';
link.href = href;
head.appendChild(link);
}
@@ -0,0 +1,24 @@
import widgetStyles from './styles.css?raw';
export { default as CourseWidget } from './course-widget.svelte';
export { default as CardGridLayout } from './card-grid.svelte';
export { default as TagFilterLayout } from './tag-filter.svelte';
export { default as CarouselLayout } from './carousel.svelte';
export { default as PrimaryCourseLayout } from './primary-course.svelte';
export { default as CompactListLayout } from './compact-list.svelte';
export { default as EditorialSpotlightLayout } from './editorial-spotlight.svelte';
export { default as CategoryShelfLayout } from './category-shelf.svelte';
export {
buildCssVarsFromDesign,
buildPoweredByMarketingUrl,
formatLessonsLine,
getCourseExcerpt,
getOrgDomainForUtm,
getCourseTypeMeta,
getStarSegments
} from './utils';
export { ensureWidgetGoogleFontsLoaded } from './ensure-google-fonts';
export { widgetStyles };
@@ -0,0 +1,69 @@
<script lang="ts">
import type { TWidgetPayload } from '@cio/utils/validation/widget';
import Stars from './atoms/stars.svelte';
import TypeBadge from './atoms/type-badge.svelte';
import Thumb from './atoms/thumb.svelte';
import { formatLessonsLine, getCourseExcerpt } from './utils';
let { payload }: { payload: TWidgetPayload } = $props();
const options = $derived(payload.design.layoutOptions.primaryCourse);
const featured = $derived(payload.courses[0] ?? null);
const secondary = $derived(payload.courses.slice(1, 1 + options.secondaryMaxCount));
</script>
{#if !featured}
<div class="cio-empty">Pick a featured course to display.</div>
{:else}
<a class="cio-ph-hero" href={featured.url} target="_blank" rel="noreferrer noopener">
<div>
<div class="cio-ph-eyebrow">{options.eyebrowLabel}</div>
<h2 class="cio-ph-title">{featured.title}</h2>
{#if payload.design.content.showDescriptionExcerpt && featured.description}
<p class="cio-ph-desc">{getCourseExcerpt(featured, 220)}</p>
{/if}
<div class="cio-ph-row">
{#if featured.rating != null}
<span class="cio-ph-rating-val">{featured.rating.toFixed(1)}</span>
<span class="cio-ph-star">★★★★★</span>
{#if featured.ratingCount}
<span class="cio-ph-count">({featured.ratingCount} reviews)</span>
{/if}
<span class="cio-ph-sep">·</span>
{/if}
<span class="cio-ph-meta">{formatLessonsLine(featured)}</span>
</div>
{#if payload.design.content.showCourseTypeBadge || featured.tags.length > 0}
<div class="cio-ph-badges">
{#if payload.design.content.showCourseTypeBadge}
<TypeBadge courseType={featured.courseType} />
{/if}
{#each featured.tags.slice(0, 2) as tag (tag.id)}
<span class="cio-pill">{tag.name}</span>
{/each}
</div>
{/if}
<span class="cio-ph-btn">{options.ctaLabel} →</span>
</div>
{#if payload.design.content.showCourseImage}
<div class="cio-ph-img">
<Thumb src={featured.imageUrl} alt={featured.title} />
</div>
{/if}
</a>
{#if secondary.length > 0}
<div class="cio-ph-grid">
{#each secondary as course (course.id)}
<a class="cio-ph-mini" href={course.url} target="_blank" rel="noreferrer noopener">
<h3 class="cio-ph-mini-title">{course.title}</h3>
<div class="cio-ph-mini-meta">
{#if payload.design.content.showCourseTypeBadge}
<TypeBadge courseType={course.courseType} />
{/if}
<Stars rating={course.rating} ratingCount={course.ratingCount} showCount={false} />
</div>
</a>
{/each}
</div>
{/if}
{/if}
@@ -0,0 +1,873 @@
/*
* Widget layouts v2 — scoped CSS for the public course widget.
*
* Every selector is namespaced under `.cio-widget` so styles can be safely
* injected into a host page (via Shadow DOM in the embed bundle, or via
* <svelte:head> in the dashboard preview iframe) without bleeding into other
* UI on the page.
*/
.cio-widget,
.cio-widget * {
box-sizing: border-box;
}
.cio-widget {
--cio-color-bg-primary: var(--cio-bg, #ffffff);
--cio-color-bg-secondary: var(--cio-highlight, #f8fafc);
--cio-color-text-primary: var(--cio-text, #0f172a);
--cio-color-text-secondary: rgba(15, 23, 42, 0.7);
--cio-color-text-tertiary: rgba(15, 23, 42, 0.55);
--cio-color-border-primary: color-mix(in srgb, var(--cio-border, rgba(15, 23, 42)) 100%, transparent);
--cio-color-border-secondary: color-mix(in srgb, var(--cio-border, rgba(15, 23, 42)) 92%, transparent);
--cio-color-border-tertiary: color-mix(in srgb, var(--cio-border, rgba(15, 23, 42)) 80%, transparent);
--cio-color-primary: var(--cio-primary, #0f172a);
--cio-color-badge-bg: var(--cio-badge, #e2e8f0);
--cio-radius-sm: 6px;
--cio-radius-md: 10px;
--cio-radius-lg: var(--cio-radius, 14px);
--cio-shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04);
font-family: var(--cio-font, 'DM Sans', Inter, ui-sans-serif, system-ui, sans-serif);
font-size: calc(14px * var(--cio-font-scale, 1));
color: var(--cio-color-text-primary);
background: var(--cio-color-bg-primary);
border-radius: var(--cio-radius-lg);
padding: 16px;
width: 100%;
display: block;
overflow: hidden;
}
.cio-widget.cio-widget--empty {
color: var(--cio-color-text-tertiary);
font-size: 13px;
text-align: center;
}
.cio-widget a {
color: inherit;
text-decoration: none;
}
.cio-widget button {
font: inherit;
cursor: pointer;
}
/* ---------------------------------------------------------------- */
/* Shared atoms */
/* ---------------------------------------------------------------- */
.cio-type-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
font-weight: 500;
padding: 3px 8px;
border-radius: 20px;
letter-spacing: 0.01em;
white-space: nowrap;
}
.cio-type-live {
background: #fef3c7;
color: #92400e;
}
.cio-type-self {
background: #ede9fe;
color: #5b21b6;
}
.cio-type-compliance {
background: #dcfce7;
color: #166534;
}
.cio-type-default {
background: var(--cio-color-badge-bg);
color: var(--cio-color-text-secondary);
}
.cio-stars {
display: inline-flex;
gap: 1px;
align-items: center;
}
.cio-star {
font-size: 11px;
color: #f59e0b;
line-height: 1;
}
.cio-star.cio-star-empty {
color: #d1d5db;
}
.cio-rating-val {
font-size: 12px;
font-weight: 500;
color: var(--cio-color-text-secondary);
margin-left: 4px;
}
.cio-rating-count {
font-size: 11px;
color: var(--cio-color-text-tertiary);
margin-left: 2px;
}
.cio-pill {
font-size: 10px;
font-weight: 400;
padding: 2px 8px;
border-radius: 20px;
border: 0.5px solid var(--cio-color-border-tertiary);
color: var(--cio-color-text-secondary);
background: transparent;
white-space: nowrap;
}
.cio-thumb-fallback {
width: 100%;
height: 100%;
display: block;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.85) 0%, rgba(56, 189, 248, 0.85) 100%);
}
.cio-img-cover {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.cio-empty {
padding: 32px 16px;
text-align: center;
font-size: 13px;
color: var(--cio-color-text-tertiary);
border: 1px dashed var(--cio-color-border-tertiary);
border-radius: var(--cio-radius-lg);
}
.cio-branding {
display: block;
margin-top: 16px;
text-align: right;
font-size: 11px;
color: var(--cio-color-text-tertiary);
opacity: 0.8;
text-decoration: none;
}
.cio-branding:hover {
opacity: 1;
text-decoration: underline;
}
.cio-branding:visited {
color: var(--cio-color-text-tertiary);
}
/* ---------------------------------------------------------------- */
/* 1. Card grid */
/* ---------------------------------------------------------------- */
.cio-cg-grid {
display: grid;
grid-template-columns: repeat(var(--cio-cg-cols, 3), minmax(0, 1fr));
gap: 14px;
}
.cio-cg-card {
background: var(--cio-color-bg-primary);
border: 0.5px solid var(--cio-color-border-tertiary);
border-radius: var(--cio-radius-lg);
overflow: hidden;
display: flex;
flex-direction: column;
transition:
border-color 0.15s,
transform 0.15s;
}
.cio-cg-card:hover {
border-color: var(--cio-color-border-primary);
transform: translateY(-2px);
}
.cio-cg-img {
height: 136px;
position: relative;
overflow: hidden;
display: flex;
align-items: flex-end;
padding: 10px;
background: var(--cio-color-bg-secondary);
}
.cio-cg-img-art {
position: absolute;
inset: 0;
}
.cio-cg-body {
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
flex: 1;
}
.cio-cg-title {
font-size: 13px;
font-weight: 500;
line-height: 1.4;
color: var(--cio-color-text-primary);
margin: 0;
}
.cio-cg-desc {
font-size: 11px;
color: var(--cio-color-text-secondary);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin: 0;
}
.cio-cg-tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.cio-cg-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 10px;
border-top: 0.5px solid var(--cio-color-border-tertiary);
margin-top: auto;
gap: 8px;
}
.cio-cg-meta {
font-size: 11px;
color: var(--cio-color-text-tertiary);
}
/* ---------------------------------------------------------------- */
/* 2. Tag filter */
/* ---------------------------------------------------------------- */
.cio-tf-bar {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.cio-tf-btn {
font-size: 11px;
font-weight: 500;
padding: 5px 12px;
border-radius: 20px;
border: 0.5px solid var(--cio-color-border-secondary);
background: var(--cio-color-bg-primary);
color: var(--cio-color-text-secondary);
transition: all 0.12s;
}
.cio-tf-btn:hover:not(.cio-on) {
background: var(--cio-color-bg-secondary);
}
.cio-tf-btn.cio-on {
background: var(--cio-color-primary);
color: var(--cio-color-bg-primary);
border-color: var(--cio-color-primary);
}
.cio-tf-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.cio-tf-row {
display: flex;
gap: 12px;
padding: 12px;
background: var(--cio-color-bg-primary);
border: 0.5px solid var(--cio-color-border-tertiary);
border-radius: var(--cio-radius-lg);
align-items: center;
transition: border-color 0.12s;
}
.cio-tf-row:hover {
border-color: var(--cio-color-border-primary);
}
.cio-tf-thumb {
width: 56px;
height: 56px;
border-radius: var(--cio-radius-md);
overflow: hidden;
flex-shrink: 0;
background: var(--cio-color-bg-secondary);
}
.cio-tf-info {
flex: 1;
min-width: 0;
}
.cio-tf-title {
font-size: 13px;
font-weight: 500;
line-height: 1.3;
color: var(--cio-color-text-primary);
margin: 0 0 4px;
}
.cio-tf-sub {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 5px;
}
.cio-tf-tags-row {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
/* ---------------------------------------------------------------- */
/* 3. Carousel */
/* ---------------------------------------------------------------- */
.cio-cr-wrap {
position: relative;
padding: 0 2px;
}
.cio-cr-outer {
overflow: hidden;
}
.cio-cr-track {
display: flex;
gap: 12px;
transition: transform 0.32s cubic-bezier(0.4, 0, 0.2, 1);
}
.cio-cr-card {
background: var(--cio-color-bg-primary);
border: 0.5px solid var(--cio-color-border-tertiary);
border-radius: var(--cio-radius-lg);
overflow: hidden;
transition: border-color 0.15s;
display: flex;
flex-direction: column;
}
.cio-cr-card:hover {
border-color: var(--cio-color-border-primary);
}
.cio-cr-img {
height: 110px;
position: relative;
background: var(--cio-color-bg-secondary);
}
.cio-cr-body {
padding: 11px 12px 13px;
display: flex;
flex-direction: column;
gap: 6px;
}
.cio-cr-title {
font-size: 12px;
font-weight: 500;
line-height: 1.35;
color: var(--cio-color-text-primary);
margin: 0;
}
.cio-cr-rating-row {
display: flex;
align-items: center;
gap: 6px;
}
.cio-cr-tags-row {
display: flex;
gap: 3px;
flex-wrap: wrap;
}
.cio-cr-meta {
font-size: 10px;
color: var(--cio-color-text-tertiary);
}
.cio-cr-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 12px;
}
.cio-cr-dots {
display: flex;
gap: 5px;
}
.cio-cr-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--cio-color-border-secondary);
border: none;
padding: 0;
cursor: pointer;
transition: background 0.15s;
}
.cio-cr-dot.cio-on {
background: var(--cio-color-primary);
}
.cio-cr-arrows {
display: flex;
gap: 6px;
}
/* `button { font: inherit }` above — scope controls so icon sizing applies */
.cio-widget button.cio-cr-btn {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--cio-color-bg-primary);
border: 0.5px solid var(--cio-color-border-secondary);
color: var(--cio-color-text-secondary);
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition: background 0.12s;
}
.cio-widget button.cio-cr-btn .cio-cr-btn-icon {
width: 18px;
height: 18px;
display: block;
flex-shrink: 0;
}
.cio-widget button.cio-cr-btn:hover {
background: var(--cio-color-bg-secondary);
}
.cio-widget button.cio-cr-btn:disabled {
opacity: 0.3;
cursor: default;
}
/* ---------------------------------------------------------------- */
/* 4. Primary course (hero) */
/* ---------------------------------------------------------------- */
.cio-ph-hero {
background: var(--cio-color-bg-primary);
color: var(--cio-color-text-primary);
border: 1px solid var(--cio-color-border-secondary);
border-radius: var(--cio-radius-lg);
padding: 24px;
display: grid;
grid-template-columns: 1fr 160px;
gap: 20px;
align-items: center;
margin-bottom: 12px;
text-decoration: none;
transition: border-color 0.15s;
}
.cio-ph-hero:hover {
border-color: var(--cio-color-border-primary);
}
.cio-ph-eyebrow {
font-size: 10px;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--cio-color-text-tertiary);
margin-bottom: 6px;
}
.cio-ph-title {
font-family: var(--cio-font-serif, 'Lora', Georgia, serif);
font-size: 20px;
font-weight: 600;
color: var(--cio-color-text-primary);
line-height: 1.3;
margin: 0 0 8px;
}
.cio-ph-desc {
font-size: 12px;
color: var(--cio-color-text-secondary);
line-height: 1.6;
margin: 0 0 12px;
}
.cio-ph-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
flex-wrap: wrap;
}
.cio-ph-rating-val {
font-size: 13px;
font-weight: 500;
color: var(--cio-color-text-primary);
}
.cio-ph-star {
font-size: 11px;
color: #f59e0b;
letter-spacing: 1px;
}
.cio-ph-count {
font-size: 11px;
color: var(--cio-color-text-tertiary);
}
.cio-ph-sep {
font-size: 11px;
color: var(--cio-color-border-secondary);
}
.cio-ph-meta {
font-size: 11px;
color: var(--cio-color-text-secondary);
}
.cio-ph-badges {
display: flex;
gap: 7px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.cio-ph-btn {
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--cio-color-primary);
color: var(--cio-color-bg-primary);
font-size: 12px;
font-weight: 500;
padding: 9px 18px;
border-radius: var(--cio-radius-md);
border: none;
transition: opacity 0.15s;
}
.cio-ph-btn:hover {
opacity: 0.88;
}
.cio-ph-img {
width: 160px;
height: 110px;
border-radius: var(--cio-radius-md);
overflow: hidden;
flex-shrink: 0;
background: var(--cio-color-bg-secondary);
}
.cio-ph-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.cio-ph-mini {
background: var(--cio-color-bg-primary);
border: 0.5px solid var(--cio-color-border-tertiary);
border-radius: var(--cio-radius-lg);
padding: 12px;
transition: border-color 0.15s;
display: flex;
flex-direction: column;
gap: 5px;
}
.cio-ph-mini:hover {
border-color: var(--cio-color-border-primary);
}
.cio-ph-mini-title {
font-size: 12px;
font-weight: 500;
color: var(--cio-color-text-primary);
line-height: 1.3;
margin: 0;
}
.cio-ph-mini-meta {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
/* ---------------------------------------------------------------- */
/* 5. Compact list */
/* ---------------------------------------------------------------- */
.cio-cl-list {
display: block;
}
.cio-cl-item {
display: flex;
gap: 14px;
padding: 12px 0;
border-bottom: 0.5px solid var(--cio-color-border-tertiary);
align-items: flex-start;
transition: opacity 0.12s;
}
.cio-cl-item:last-child {
border-bottom: none;
}
.cio-cl-item:hover {
opacity: 0.78;
}
.cio-cl-list.cio-density-compact .cio-cl-item {
padding: 8px 0;
gap: 10px;
}
.cio-cl-thumb {
width: 60px;
height: 48px;
border-radius: var(--cio-radius-md);
overflow: hidden;
flex-shrink: 0;
background: var(--cio-color-bg-secondary);
}
.cio-cl-info {
flex: 1;
min-width: 0;
}
.cio-cl-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
.cio-cl-title {
font-size: 13px;
font-weight: 500;
color: var(--cio-color-text-primary);
line-height: 1.35;
margin: 0;
}
.cio-cl-mid {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
flex-wrap: wrap;
}
.cio-cl-bottom {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.cio-cl-lessons {
font-size: 11px;
color: var(--cio-color-text-tertiary);
white-space: nowrap;
flex-shrink: 0;
padding-top: 1px;
}
/* ---------------------------------------------------------------- */
/* 6. Editorial spotlight */
/* ---------------------------------------------------------------- */
.cio-ed-grid {
display: grid;
grid-template-columns: 1.5fr 1fr;
gap: 12px;
}
@media (max-width: 640px) {
.cio-ed-grid {
grid-template-columns: 1fr;
}
}
.cio-ed-main {
background: var(--cio-color-bg-primary);
border: 0.5px solid var(--cio-color-border-tertiary);
border-radius: var(--cio-radius-lg);
overflow: hidden;
display: flex;
flex-direction: column;
}
.cio-ed-img-wrap {
height: 190px;
background: var(--cio-color-bg-secondary);
transition: opacity 0.15s;
}
.cio-ed-main:hover .cio-ed-img-wrap {
opacity: 0.9;
}
.cio-ed-body {
padding: 14px;
}
.cio-ed-top-meta {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.cio-ed-title {
font-family: var(--cio-font-serif, 'Lora', Georgia, serif);
font-size: 16px;
font-weight: 600;
color: var(--cio-color-text-primary);
line-height: 1.35;
margin: 0 0 6px;
}
.cio-ed-title.cio-title-sans {
font-family: inherit;
}
.cio-ed-desc {
font-size: 12px;
color: var(--cio-color-text-secondary);
line-height: 1.6;
margin: 0 0 10px;
}
.cio-ed-foot {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.cio-ed-stack {
display: flex;
flex-direction: column;
gap: 8px;
}
.cio-ed-mini {
background: var(--cio-color-bg-primary);
border: 0.5px solid var(--cio-color-border-tertiary);
border-radius: var(--cio-radius-lg);
display: flex;
gap: 10px;
padding: 10px;
align-items: flex-start;
transition: border-color 0.15s;
}
.cio-ed-mini:hover {
border-color: var(--cio-color-border-primary);
}
.cio-ed-mini-img {
width: 52px;
height: 44px;
border-radius: var(--cio-radius-md);
overflow: hidden;
flex-shrink: 0;
background: var(--cio-color-bg-secondary);
}
.cio-ed-mini-title {
font-size: 12px;
font-weight: 500;
color: var(--cio-color-text-primary);
line-height: 1.3;
margin: 0 0 3px;
}
.cio-ed-mini-meta {
display: flex;
gap: 5px;
align-items: center;
flex-wrap: wrap;
}
/* ---------------------------------------------------------------- */
/* 7. Category shelf */
/* ---------------------------------------------------------------- */
.cio-shelf-tabs {
display: flex;
gap: 0;
margin-bottom: 0;
border-bottom: 0.5px solid var(--cio-color-border-tertiary);
overflow-x: auto;
scrollbar-width: none;
}
.cio-shelf-tabs::-webkit-scrollbar {
display: none;
}
.cio-shelf-tab {
font-size: 12px;
font-weight: 500;
padding: 9px 16px;
color: var(--cio-color-text-tertiary);
border: none;
border-bottom: 2px solid transparent;
background: transparent;
white-space: nowrap;
text-transform: capitalize;
transition: all 0.15s;
}
.cio-shelf-tab.cio-on {
color: var(--cio-color-text-primary);
border-bottom-color: var(--cio-color-text-primary);
}
.cio-shelf-tab:hover:not(.cio-on) {
color: var(--cio-color-text-secondary);
}
.cio-shelf-pane {
padding-top: 14px;
}
.cio-shelf-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.cio-shelf-card {
background: var(--cio-color-bg-primary);
border: 0.5px solid var(--cio-color-border-tertiary);
border-radius: var(--cio-radius-lg);
overflow: hidden;
display: flex;
flex-direction: column;
transition: border-color 0.15s;
}
.cio-shelf-card:hover {
border-color: var(--cio-color-border-primary);
}
.cio-shelf-img {
height: 100px;
background: var(--cio-color-bg-secondary);
}
.cio-shelf-body {
padding: 10px 12px 12px;
display: flex;
flex-direction: column;
gap: 5px;
}
.cio-shelf-title {
font-size: 12px;
font-weight: 500;
color: var(--cio-color-text-primary);
line-height: 1.3;
margin: 0;
}
.cio-shelf-meta-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.cio-shelf-tags-row {
display: flex;
gap: 3px;
flex-wrap: wrap;
}
/* ---------------------------------------------------------------- */
/* Responsive collapses */
/* ---------------------------------------------------------------- */
@media (max-width: 720px) {
.cio-cg-grid {
grid-template-columns: repeat(min(var(--cio-cg-cols, 3), 2), minmax(0, 1fr));
}
.cio-tf-grid {
grid-template-columns: 1fr;
}
.cio-shelf-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.cio-ph-hero {
grid-template-columns: 1fr;
}
.cio-ph-img {
width: 100%;
height: 160px;
}
.cio-ph-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 480px) {
.cio-cg-grid {
grid-template-columns: 1fr;
}
.cio-shelf-grid {
grid-template-columns: 1fr;
}
.cio-ph-grid {
grid-template-columns: 1fr;
}
}
.cio-widget :focus-visible {
outline: 2px solid var(--cio-color-primary);
outline-offset: 2px;
border-radius: var(--cio-radius-sm);
}
@@ -0,0 +1,68 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { TWidgetPayload } from '@cio/utils/validation/widget';
import Stars from './atoms/stars.svelte';
import TypeBadge from './atoms/type-badge.svelte';
import Thumb from './atoms/thumb.svelte';
let { payload }: { payload: TWidgetPayload } = $props();
const options = $derived(payload.design.layoutOptions.tagFilter);
const courses = $derived(payload.courses.slice(0, options.maxCount));
const tagPool = $derived(payload.tagPool);
let activeTagId = $state<string | null>(untrack(() => payload.design.layoutOptions.tagFilter.defaultTagId ?? null));
const visibleCourses = $derived(
activeTagId === null ? courses : courses.filter((course) => course.tags.some((tag) => tag.id === activeTagId))
);
</script>
{#if tagPool.length > 0}
<div class="cio-tf-bar" role="tablist">
{#if options.showAllOption}
<button type="button" class="cio-tf-btn" class:cio-on={activeTagId === null} onclick={() => (activeTagId = null)}
>All</button
>
{/if}
{#each tagPool as tag (tag.id)}
<button
type="button"
class="cio-tf-btn"
class:cio-on={activeTagId === tag.id}
onclick={() => (activeTagId = activeTagId === tag.id ? null : tag.id)}>{tag.name}</button
>
{/each}
</div>
{/if}
{#if visibleCourses.length === 0}
<div class="cio-empty">No courses match this filter.</div>
{:else}
<div class="cio-tf-grid">
{#each visibleCourses as course (course.id)}
<a class="cio-tf-row" href={course.url} target="_blank" rel="noreferrer noopener" aria-label={course.title}>
{#if payload.design.content.showCourseImage}
<div class="cio-tf-thumb">
<Thumb src={course.imageUrl} alt={course.title} />
</div>
{/if}
<div class="cio-tf-info">
<h3 class="cio-tf-title">{course.title}</h3>
<div class="cio-tf-sub">
{#if payload.design.content.showCourseTypeBadge}
<TypeBadge courseType={course.courseType} />
{/if}
<Stars rating={course.rating} ratingCount={course.ratingCount} showCount={false} />
</div>
{#if course.tags.length > 0}
<div class="cio-tf-tags-row">
{#each course.tags.slice(0, 3) as tag (tag.id)}
<span class="cio-pill">{tag.name}</span>
{/each}
</div>
{/if}
</div>
</a>
{/each}
</div>
{/if}
@@ -0,0 +1,93 @@
import {
getWidgetThemeSerifStack,
type TWidgetConfig,
type TWidgetPayload,
type TWidgetPayloadCourse
} from '@cio/utils/validation/widget';
const COURSE_TYPE_MAP: Record<string, { label: string; className: string }> = {
LIVE_CLASS: { label: 'Live class', className: 'cio-type-badge cio-type-live' },
SELF_PACED: { label: 'Self paced', className: 'cio-type-badge cio-type-self' },
COMPLIANCE: { label: 'Compliance', className: 'cio-type-badge cio-type-compliance' }
};
export function getCourseTypeMeta(courseType: string | null | undefined): { label: string; className: string } | null {
if (!courseType) return null;
return COURSE_TYPE_MAP[courseType] ?? { label: courseType, className: 'cio-type-badge cio-type-default' };
}
export function getStarSegments(rating: number | null | undefined): { filled: number; empty: number } {
if (rating == null) return { filled: 0, empty: 5 };
const rounded = Math.round(rating);
const filled = Math.max(0, Math.min(5, rounded));
return { filled, empty: 5 - filled };
}
export function buildCssVarsFromDesign(design: TWidgetConfig): string {
const shadowBlur =
design.content.shadowSize === 'lg'
? 28
: design.content.shadowSize === 'md'
? 18
: design.content.shadowSize === 'sm'
? 8
: 0;
return [
`--cio-primary:${design.colors.primaryColor}`,
`--cio-bg:${design.colors.backgroundColor}`,
`--cio-text:${design.colors.textColor}`,
`--cio-border:${design.colors.borderColor}`,
`--cio-badge:${design.colors.badgeColor}`,
`--cio-highlight:${design.colors.highlightColor}`,
`--cio-radius:${design.content.borderRadius}px`,
`--cio-font:${design.typography.fontFamily}`,
`--cio-font-serif:${getWidgetThemeSerifStack(design.themePreset)}`,
`--cio-font-scale:${design.typography.fontSizeScale}`,
`--cio-shadow:0 8px ${shadowBlur}px ${design.content.shadowColor}`
].join(';');
}
export function getCourseExcerpt(course: TWidgetPayloadCourse, maxLength = 160): string {
if (!course.description) return '';
const flat = course.description.replace(/\s+/g, ' ').trim();
if (flat.length <= maxLength) return flat;
return `${flat.slice(0, maxLength - 1).trim()}…`;
}
export function formatLessonsLine(course: TWidgetPayloadCourse): string {
const parts: string[] = [];
if (course.lessonCount) parts.push(`${course.lessonCount} lessons`);
if (course.exerciseCount) parts.push(`${course.exerciseCount} exercises`);
return parts.join(' · ');
}
/** Hostname used in UTM (custom domain or `{siteName}.classroomio.com`). */
export function getOrgDomainForUtm(organization: TWidgetPayload['organization']): string {
const custom = organization.customDomain?.trim();
if (custom) {
try {
if (custom.includes('://')) {
return new URL(custom).hostname;
}
} catch {
/* use raw host below */
}
return custom.replace(/^\/+/, '').split('/')[0] || organization.siteName;
}
const site = organization.siteName.trim();
return site ? `${site}.classroomio.com` : 'classroomio.com';
}
/** Marketing homepage link with widget + org attribution for analytics. */
export function buildPoweredByMarketingUrl(payload: TWidgetPayload): string {
const orgDomain = getOrgDomainForUtm(payload.organization);
const params = new URLSearchParams({
utm_source: 'classroomio_widget',
utm_medium: 'embed',
utm_campaign: 'powered_by',
utm_content: payload.widgetId,
utm_term: orgDomain
});
return `https://classroomio.com/?${params.toString()}`;
}