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,109 @@
import { z } from 'zod';
import { AGENT_MODEL_IDS } from '../../agent-models';
// ─── POST /agent/chat ────────────────────────────────────────────────────────
export const ZAgentChatBody = z.object({
courseId: z.string().uuid(),
messages: z.array(z.any()), // UIMessage[] from Vercel AI SDK — validated by the SDK itself
model: z.enum(AGENT_MODEL_IDS).optional(),
context: z
.object({
lessonId: z.string().uuid().optional(),
exerciseId: z.string().uuid().optional(),
documentId: z.string().optional()
})
.optional()
});
export type TAgentChatBody = z.infer<typeof ZAgentChatBody>;
// ─── POST /agent/upload ──────────────────────────────────────────────────────
export const ZAgentUploadQuery = z.object({
courseId: z.string().uuid(),
conversationId: z.string().uuid()
});
export type TAgentUploadQuery = z.infer<typeof ZAgentUploadQuery>;
// ─── GET /agent/status ───────────────────────────────────────────────────────
export const ZAgentStatusQuery = z.object({
courseId: z.string().uuid()
});
export type TAgentStatusQuery = z.infer<typeof ZAgentStatusQuery>;
// ─── POST /agent/credits ─────────────────────────────────────────────────────
export const ZAgentCreditsBody = z.object({
amount: z.number().int().min(1)
});
export type TAgentCreditsBody = z.infer<typeof ZAgentCreditsBody>;
// ─── Supported MIME types ────────────────────────────────────────────────────
export const SUPPORTED_UPLOAD_MIME_TYPES = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation'
] as const;
export const MAX_AGENT_DOCUMENT_SIZE = 5 * 1024 * 1024; // 5MB
// ─── GET /agent/history (list conversations) ────────────────────────────────
export const ZAgentHistoryQuery = z.object({
courseId: z.string().uuid()
});
export type TAgentHistoryQuery = z.infer<typeof ZAgentHistoryQuery>;
// ─── GET /agent/history/:conversationId ──────────────────────────────────────
export const ZAgentConversationParam = z.object({
conversationId: z.string().uuid()
});
export type TAgentConversationParam = z.infer<typeof ZAgentConversationParam>;
// ─── POST /agent/history (create conversation) ──────────────────────────────
export const ZAgentConversationCreateBody = z.object({
courseId: z.string().uuid(),
title: z.string().optional()
});
export type TAgentConversationCreateBody = z.infer<typeof ZAgentConversationCreateBody>;
// ─── PUT /agent/history/:conversationId (save messages) ──────────────────────
export const ZAgentHistorySaveBody = z.object({
messages: z.array(z.any()),
title: z.string().optional()
});
export type TAgentHistorySaveBody = z.infer<typeof ZAgentHistorySaveBody>;
// ─── DELETE /agent/history/:conversationId ───────────────────────────────────
export const ZAgentHistoryDeleteParam = z.object({
conversationId: z.string().uuid()
});
export type TAgentHistoryDeleteParam = z.infer<typeof ZAgentHistoryDeleteParam>;
// ─── POST /agent/history/:conversationId/generate-title ──────────────────────
export const ZAgentGenerateTitleParam = z.object({
conversationId: z.string().uuid()
});
export const ZAgentGenerateTitleBody = z.object({
firstMessageText: z.string().min(1).max(500)
});
export type TAgentGenerateTitleParam = z.infer<typeof ZAgentGenerateTitleParam>;
export type TAgentGenerateTitleBody = z.infer<typeof ZAgentGenerateTitleBody>;
@@ -0,0 +1 @@
export * from './agent';
@@ -1,6 +1,6 @@
import * as z from 'zod';
import { ZCourseMetadata } from '../course/course';
import { ZComplianceSettings, ZCourseMetadata } from '../course/course';
import { ZExerciseQuestionTypeId } from '../exercise/exercise';
const ZSupportedLocale = z.enum(['en', 'hi', 'fr', 'pt', 'de', 'vi', 'ru', 'es', 'pl', 'da']);
@@ -22,13 +22,19 @@ export const ZCourseImportSourceReference = z.object({
});
export type TCourseImportSourceReference = z.infer<typeof ZCourseImportSourceReference>;
export const ZCourseImportDraftCourse = z.object({
title: z.string().min(1),
description: z.string().min(1),
type: z.enum(['LIVE_CLASS', 'SELF_PACED']),
locale: ZSupportedLocale.default('en'),
metadata: ZCourseMetadata.optional()
});
export const ZCourseImportDraftCourse = z
.object({
title: z.string().min(1),
description: z.string().min(1),
type: z.enum(['LIVE_CLASS', 'SELF_PACED', 'COMPLIANCE']),
locale: ZSupportedLocale.default('en'),
metadata: ZCourseMetadata.optional(),
compliance: ZComplianceSettings.optional()
})
.refine((data) => data.type !== 'COMPLIANCE' || data.compliance !== undefined, {
message: 'Compliance settings are required for COMPLIANCE courses',
path: ['compliance']
});
export type TCourseImportDraftCourse = z.infer<typeof ZCourseImportDraftCourse>;
export const ZCourseImportDraftSection = z.object({
@@ -216,19 +222,36 @@ export const ZCourseImportDraftUpdate = z.object({
});
export type TCourseImportDraftUpdate = z.infer<typeof ZCourseImportDraftUpdate>;
export const ZCourseImportDraftPublish = z.object({
export const ZCourseImportDraftPublishBase = z.object({
title: z.string().min(1).optional(),
description: z.string().min(1).optional(),
type: z.enum(['LIVE_CLASS', 'SELF_PACED']).optional(),
type: z.enum(['LIVE_CLASS', 'SELF_PACED', 'COMPLIANCE']).optional(),
metadata: ZCourseMetadata.optional(),
compliance: ZComplianceSettings.optional(),
bannerImageUrl: z.string().url().optional(),
bannerImageQuery: z.string().min(1).max(120).optional(),
generateBannerImage: z.boolean().optional()
});
export const ZCourseImportDraftPublish = ZCourseImportDraftPublishBase.refine(
(data) => data.type !== 'COMPLIANCE' || data.compliance !== undefined,
{
message: 'Compliance settings are required for COMPLIANCE courses',
path: ['compliance']
}
);
export type TCourseImportDraftPublish = z.infer<typeof ZCourseImportDraftPublish>;
export const ZCourseImportDraftPublishToCourse = ZCourseImportDraftPublish.extend({
export const ZCourseImportDraftPublishToCourseBase = ZCourseImportDraftPublishBase.extend({
courseId: z.string().min(1),
syncMode: z.enum(['merge', 'replace']).default('merge')
});
export const ZCourseImportDraftPublishToCourse = ZCourseImportDraftPublishToCourseBase.refine(
(data) => data.type !== 'COMPLIANCE' || data.compliance !== undefined,
{
message: 'Compliance settings are required for COMPLIANCE courses',
path: ['compliance']
}
);
export type TCourseImportDraftPublishToCourse = z.infer<typeof ZCourseImportDraftPublishToCourse>;
@@ -0,0 +1,37 @@
import { z } from 'zod';
export const ZCourseComplianceParam = z.object({
courseId: z.string().uuid()
});
export type TCourseComplianceParam = z.infer<typeof ZCourseComplianceParam>;
export const ZCourseComplianceLearnerParam = ZCourseComplianceParam.extend({
profileId: z.string().uuid()
});
export type TCourseComplianceLearnerParam = z.infer<typeof ZCourseComplianceLearnerParam>;
const ZOptionalProfileIds = z.array(z.string().uuid()).min(1).optional();
export const ZCourseComplianceReset = z.object({
profileIds: ZOptionalProfileIds,
dueDate: z.string().datetime()
});
export type TCourseComplianceReset = z.infer<typeof ZCourseComplianceReset>;
export const ZCourseComplianceExtend = z.object({
profileIds: ZOptionalProfileIds,
dueDate: z.string().datetime()
});
export type TCourseComplianceExtend = z.infer<typeof ZCourseComplianceExtend>;
export const ZCourseComplianceWaive = z.object({
profileIds: ZOptionalProfileIds,
waiverReason: z.string().trim().min(1).max(1000).optional(),
waiverExpiresAt: z.string().datetime().optional()
});
export type TCourseComplianceWaive = z.infer<typeof ZCourseComplianceWaive>;
+57 -9
View File
@@ -188,18 +188,57 @@ export const ZCourseContentDelete = z
sectionId: z.string().min(1).optional(),
items: z.array(ZCourseContentDeleteItem).min(1).optional()
})
.refine((data) => Boolean(data.sectionId) !== Boolean(data.items), {
message: 'Provide either sectionId or items',
path: ['sectionId']
.superRefine((data, ctx) => {
if (Boolean(data.sectionId) === Boolean(data.items)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['sectionId'],
message: 'Provide either sectionId or items'
});
}
const itemKeys = new Set<string>();
data.items?.forEach((item, index) => {
const itemKey = `${item.type}:${item.id}`;
if (itemKeys.has(itemKey)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['items', index, 'id'],
message: 'Item IDs must be unique per content type'
});
}
itemKeys.add(itemKey);
});
});
export type TCourseContentDelete = z.infer<typeof ZCourseContentDelete>;
export const ZCourseCreate = z.object({
export const ZComplianceSettings = z.object({
retakeIntervalMonths: z.number().int().min(1).max(120),
gracePeriodDays: z.number().int().min(0).max(365).optional().default(0),
reminderDaysBefore: z.array(z.number().int().min(1).max(365)).max(10).optional().default([30, 7, 1]),
isMandatory: z.boolean().optional().default(true),
framework: z.enum(['HIPAA', 'OSHA', 'SOX', 'GDPR', 'PCI_DSS', 'FERPA', 'ISO', 'CUSTOM']).nullable().optional(),
maxRetakeAttempts: z.number().int().min(1).nullable().optional(),
passingScore: z.number().int().min(0).max(100).optional().default(80)
});
export type TComplianceSettings = z.infer<typeof ZComplianceSettings>;
export const ZCourseCreateBase = z.object({
title: z.string().min(1),
description: z.string().min(1),
type: z.enum(['LIVE_CLASS', 'SELF_PACED']),
organizationId: z.string().min(1)
type: z.enum(['LIVE_CLASS', 'SELF_PACED', 'COMPLIANCE']),
organizationId: z.string().min(1),
compliance: ZComplianceSettings.optional()
});
export const ZCourseCreate = ZCourseCreateBase.refine(
(data) => data.type !== 'COMPLIANCE' || data.compliance !== undefined,
{
message: 'Compliance settings are required for COMPLIANCE courses',
path: ['compliance']
}
);
export type TCourseCreate = z.infer<typeof ZCourseCreate>;
export const ZCourseReward = z.object({
@@ -291,10 +330,10 @@ export const ZCertificationSettings = z.object({
});
export type TCertificationSettings = z.infer<typeof ZCertificationSettings>;
export const ZCourseUpdate = z.object({
export const ZCourseUpdateBase = z.object({
title: z.string().min(1).optional(),
description: z.string().min(1).optional(),
type: z.enum(['LIVE_CLASS', 'SELF_PACED']).optional(),
type: z.enum(['LIVE_CLASS', 'SELF_PACED', 'COMPLIANCE']).optional(),
logo: z.string().optional(),
slug: z.string().optional(),
isPublished: z.boolean().optional(),
@@ -303,8 +342,17 @@ export const ZCourseUpdate = z.object({
cost: z.number().int().min(0).optional(),
currency: z.enum(['NGN', 'USD']).optional(),
certificate: ZCertificationSettings.optional(),
tagIds: z.array(z.uuid()).max(100).optional()
tagIds: z.array(z.uuid()).max(100).optional(),
compliance: ZComplianceSettings.optional()
});
export const ZCourseUpdate = ZCourseUpdateBase.refine(
(data) => data.type !== 'COMPLIANCE' || data.compliance !== undefined,
{
message: 'Compliance settings are required when changing a course to COMPLIANCE',
path: ['compliance']
}
);
export type TCourseUpdate = z.infer<typeof ZCourseUpdate>;
export const ZCourseUpdateParam = z.object({
@@ -6,6 +6,7 @@
*/
export * from './course';
export * from './compliance';
export * from './section';
export * from './invite';
export { ZCourseGetParam, type TCourseGetParam } from './course';
@@ -1 +1,2 @@
export * from './stats';
export * from './login-activity';
@@ -0,0 +1,13 @@
import * as z from 'zod';
export const ZLoginActivity = z
.object({
orgId: z.string().optional(),
siteName: z.string().optional(),
days: z.coerce.number().int().min(1).max(365).default(90)
})
.refine((v) => !!(v.orgId || v.siteName), {
message: 'Either orgId or siteName is required'
});
export type TLoginActivity = z.infer<typeof ZLoginActivity>;
+2
View File
@@ -1,6 +1,7 @@
export * from './constants';
export * from './account';
export * from './agent';
export * from './attendance';
export * from './assets';
export * from './course';
@@ -17,3 +18,4 @@ export * from './submission';
export * from './dash';
export * from './tag';
export * from './program';
export * from './widget';
@@ -8,3 +8,4 @@ export * from './sso';
export * from './login-link';
export * from './token-auth';
export * from './automation-key';
export * from './search';
@@ -0,0 +1,8 @@
import * as z from 'zod';
export const ZSearchOrganization = z.object({
q: z.string().trim().min(1).max(200),
limit: z.coerce.number().int().min(1).max(20).default(5)
});
export type TSearchOrganization = z.infer<typeof ZSearchOrganization>;
@@ -1,6 +1,6 @@
import * as z from 'zod';
import { ZCourseCreate, ZCourseUpdate } from '../course/course';
import { ZCourseCreateBase, ZCourseUpdateBase } from '../course/course';
import { ZCourseImportDraftPayload } from '../course-import/course-import';
export const ZPublicApiCoursesQuery = z.object({
@@ -13,13 +13,19 @@ export const ZPublicApiCourseParam = z.object({
});
export type TPublicApiCourseParam = z.infer<typeof ZPublicApiCourseParam>;
export const ZPublicApiCreateCourse = ZCourseCreate.omit({
export const ZPublicApiCreateCourse = ZCourseCreateBase.omit({
organizationId: true
}).refine((data) => data.type !== 'COMPLIANCE' || data.compliance !== undefined, {
message: 'Compliance settings are required for COMPLIANCE courses',
path: ['compliance']
});
export type TPublicApiCreateCourse = z.infer<typeof ZPublicApiCreateCourse>;
export const ZPublicApiUpdateCourse = ZCourseUpdate.omit({
export const ZPublicApiUpdateCourse = ZCourseUpdateBase.omit({
tagIds: true
}).refine((data) => data.type !== 'COMPLIANCE' || data.compliance !== undefined, {
message: 'Compliance settings are required when changing a course to COMPLIANCE',
path: ['compliance']
});
export type TPublicApiUpdateCourse = z.infer<typeof ZPublicApiUpdateCourse>;
@@ -0,0 +1,104 @@
import type { TWidgetConfig, TWidgetThemePreset } from './widget';
/**
* Display / editorial serif stack per theme preset (pairs with sans in {@link WIDGET_THEME_GOOGLE_FONT_FAMILIES}).
*/
export const WIDGET_THEME_SERIF_STACK: Record<TWidgetThemePreset, string> = {
classroomio: "'Lora', Georgia, 'Times New Roman', serif",
graphite: "'IBM Plex Serif', Georgia, 'Times New Roman', serif",
linen: "'Libre Baskerville', Georgia, 'Times New Roman', serif",
spruce: "'Fraunces', Georgia, 'Times New Roman', serif"
};
/** Google Font family names loaded for each widget theme (sans + serif used in layouts). */
export const WIDGET_THEME_GOOGLE_FONT_FAMILIES: Record<TWidgetThemePreset, readonly string[]> = {
classroomio: ['DM Sans', 'Inter', 'Lora'],
graphite: ['IBM Plex Sans', 'IBM Plex Serif', 'Inter'],
linen: ['Source Sans 3', 'Libre Baskerville'],
spruce: ['Plus Jakarta Sans', 'Fraunces']
};
/**
* Normalize user-facing font names (from `typography.fontFamily`) to Google family names we can load.
* Keys are lowercased trimmed strings from single- or double-quoted CSS family tokens.
*/
export const WIDGET_GOOGLE_FONT_CANONICAL_NAMES: Record<string, string> = {
'dm sans': 'DM Sans',
inter: 'Inter',
lora: 'Lora',
'ibm plex sans': 'IBM Plex Sans',
'ibm plex serif': 'IBM Plex Serif',
'source sans 3': 'Source Sans 3',
'source sans3': 'Source Sans 3',
'libre baskerville': 'Libre Baskerville',
'plus jakarta sans': 'Plus Jakarta Sans',
fraunces: 'Fraunces',
manrope: 'Manrope',
'nunito sans': 'Nunito Sans',
'open sans': 'Open Sans',
'work sans': 'Work Sans',
outfit: 'Outfit',
figtree: 'Figtree',
'playfair display': 'Playfair Display',
'space grotesk': 'Space Grotesk'
};
/** CSS2 `family=Name:…` axis fragment; defaults work for most variable fonts we use. */
const FONT_GOOGLE_AXIS: Record<string, string> = {
'Libre Baskerville': 'wght@400;700',
Lora: 'wght@400;600;700',
Fraunces: 'wght@400;600;700'
};
const DEFAULT_AXIS = 'wght@400;500;600;700';
export function getWidgetThemeSerifStack(themePreset: TWidgetThemePreset): string {
return WIDGET_THEME_SERIF_STACK[themePreset];
}
/** Extract quoted `font-family` names from a CSS font stack string. */
export function extractQuotedFontFamiliesFromStack(fontFamilyCss: string): string[] {
const out: string[] = [];
let m: RegExpExecArray | null;
const re = /'([^']+)'|"([^"]+)"/g;
while ((m = re.exec(fontFamilyCss)) !== null) {
const name = (m[1] ?? m[2] ?? '').trim();
if (name) out.push(name);
}
return out;
}
/** Resolve additional Google families declared in config.typography.fontFamily. */
export function collectCustomGoogleFontFamilies(fontFamilyCss: string): Set<string> {
const set = new Set<string>();
for (const raw of extractQuotedFontFamiliesFromStack(fontFamilyCss)) {
const key = raw.trim().toLowerCase();
const canonical = WIDGET_GOOGLE_FONT_CANONICAL_NAMES[key];
if (canonical) set.add(canonical);
}
return set;
}
export function collectWidgetGoogleFontFamilies(design: TWidgetConfig): string[] {
const set = new Set<string>(WIDGET_THEME_GOOGLE_FONT_FAMILIES[design.themePreset]);
for (const f of collectCustomGoogleFontFamilies(design.typography.fontFamily)) {
set.add(f);
}
return [...set].sort((a, b) => a.localeCompare(b));
}
function familyQueryParam(name: string): string {
const axis = FONT_GOOGLE_AXIS[name] ?? DEFAULT_AXIS;
const enc = encodeURIComponent(name);
return `family=${enc}:${axis}`;
}
/**
* Returns a Google Fonts CSS2 stylesheet URL for all families needed by the widget design, or `''` if none.
*/
export function buildWidgetGoogleFontsStylesheetHref(design: TWidgetConfig): string {
const families = collectWidgetGoogleFontFamilies(design);
if (families.length === 0) return '';
const q = families.map(familyQueryParam).join('&');
return `https://fonts.googleapis.com/css2?${q}&display=swap`;
}
@@ -0,0 +1,2 @@
export * from './widget';
export * from './google-fonts';
@@ -0,0 +1,613 @@
import { PLAN } from '@cio/utils/plans';
import * as z from 'zod';
export const WIDGET_STATUS_VALUES = ['DRAFT', 'PUBLISHED', 'ARCHIVED'] as const;
export const WIDGET_LAYOUT_TYPE_VALUES = [
'card_grid',
'tag_filter',
'carousel',
'primary_course',
'compact_list',
'editorial_spotlight',
'category_shelf'
] as const;
export const WIDGET_SELECTION_MODE_VALUES = ['manual', 'published'] as const;
export const WIDGET_SORT_BY_VALUES = ['manual', 'newest', 'title'] as const;
export const WIDGET_THEME_PRESET_VALUES = ['classroomio', 'graphite', 'linen', 'spruce'] as const;
export const WIDGET_SHADOW_SIZE_VALUES = ['none', 'sm', 'md', 'lg'] as const;
export const WIDGET_TOP_BADGE_STYLE_VALUES = ['solid', 'outline'] as const;
export const WIDGET_CARD_GRID_COLUMN_VALUES = ['2', '3'] as const;
export const WIDGET_CAROUSEL_VISIBLE_CARD_VALUES = ['2', '3', '4'] as const;
export const WIDGET_COMPACT_LIST_DENSITY_VALUES = ['comfortable', 'compact'] as const;
export const WIDGET_EDITORIAL_TITLE_STYLE_VALUES = ['serif', 'sans'] as const;
export const WIDGET_TAG_FILTER_SOURCE_VALUES = ['auto', 'manual'] as const;
const ZCssColor = z.string().trim().min(1).max(64);
export const ZWidgetStatus = z.enum(WIDGET_STATUS_VALUES);
export const ZWidgetLayoutType = z.enum(WIDGET_LAYOUT_TYPE_VALUES);
export const ZWidgetSelectionMode = z.enum(WIDGET_SELECTION_MODE_VALUES);
export const ZWidgetSortBy = z.enum(WIDGET_SORT_BY_VALUES);
export const ZWidgetThemePreset = z.enum(WIDGET_THEME_PRESET_VALUES);
export const ZWidgetShadowSize = z.enum(WIDGET_SHADOW_SIZE_VALUES);
export const ZWidgetTopBadgeStyle = z.enum(WIDGET_TOP_BADGE_STYLE_VALUES);
export const ZWidgetCardGridColumns = z.enum(WIDGET_CARD_GRID_COLUMN_VALUES);
export const ZWidgetCarouselVisibleCards = z.enum(WIDGET_CAROUSEL_VISIBLE_CARD_VALUES);
export const ZWidgetCompactListDensity = z.enum(WIDGET_COMPACT_LIST_DENSITY_VALUES);
export const ZWidgetEditorialTitleStyle = z.enum(WIDGET_EDITORIAL_TITLE_STYLE_VALUES);
export const ZWidgetTagFilterSource = z.enum(WIDGET_TAG_FILTER_SOURCE_VALUES);
const DEFAULT_WIDGET_CONTENT_CONFIG = {
loadMoreLabel: 'Load more',
hideLoadMore: false,
showBorder: true,
borderWidth: 1,
borderRadius: 12,
shadowSize: 'md' as const,
shadowColor: 'rgba(15, 23, 42, 0.3)',
showCourseImage: true,
showCourseTypeBadge: true,
showLessonsCount: true,
showPrice: true,
showDescriptionExcerpt: true,
shortenLongDescription: true,
showRating: true,
showFeaturedStyle: false,
ctaLabel: 'View course'
};
const DEFAULT_WIDGET_COLORS_CONFIG = {
primaryColor: '#0f172a',
backgroundColor: '#ffffff',
textColor: '#0f172a',
badgeColor: '#e2e8f0',
borderColor: 'rgba(15, 23, 42, 0.3)',
highlightColor: '#f8fafc'
};
const DEFAULT_WIDGET_TYPOGRAPHY_CONFIG = {
fontFamily: "'DM Sans', Inter, ui-sans-serif, system-ui, sans-serif",
fontSizeScale: 1
};
const DEFAULT_WIDGET_FILTERS_CONFIG = {
includeTagIds: [] as string[],
excludeTagIds: [] as string[],
topBadgeStyle: 'solid' as const
};
const DEFAULT_WIDGET_BRANDING_CONFIG = {
showPoweredBy: true
};
const DEFAULT_WIDGET_ADVANCED_CONFIG = {
customCss: ''
};
const DEFAULT_LAYOUT_OPTIONS_CARD_GRID = {
columns: '3' as const,
maxCount: 9,
showRating: true
};
const DEFAULT_LAYOUT_OPTIONS_TAG_FILTER = {
maxCount: 12,
tagSource: 'auto' as const,
manualTagIds: [] as string[],
defaultTagId: null,
showAllOption: true
};
const DEFAULT_LAYOUT_OPTIONS_CAROUSEL = {
visibleCards: '3' as const,
maxCount: 6,
autoPlay: false,
autoPlayIntervalMs: 5000,
showDots: true,
showArrows: true,
loop: true
};
const DEFAULT_LAYOUT_OPTIONS_PRIMARY_COURSE = {
featuredCourseId: null,
eyebrowLabel: 'Featured course',
ctaLabel: 'Enroll now',
secondaryMaxCount: 3
};
const DEFAULT_LAYOUT_OPTIONS_COMPACT_LIST = {
maxCount: 5,
density: 'comfortable' as const,
showThumbnail: true,
showTags: true
};
const DEFAULT_LAYOUT_OPTIONS_EDITORIAL_SPOTLIGHT = {
mainCourseId: null,
secondaryMaxCount: 4,
titleStyle: 'serif' as const
};
const DEFAULT_LAYOUT_OPTIONS_CATEGORY_SHELF = {
categoryTagIds: [] as string[],
defaultCategoryTagId: null,
showAllTab: true,
maxPerCategory: 6
};
const DEFAULT_LAYOUT_OPTIONS = {
cardGrid: DEFAULT_LAYOUT_OPTIONS_CARD_GRID,
tagFilter: DEFAULT_LAYOUT_OPTIONS_TAG_FILTER,
carousel: DEFAULT_LAYOUT_OPTIONS_CAROUSEL,
primaryCourse: DEFAULT_LAYOUT_OPTIONS_PRIMARY_COURSE,
compactList: DEFAULT_LAYOUT_OPTIONS_COMPACT_LIST,
editorialSpotlight: DEFAULT_LAYOUT_OPTIONS_EDITORIAL_SPOTLIGHT,
categoryShelf: DEFAULT_LAYOUT_OPTIONS_CATEGORY_SHELF
};
const DEFAULT_WIDGET_CONFIG = {
themePreset: 'classroomio' as const,
sortBy: 'manual' as const,
content: DEFAULT_WIDGET_CONTENT_CONFIG,
colors: DEFAULT_WIDGET_COLORS_CONFIG,
typography: DEFAULT_WIDGET_TYPOGRAPHY_CONFIG,
filters: DEFAULT_WIDGET_FILTERS_CONFIG,
branding: DEFAULT_WIDGET_BRANDING_CONFIG,
advanced: DEFAULT_WIDGET_ADVANCED_CONFIG,
layoutOptions: DEFAULT_LAYOUT_OPTIONS
};
export const ZWidgetContentConfig = z.object({
loadMoreLabel: z.string().trim().min(1).max(64).default('Load more'),
hideLoadMore: z.boolean().default(false),
showBorder: z.boolean().default(true),
borderWidth: z.number().min(0).max(8).default(1),
borderRadius: z.number().min(0).max(32).default(12),
shadowSize: ZWidgetShadowSize.default('md'),
shadowColor: ZCssColor.default('rgba(15, 23, 42, 0.3)'),
showCourseImage: z.boolean().default(true),
showCourseTypeBadge: z.boolean().default(true),
showLessonsCount: z.boolean().default(true),
showPrice: z.boolean().default(true),
showDescriptionExcerpt: z.boolean().default(true),
shortenLongDescription: z.boolean().default(true),
showRating: z.boolean().default(true),
showFeaturedStyle: z.boolean().default(false),
ctaLabel: z.string().trim().min(1).max(64).default('View course')
});
export const ZWidgetColorsConfig = z.object({
primaryColor: ZCssColor.default('#0f172a'),
backgroundColor: ZCssColor.default('#ffffff'),
textColor: ZCssColor.default('#0f172a'),
badgeColor: ZCssColor.default('#e2e8f0'),
borderColor: ZCssColor.default('rgba(15, 23, 42, 0.3)'),
highlightColor: ZCssColor.default('#f8fafc')
});
export const ZWidgetTypographyConfig = z.object({
fontFamily: z.string().trim().min(1).max(120).default("'DM Sans', Inter, ui-sans-serif, system-ui, sans-serif"),
fontSizeScale: z.number().min(0.8).max(1.4).default(1)
});
export const ZWidgetFiltersConfig = z.object({
includeTagIds: z.array(z.uuid()).default([]),
excludeTagIds: z.array(z.uuid()).default([]),
topBadgeStyle: ZWidgetTopBadgeStyle.default('solid')
});
export const ZWidgetBrandingConfig = z.object({
showPoweredBy: z.boolean().default(true)
});
export const ZWidgetAdvancedConfig = z.object({
customCss: z.string().max(5_000).default('')
});
export const ZWidgetCardGridOptions = z.object({
columns: ZWidgetCardGridColumns.default('3'),
maxCount: z.number().int().min(3).max(24).default(9),
showRating: z.boolean().default(true)
});
export const ZWidgetTagFilterOptions = z.object({
maxCount: z.number().int().min(4).max(24).default(12),
tagSource: ZWidgetTagFilterSource.default('auto'),
manualTagIds: z.array(z.uuid()).default([]),
defaultTagId: z.uuid().nullable().default(null),
showAllOption: z.boolean().default(true)
});
export const ZWidgetCarouselOptions = z.object({
visibleCards: ZWidgetCarouselVisibleCards.default('3'),
maxCount: z.number().int().min(3).max(12).default(6),
autoPlay: z.boolean().default(false),
autoPlayIntervalMs: z.number().int().min(3000).max(10000).default(5000),
showDots: z.boolean().default(true),
showArrows: z.boolean().default(true),
loop: z.boolean().default(true)
});
export const ZWidgetPrimaryCourseOptions = z.object({
featuredCourseId: z.uuid().nullable().default(null),
eyebrowLabel: z.string().trim().min(1).max(40).default('Featured course'),
ctaLabel: z.string().trim().min(1).max(40).default('Enroll now'),
secondaryMaxCount: z.number().int().min(0).max(6).default(3)
});
export const ZWidgetCompactListOptions = z.object({
maxCount: z.number().int().min(1).max(12).default(5),
density: ZWidgetCompactListDensity.default('comfortable'),
showThumbnail: z.boolean().default(true),
showTags: z.boolean().default(true)
});
export const ZWidgetEditorialSpotlightOptions = z.object({
mainCourseId: z.uuid().nullable().default(null),
secondaryMaxCount: z.number().int().min(2).max(4).default(4),
titleStyle: ZWidgetEditorialTitleStyle.default('serif')
});
export const ZWidgetCategoryShelfOptions = z.object({
categoryTagIds: z.array(z.uuid()).max(8).default([]),
defaultCategoryTagId: z.uuid().nullable().default(null),
showAllTab: z.boolean().default(true),
maxPerCategory: z.number().int().min(3).max(9).default(6)
});
export const ZWidgetLayoutOptions = z.object({
cardGrid: ZWidgetCardGridOptions.default(DEFAULT_LAYOUT_OPTIONS_CARD_GRID),
tagFilter: ZWidgetTagFilterOptions.default(DEFAULT_LAYOUT_OPTIONS_TAG_FILTER),
carousel: ZWidgetCarouselOptions.default(DEFAULT_LAYOUT_OPTIONS_CAROUSEL),
primaryCourse: ZWidgetPrimaryCourseOptions.default(DEFAULT_LAYOUT_OPTIONS_PRIMARY_COURSE),
compactList: ZWidgetCompactListOptions.default(DEFAULT_LAYOUT_OPTIONS_COMPACT_LIST),
editorialSpotlight: ZWidgetEditorialSpotlightOptions.default(DEFAULT_LAYOUT_OPTIONS_EDITORIAL_SPOTLIGHT),
categoryShelf: ZWidgetCategoryShelfOptions.default(DEFAULT_LAYOUT_OPTIONS_CATEGORY_SHELF)
});
export const ZWidgetConfig = z.object({
themePreset: ZWidgetThemePreset.default('classroomio'),
sortBy: ZWidgetSortBy.default('manual'),
content: ZWidgetContentConfig.default(DEFAULT_WIDGET_CONTENT_CONFIG),
colors: ZWidgetColorsConfig.default(DEFAULT_WIDGET_COLORS_CONFIG),
typography: ZWidgetTypographyConfig.default(DEFAULT_WIDGET_TYPOGRAPHY_CONFIG),
filters: ZWidgetFiltersConfig.default(DEFAULT_WIDGET_FILTERS_CONFIG),
branding: ZWidgetBrandingConfig.default(DEFAULT_WIDGET_BRANDING_CONFIG),
advanced: ZWidgetAdvancedConfig.default(DEFAULT_WIDGET_ADVANCED_CONFIG),
layoutOptions: ZWidgetLayoutOptions.default(DEFAULT_LAYOUT_OPTIONS)
});
export const ZWidgetPlanGatedFields = z.object({
isPaidPlan: z.boolean(),
canUseCustomColors: z.boolean(),
canUseCustomCss: z.boolean(),
canToggleBranding: z.boolean(),
isBrandingForced: z.boolean(),
availableThemes: z.array(ZWidgetThemePreset),
selectedTheme: ZWidgetThemePreset
});
export const ZWidgetPayloadCourseTag = z.object({
id: z.uuid(),
name: z.string(),
slug: z.string(),
color: z.string()
});
export const ZWidgetPayloadCourse = z.object({
id: z.uuid(),
slug: z.string(),
title: z.string(),
description: z.string().nullable().optional(),
imageUrl: z.string().nullable().optional(),
price: z.union([z.string(), z.number()]).nullable().optional(),
lessonCount: z.number().int().nonnegative(),
exerciseCount: z.number().int().nonnegative().default(0),
rating: z.number().min(0).max(5).nullable().optional(),
ratingCount: z.number().int().nonnegative().default(0),
courseType: z.string().nullable().optional(),
createdAt: z.string(),
tags: z.array(ZWidgetPayloadCourseTag).default([]),
featured: z.boolean().default(false),
url: z.string()
});
export const ZWidgetPayloadCategory = z.object({
tagId: z.uuid(),
name: z.string(),
slug: z.string(),
color: z.string(),
courseIds: z.array(z.uuid())
});
export const ZWidgetPayloadLabels = z.object({
loadMoreLabel: z.string(),
poweredByLabel: z.string()
});
export const ZWidgetPayload = z.object({
version: z.literal('v1'),
widgetId: z.uuid(),
publicKey: z.string(),
organization: z.object({
id: z.uuid(),
name: z.string(),
siteName: z.string(),
customDomain: z.string().nullable().optional()
}),
layoutType: ZWidgetLayoutType,
selectionMode: ZWidgetSelectionMode,
design: ZWidgetConfig,
planGatedFields: ZWidgetPlanGatedFields,
labels: ZWidgetPayloadLabels,
courses: z.array(ZWidgetPayloadCourse),
tagPool: z.array(ZWidgetPayloadCourseTag).default([]),
categories: z.array(ZWidgetPayloadCategory).default([]),
timestamp: z.number().int().nonnegative()
});
export const ZWidgetListItem = z.object({
id: z.uuid(),
organizationId: z.uuid(),
name: z.string(),
status: ZWidgetStatus,
layoutType: ZWidgetLayoutType,
selectionMode: ZWidgetSelectionMode,
publicKey: z.string(),
hasUnpublishedChanges: z.boolean().default(false),
latestPublishedVersionId: z.uuid().nullable().optional(),
createdAt: z.string(),
updatedAt: z.string()
});
export const ZWidgetEditorCourse = z.object({
id: z.uuid(),
slug: z.string(),
title: z.string(),
description: z.string().nullable().optional(),
imageUrl: z.string().nullable().optional(),
isPublished: z.boolean().nullable().optional(),
price: z.union([z.string(), z.number()]).nullable().optional(),
lessonCount: z.number().int().nonnegative(),
exerciseCount: z.number().int().nonnegative().default(0),
courseType: z.string().nullable().optional(),
createdAt: z.string(),
tags: z.array(ZWidgetPayloadCourseTag).default([])
});
export const ZWidgetVersionRecord = z.object({
id: z.uuid(),
widgetId: z.uuid(),
version: z.number().int().positive(),
payloadSnapshot: ZWidgetPayload,
runtimeManifest: z.record(z.string(), z.unknown()),
rolledBackFromVersionId: z.uuid().nullable().optional(),
publishedAt: z.string(),
publishedByUserId: z.uuid()
});
export const ZWidgetDetail = z.object({
widget: ZWidgetListItem.extend({
config: ZWidgetConfig,
selectedCourseIds: z.array(z.uuid()),
embedCode: z.string(),
publicScriptUrl: z.string()
}),
availableCourses: z.array(ZWidgetEditorCourse),
availableTags: z.array(
z.object({
id: z.uuid(),
name: z.string(),
slug: z.string(),
color: z.string(),
groupId: z.uuid()
})
),
previewPayload: ZWidgetPayload.nullable(),
versions: z.array(ZWidgetVersionRecord),
planGatedFields: ZWidgetPlanGatedFields
});
/**
* Per-layout cross-field rules. Keep this in sync with the editor UI gating
* in `widget-editor.svelte` so users get the same feedback there.
*/
function applyLayoutRefinements(
data: {
layoutType?: TWidgetLayoutType;
selectionMode?: TWidgetSelectionMode;
selectedCourseIds?: string[];
config?: TWidgetConfig;
},
ctx: z.RefinementCtx
) {
if (!data.layoutType || !data.config) return;
const layoutOptions = data.config.layoutOptions;
const selectedIds = new Set(data.selectedCourseIds ?? []);
const isManual = data.selectionMode === 'manual';
if (data.layoutType === 'primary_course') {
const featuredId = layoutOptions.primaryCourse.featuredCourseId;
if (!featuredId) {
ctx.addIssue({
code: 'custom',
path: ['config', 'layoutOptions', 'primaryCourse', 'featuredCourseId'],
message: 'A featured course is required for the Primary course layout.'
});
} else if (isManual && !selectedIds.has(featuredId)) {
ctx.addIssue({
code: 'custom',
path: ['config', 'layoutOptions', 'primaryCourse', 'featuredCourseId'],
message: 'The featured course must be one of the selected courses.'
});
}
}
if (data.layoutType === 'editorial_spotlight') {
const mainId = layoutOptions.editorialSpotlight.mainCourseId;
if (!mainId) {
ctx.addIssue({
code: 'custom',
path: ['config', 'layoutOptions', 'editorialSpotlight', 'mainCourseId'],
message: 'A main course is required for the Editorial spotlight layout.'
});
} else if (isManual && !selectedIds.has(mainId)) {
ctx.addIssue({
code: 'custom',
path: ['config', 'layoutOptions', 'editorialSpotlight', 'mainCourseId'],
message: 'The main course must be one of the selected courses.'
});
}
}
if (data.layoutType === 'category_shelf' && !isManual) {
if (layoutOptions.categoryShelf.categoryTagIds.length === 0) {
ctx.addIssue({
code: 'custom',
path: ['config', 'layoutOptions', 'categoryShelf', 'categoryTagIds'],
message: 'Add at least one category tag for the Category shelf layout.'
});
}
}
}
export const ZCreateWidget = z
.object({
name: z.string().trim().min(1).max(120),
layoutType: ZWidgetLayoutType.default('card_grid'),
selectionMode: ZWidgetSelectionMode.default('manual'),
config: ZWidgetConfig.default(DEFAULT_WIDGET_CONFIG),
selectedCourseIds: z.array(z.uuid()).default([])
})
.superRefine((data, ctx) => applyLayoutRefinements(data, ctx));
export const ZUpdateWidget = z
.object({
name: z.string().trim().min(1).max(120).optional(),
layoutType: ZWidgetLayoutType.optional(),
selectionMode: ZWidgetSelectionMode.optional(),
config: ZWidgetConfig.optional(),
selectedCourseIds: z.array(z.uuid()).optional()
})
.superRefine((data, ctx) => applyLayoutRefinements(data, ctx));
export const ZRollbackWidget = z.object({
versionId: z.uuid()
});
export const ZPreviewWidget = z.object({
layoutType: ZWidgetLayoutType.optional(),
selectionMode: ZWidgetSelectionMode.optional(),
config: ZWidgetConfig.optional(),
selectedCourseIds: z.array(z.uuid()).optional()
});
export const ZWidgetIdParams = z.object({
widgetId: z.uuid()
});
export const ZWidgetPublicKeyParams = z.object({
publicKey: z.string().trim().min(8)
});
export type TWidgetStatus = z.infer<typeof ZWidgetStatus>;
export type TWidgetLayoutType = z.infer<typeof ZWidgetLayoutType>;
export type TWidgetSelectionMode = z.infer<typeof ZWidgetSelectionMode>;
export type TWidgetSortBy = z.infer<typeof ZWidgetSortBy>;
export type TWidgetThemePreset = z.infer<typeof ZWidgetThemePreset>;
export type TWidgetConfig = z.infer<typeof ZWidgetConfig>;
export type TWidgetLayoutOptions = z.infer<typeof ZWidgetLayoutOptions>;
export type TWidgetCardGridOptions = z.infer<typeof ZWidgetCardGridOptions>;
export type TWidgetTagFilterOptions = z.infer<typeof ZWidgetTagFilterOptions>;
export type TWidgetCarouselOptions = z.infer<typeof ZWidgetCarouselOptions>;
export type TWidgetPrimaryCourseOptions = z.infer<typeof ZWidgetPrimaryCourseOptions>;
export type TWidgetCompactListOptions = z.infer<typeof ZWidgetCompactListOptions>;
export type TWidgetEditorialSpotlightOptions = z.infer<typeof ZWidgetEditorialSpotlightOptions>;
export type TWidgetCategoryShelfOptions = z.infer<typeof ZWidgetCategoryShelfOptions>;
export type TWidgetPlanGatedFields = z.infer<typeof ZWidgetPlanGatedFields>;
export type TWidgetPayloadCourse = z.infer<typeof ZWidgetPayloadCourse>;
export type TWidgetPayloadCategory = z.infer<typeof ZWidgetPayloadCategory>;
export type TWidgetPayloadCourseTag = z.infer<typeof ZWidgetPayloadCourseTag>;
export type TWidgetPayload = z.infer<typeof ZWidgetPayload>;
export type TWidgetListItem = z.infer<typeof ZWidgetListItem>;
export type TWidgetEditorCourse = z.infer<typeof ZWidgetEditorCourse>;
export type TWidgetDetail = z.infer<typeof ZWidgetDetail>;
export type TCreateWidget = z.infer<typeof ZCreateWidget>;
export type TUpdateWidget = z.infer<typeof ZUpdateWidget>;
export type TRollbackWidget = z.infer<typeof ZRollbackWidget>;
export type TPreviewWidget = z.infer<typeof ZPreviewWidget>;
export function getDefaultWidgetConfig(): TWidgetConfig {
return ZWidgetConfig.parse(DEFAULT_WIDGET_CONFIG);
}
export function isPaidWidgetPlan(planName?: string | null): boolean {
return planName === PLAN.EARLY_ADOPTER || planName === PLAN.ENTERPRISE;
}
export function resolveWidgetPlanGatedFields(
planName?: string | null,
selectedTheme?: TWidgetThemePreset
): TWidgetPlanGatedFields {
const isPaidPlan = isPaidWidgetPlan(planName);
const availableThemes: TWidgetThemePreset[] = isPaidPlan ? [...WIDGET_THEME_PRESET_VALUES] : ['classroomio'];
const themeCandidate = selectedTheme ?? 'classroomio';
const selectedResolvedTheme = availableThemes.includes(themeCandidate) ? themeCandidate : 'classroomio';
return {
isPaidPlan,
canUseCustomColors: isPaidPlan,
canUseCustomCss: isPaidPlan,
canToggleBranding: isPaidPlan,
isBrandingForced: !isPaidPlan,
availableThemes,
selectedTheme: selectedResolvedTheme
};
}
export function normalizeWidgetConfig(config?: Partial<TWidgetConfig> | null, planName?: string | null): TWidgetConfig {
const parsedConfig = ZWidgetConfig.parse(config ?? {});
const planGatedFields = resolveWidgetPlanGatedFields(planName, parsedConfig.themePreset);
return {
...parsedConfig,
themePreset: planGatedFields.selectedTheme,
branding: {
...parsedConfig.branding,
showPoweredBy: planGatedFields.isBrandingForced ? true : parsedConfig.branding.showPoweredBy
},
advanced: {
...parsedConfig.advanced,
customCss: planGatedFields.canUseCustomCss ? parsedConfig.advanced.customCss : ''
},
colors: planGatedFields.canUseCustomColors ? parsedConfig.colors : getDefaultWidgetConfig().colors
};
}
/**
* Returns the maximum number of courses that should be requested/rendered for a
* given layout. Mirrors the rules enforced server-side in `buildWidgetPayload`.
*/
export function getLayoutCourseLimit(layoutType: TWidgetLayoutType, options: TWidgetLayoutOptions): number {
switch (layoutType) {
case 'card_grid':
return options.cardGrid.maxCount;
case 'tag_filter':
return options.tagFilter.maxCount;
case 'carousel':
return options.carousel.maxCount;
case 'primary_course':
return 1 + options.primaryCourse.secondaryMaxCount;
case 'compact_list':
return options.compactList.maxCount;
case 'editorial_spotlight':
return 1 + options.editorialSpotlight.secondaryMaxCount;
case 'category_shelf': {
const tagCount = Math.max(options.categoryShelf.categoryTagIds.length, 1);
return tagCount * options.categoryShelf.maxPerCategory;
}
default:
return 24;
}
}