feat: widgets and ai chat
This commit is contained in:
@@ -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>;
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user