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() }), organization: z.object({ id: z.uuid(), name: z.string(), siteName: z.string(), customDomain: z.string().nullable().optional() }), orgBaseUrl: 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() }) ), versions: z.array(ZWidgetVersionRecord), planGatedFields: ZWidgetPlanGatedFields, planName: z.string().nullable() }); /** * 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 ZWidgetIdParams = z.object({ widgetId: z.uuid() }); export const ZWidgetPublicKeyParams = z.object({ publicKey: z.string().trim().min(8) }); export type TWidgetStatus = z.infer; export type TWidgetLayoutType = z.infer; export type TWidgetSelectionMode = z.infer; export type TWidgetSortBy = z.infer; export type TWidgetThemePreset = z.infer; export type TWidgetConfig = z.infer; export type TWidgetLayoutOptions = z.infer; export type TWidgetCardGridOptions = z.infer; export type TWidgetTagFilterOptions = z.infer; export type TWidgetCarouselOptions = z.infer; export type TWidgetPrimaryCourseOptions = z.infer; export type TWidgetCompactListOptions = z.infer; export type TWidgetEditorialSpotlightOptions = z.infer; export type TWidgetCategoryShelfOptions = z.infer; export type TWidgetPlanGatedFields = z.infer; export type TWidgetPayloadCourse = z.infer; export type TWidgetPayloadCategory = z.infer; export type TWidgetPayloadCourseTag = z.infer; export type TWidgetPayload = z.infer; export type TWidgetListItem = z.infer; export type TWidgetEditorCourse = z.infer; export type TWidgetDetail = z.infer; export type TCreateWidget = z.infer; export type TUpdateWidget = z.infer; export type TRollbackWidget = z.infer; 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 | 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; } }