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
+1
View File
@@ -14,6 +14,7 @@
},
"dependencies": {
"@cio/ui": "workspace:*",
"@cio/utils": "workspace:*",
"svelte-inview": "^4.0.4",
"svelte-motion": "^0.12.2"
},
@@ -0,0 +1,174 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { ChatTextarea } from '@cio/ui';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Molecules/ChatTextarea',
component: ChatTextarea,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
const sampleItems = [
{ id: '1', label: 'Introduction to Programming', type: 'lesson' },
{ id: '2', label: 'Variables and Data Types', type: 'lesson' },
{ id: '3', label: 'Quiz: Variables', type: 'exercise' },
{ id: '4', label: 'Control Flow', type: 'lesson' },
{ id: '5', label: 'Practice: Loops', type: 'exercise' },
{ id: '6', label: 'Functions and Scope', type: 'lesson' },
{ id: '7', label: 'Final Assessment', type: 'exercise' }
];
</script>
<script lang="ts">
import { LessonIcon, ExerciseIcon } from '@cio/ui/custom/moving-icons';
import { Button } from '@cio/ui/base/button';
import SendIcon from '@lucide/svelte/icons/send';
import PaperclipIcon from '@lucide/svelte/icons/paperclip';
import SquareIcon from '@lucide/svelte/icons/square';
let value = $state('');
let sentMessages: string[] = $state([]);
let isStreaming = $state(false);
const MENTION_REGEX = /@\[([^\]]+)\]\((lesson|exercise):([a-zA-Z0-9_-]+)\)/g;
function renderMentionHtml(text: string): string {
const escaped = text.replace(/</g, '&lt;').replace(/>/g, '&gt;');
return escaped.replace(MENTION_REGEX, (_match, title, type) => {
const emoji = type === 'exercise' ? '📝' : '📖';
return `<span style="background: hsl(var(--primary) / 0.15); padding: 1px 6px; border-radius: 4px; font-weight: 500;">${emoji} ${title}</span>`;
});
}
function handleSend() {
if (!value.trim()) return;
sentMessages = [...sentMessages, value];
value = '';
}
</script>
<Story name="Default">
{#snippet template()}
<div style="width: 360px;">
<ChatTextarea
bind:value
mentionItems={sampleItems}
onSubmit={handleSend}
placeholder="Ask the AI assistant... (type @ to mention)"
typeLabel={(item) => (item.type === 'exercise' ? 'Exercise' : 'Lesson')}
emptyMessage="No matching content"
>
{#snippet icon({ item })}
{#if item.type === 'exercise'}
<ExerciseIcon size={14} />
{:else}
<LessonIcon size={14} />
{/if}
{/snippet}
{#snippet actions()}
<button class="ui:text-muted-foreground hover:ui:bg-muted ui:rounded-md ui:p-1.5 ui:transition-colors">
<PaperclipIcon size={16} />
</button>
<div class="ui:flex-1"></div>
<Button size="icon" variant="default" onclick={handleSend} disabled={!value.trim()} class="ui:size-7">
<SendIcon size={14} />
</Button>
{/snippet}
</ChatTextarea>
<p class="ui:text-muted-foreground ui:mt-2 ui:text-xs">
Press Enter to send. Current value: <code class="ui:text-xs">{value || '(empty)'}</code>
</p>
</div>
{/snippet}
</Story>
<Story name="With Sent Messages">
{#snippet template()}
<div
style="width: 380px; border: 1px solid hsl(var(--border)); border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; height: 400px; background: hsl(var(--background));"
>
<div style="flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px;">
{#if sentMessages.length === 0}
<p class="ui:text-muted-foreground" style="text-align: center; font-size: 13px; margin-top: 40px;">
Type <kbd class="ui:bg-muted" style="padding: 1px 5px; border-radius: 4px; font-size: 12px;">@</kbd> to mention
a lesson or exercise, then press Enter to send.
</p>
{/if}
{#each sentMessages as msg, index (index)}
<div
style="align-self: flex-end; max-width: 85%; background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); border-radius: 8px; padding: 8px 12px; font-size: 14px;"
>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html renderMentionHtml(msg)}
</div>
{/each}
</div>
<div style="border-top: 1px solid hsl(var(--border)); padding: 8px;">
<ChatTextarea
bind:value
mentionItems={sampleItems}
onSubmit={handleSend}
placeholder="Ask the AI assistant..."
typeLabel={(item) => (item.type === 'exercise' ? 'Exercise' : 'Lesson')}
emptyMessage="No matching content"
>
{#snippet icon({ item })}
{#if item.type === 'exercise'}
<ExerciseIcon size={14} />
{:else}
<LessonIcon size={14} />
{/if}
{/snippet}
{#snippet actions()}
<button class="ui:text-muted-foreground hover:ui:bg-muted ui:rounded-md ui:p-1.5 ui:transition-colors">
<PaperclipIcon size={16} />
</button>
<div class="ui:flex-1"></div>
<Button size="icon" variant="default" onclick={handleSend} disabled={!value.trim()} class="ui:size-7">
<SendIcon size={14} />
</Button>
{/snippet}
</ChatTextarea>
</div>
</div>
{/snippet}
</Story>
<Story name="Without Mentions">
{#snippet template()}
<div style="width: 360px;">
<ChatTextarea bind:value placeholder="Type a message..." onSubmit={handleSend} />
</div>
{/snippet}
</Story>
<Story name="Disabled">
{#snippet template()}
<div style="width: 360px;">
<ChatTextarea value="Streaming response..." disabled placeholder="Ask the AI assistant...">
{#snippet actions()}
<button class="ui:text-muted-foreground ui:rounded-md ui:p-1.5 ui:opacity-50" disabled>
<PaperclipIcon size={16} />
</button>
<div class="ui:flex-1"></div>
<Button size="icon" variant="outline" class="ui:size-7">
<SquareIcon size={12} />
</Button>
{/snippet}
</ChatTextarea>
</div>
{/snippet}
</Story>
@@ -0,0 +1 @@
export const FIELDS = ['class', 'placeholder', 'disabled', 'emptyMessage'] as string[];
@@ -0,0 +1,135 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import * as Field from '@cio/ui/base/field';
import { CheckboxOptionCard, CheckboxOptionCardGroup } from '@cio/ui/custom/checkbox-option-card';
import { Badge } from '@cio/ui/base/badge';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Molecules/CheckboxOptionCard',
component: CheckboxOptionCard,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
let selectedCourses = $state<string[]>(['intro-html']);
let selectedFeatures = $state<string[]>(['certificates', 'live-classes']);
let selectedAudiences = $state<string[]>([]);
const courseOptions = [
{
id: 'intro-html',
title: 'Introduction to HTML',
description: '12 lessons · Self-paced',
value: 'intro-html'
},
{
id: 'css-fundamentals',
title: 'CSS Fundamentals',
description: '18 lessons · Self-paced',
value: 'css-fundamentals'
},
{
id: 'js-essentials',
title: 'JavaScript Essentials',
description: '24 lessons · Live class',
value: 'js-essentials'
},
{
id: 'svelte-bootcamp',
title: 'Svelte Bootcamp',
description: 'Coming soon',
value: 'svelte-bootcamp',
disabled: true
}
];
const featureOptions = [
{
id: 'certificates',
title: 'Certificates',
description: 'Issue completion certificates to students.',
value: 'certificates'
},
{
id: 'live-classes',
title: 'Live classes',
description: 'Schedule and host real-time lessons.',
value: 'live-classes'
},
{
id: 'compliance',
title: 'Compliance tracking',
description: 'Track required training across teams.',
value: 'compliance'
}
];
const audienceOptions = [
{ id: 'students', title: 'Students', description: 'Anyone enrolled in a course.', value: 'students' },
{ id: 'tutors', title: 'Tutors', description: 'Course facilitators and graders.', value: 'tutors' },
{ id: 'admins', title: 'Admins', description: 'Organization administrators.', value: 'admins' }
];
</script>
<Story name="Course Picker (stacked horizontally)">
{#snippet template()}
<div class="ui:w-full ui:max-w-xl">
<Field.Description class="ui:mb-4 ui:block">
Pick one or more courses. Each card stacks vertically and lays out title and checkbox horizontally.
</Field.Description>
<CheckboxOptionCardGroup options={courseOptions} bind:value={selectedCourses} class="ui:grid-cols-1" />
<p class="ui:mt-3 ui:text-muted-foreground ui:text-sm">Selected: {selectedCourses.join(', ') || 'none'}</p>
</div>
{/snippet}
</Story>
<Story name="Feature Toggles (Group)">
{#snippet template()}
<div class="ui:w-full ui:max-w-xl">
<Field.Description class="ui:mb-4 ui:block">
Toggle one or more product features. Bound value is an array of selected option values.
</Field.Description>
<CheckboxOptionCardGroup options={featureOptions} bind:value={selectedFeatures} />
<p class="ui:mt-3 ui:text-muted-foreground ui:text-sm">Selected: {selectedFeatures.join(', ') || 'none'}</p>
</div>
{/snippet}
</Story>
<Story name="With Disabled Option and Title Suffix">
{#snippet template()}
<div class="ui:w-full ui:max-w-xl">
<Field.Description class="ui:mb-4 ui:block">
Use the <code>titleSuffix(option)</code> snippet to render per-option content.
</Field.Description>
<CheckboxOptionCardGroup options={courseOptions} bind:value={selectedCourses}>
{#snippet titleSuffix(option)}
{#if option.disabled}
<Badge variant="secondary" class="ui:text-xs">Coming soon</Badge>
{/if}
{/snippet}
</CheckboxOptionCardGroup>
</div>
{/snippet}
</Story>
<Story name="Custom Columns (Group class override)">
{#snippet template()}
<div class="ui:w-full ui:max-w-3xl">
<Field.Description class="ui:mb-4 ui:block">
Override grid with the <code>class</code> prop on <code>CheckboxOptionCardGroup</code>.
</Field.Description>
<CheckboxOptionCardGroup
options={audienceOptions}
bind:value={selectedAudiences}
class="ui:grid-cols-1 ui:sm:grid-cols-2 ui:lg:grid-cols-3"
/>
<p class="ui:mt-3 ui:text-muted-foreground ui:text-sm">Selected: {selectedAudiences.join(', ') || 'none'}</p>
</div>
{/snippet}
</Story>
@@ -0,0 +1,2 @@
/** Props shown in Storybook controls (class overrides layout/columns per card) */
export const FIELDS = ['class'] as string[];
@@ -0,0 +1 @@
export const FIELDS = ['class'] as string[];
@@ -0,0 +1,94 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { MentionPopover } from '@cio/ui';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Molecules/MentionPopover',
component: MentionPopover,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
const sampleItems = [
{ id: '1', label: 'Introduction to Programming', type: 'lesson' },
{ id: '2', label: 'Variables and Data Types', type: 'lesson' },
{ id: '3', label: 'Quiz: Variables', type: 'exercise' },
{ id: '4', label: 'Control Flow', type: 'lesson' },
{ id: '5', label: 'Practice: Loops', type: 'exercise' },
{ id: '6', label: 'Functions and Scope', type: 'lesson' },
{ id: '7', label: 'Final Assessment', type: 'exercise' }
];
</script>
<script lang="ts">
import { LessonIcon, ExerciseIcon } from '@cio/ui/custom/moving-icons';
let lastSelected = $state('');
</script>
<Story name="Default">
{#snippet template()}
<MentionPopover
items={sampleItems}
query=""
selectedIndex={0}
onSelect={(item) => (lastSelected = item.label)}
typeLabel={(item) => (item.type === 'exercise' ? 'Exercise' : 'Lesson')}
>
{#snippet icon({ item })}
{#if item.type === 'exercise'}
<ExerciseIcon size={14} />
{:else}
<LessonIcon size={14} />
{/if}
{/snippet}
</MentionPopover>
{#if lastSelected}
<p class="ui:mt-3 ui:text-muted-foreground ui:text-sm">Selected: {lastSelected}</p>
{/if}
{/snippet}
</Story>
<Story name="Filtered with Highlight">
{#snippet template()}
<MentionPopover
items={sampleItems}
query="Quiz"
selectedIndex={0}
onSelect={(item) => (lastSelected = item.label)}
typeLabel={(item) => (item.type === 'exercise' ? 'Exercise' : 'Lesson')}
>
{#snippet icon({ item })}
{#if item.type === 'exercise'}
<ExerciseIcon size={14} />
{:else}
<LessonIcon size={14} />
{/if}
{/snippet}
</MentionPopover>
{/snippet}
</Story>
<Story name="No Results">
{#snippet template()}
<MentionPopover
items={sampleItems}
query="zzzzz"
selectedIndex={0}
onSelect={() => {}}
emptyMessage="No matching content"
/>
{/snippet}
</Story>
<Story name="Without Icons or Type Labels">
{#snippet template()}
<MentionPopover items={sampleItems} query="" selectedIndex={2} onSelect={(item) => (lastSelected = item.label)} />
{/snippet}
</Story>
@@ -30,6 +30,7 @@
'Unlimited Q/A',
'20 Students',
'AI Course Builder (no video upload)',
'50K AI credits / month',
'ClassroomIO branding'
],
CTA: {
@@ -0,0 +1 @@
export const FIELDS = ['payload'] as string[];
@@ -0,0 +1,312 @@
import {
getDefaultWidgetConfig,
type TWidgetLayoutType,
type TWidgetPayload,
type TWidgetPayloadCategory,
type TWidgetPayloadCourse,
type TWidgetPayloadCourseTag
} from '@cio/utils/validation/widget';
const TAGS: TWidgetPayloadCourseTag[] = [
{ id: '11111111-1111-4111-8111-111111111111', name: 'Compliance', slug: 'compliance', color: '#16a34a' },
{ id: '22222222-2222-4222-8222-222222222222', name: 'Math', slug: 'math', color: '#ef4444' },
{ id: '33333333-3333-4333-8333-333333333333', name: 'AI', slug: 'ai', color: '#6366f1' },
{ id: '44444444-4444-4444-8444-444444444444', name: 'Certificate', slug: 'certificate', color: '#f59e0b' },
{ id: '55555555-5555-4555-8555-555555555555', name: 'Live', slug: 'live', color: '#0ea5e9' },
{ id: '66666666-6666-4666-8666-666666666666', name: 'Design', slug: 'design', color: '#ec4899' },
{ id: '77777777-7777-4777-8777-777777777777', name: 'Engineering', slug: 'engineering', color: '#14b8a6' },
{ id: '88888888-8888-4888-8888-888888888888', name: 'Leadership', slug: 'leadership', color: '#8b5cf6' }
];
/** Stable Unsplash thumbnails (signed by Unsplash, no API key needed) at 800×500. */
function unsplash(seed: string): string {
return `https://images.unsplash.com/${seed}?auto=format&fit=crop&w=800&h=500&q=70`;
}
function makeCourse(
partial: Partial<TWidgetPayloadCourse> & { id: string; title: string; courseType: string }
): TWidgetPayloadCourse {
return {
id: partial.id,
slug: partial.id,
title: partial.title,
description:
partial.description ??
'A structured course covering core concepts, applied exercises, and a final assessment to lock in mastery.',
imageUrl: partial.imageUrl ?? null,
price: partial.price ?? 'Free',
lessonCount: partial.lessonCount ?? 12,
exerciseCount: partial.exerciseCount ?? 4,
rating: partial.rating ?? 4.4,
ratingCount: partial.ratingCount ?? 38,
courseType: partial.courseType,
createdAt: partial.createdAt ?? new Date().toISOString(),
tags: partial.tags ?? [],
featured: partial.featured ?? false,
url: partial.url ?? `https://demo.classroomio.com/course/${partial.id}`
};
}
const COURSES: TWidgetPayloadCourse[] = [
makeCourse({
id: 'aaaaaaa1-1111-4111-8111-aaaaaaaaaaaa',
title: 'SaaS Security & Compliance Essentials',
description:
'Stand up a SOC 2-ready posture in weeks: scope, controls, evidence collection, vendor reviews, and audit prep — explained for engineers and ops leads.',
courseType: 'COMPLIANCE',
rating: 4.0,
ratingCount: 28,
lessonCount: 14,
exerciseCount: 4,
price: '$199',
imageUrl: unsplash('photo-1518770660439-4636190af475'),
tags: [TAGS[0], TAGS[3]]
}),
makeCourse({
id: 'aaaaaaa2-2222-4222-8222-aaaaaaaaaaab',
title: 'Geometry: Shapes, Proofs, and Space',
description:
'Twenty-five hands-on lessons on Euclidean and analytic geometry, with proof-writing drills and visual intuition for transformations and loci.',
courseType: 'SELF_PACED',
rating: 4.8,
ratingCount: 112,
lessonCount: 25,
exerciseCount: 9,
price: 'Free',
imageUrl: unsplash('photo-1509228468518-180dd4864904'),
tags: [TAGS[1], TAGS[3]]
}),
makeCourse({
id: 'aaaaaaa3-3333-4333-8333-aaaaaaaaaaac',
title: 'Trigonometry Rebuild: A Detailed Refresher',
description:
'Rebuild your trig instincts from the unit circle up. Identities, graphs, inverse functions, and applied problems with worked solutions.',
courseType: 'SELF_PACED',
rating: 4.2,
ratingCount: 47,
lessonCount: 7,
exerciseCount: 4,
price: '$49',
imageUrl: unsplash('photo-1503676260728-1c00da094a0b'),
tags: [TAGS[1], TAGS[3]]
}),
makeCourse({
id: 'aaaaaaa4-4444-4444-8444-aaaaaaaaaaad',
title: 'AI Ethics & Responsible Use in Production',
description:
'A practical, case-based curriculum for product teams shipping LLM features. Bias audits, eval harnesses, escalation paths, and red-team exercises.',
courseType: 'LIVE_CLASS',
rating: 4.9,
ratingCount: 203,
lessonCount: 9,
exerciseCount: 2,
price: '$299',
imageUrl: unsplash('photo-1677442136019-21780ecad995'),
tags: [TAGS[2], TAGS[4], TAGS[3]]
}),
makeCourse({
id: 'aaaaaaa5-5555-4555-8555-aaaaaaaaaaae',
title: 'Data Privacy Fundamentals (GDPR & CCPA)',
description:
'Map data flows, write defensible DPIAs, and configure consent so legal sleeps at night. Built for PMs, engineers, and customer-facing teams.',
courseType: 'COMPLIANCE',
rating: 4.3,
ratingCount: 61,
lessonCount: 11,
exerciseCount: 3,
price: '$149',
imageUrl: unsplash('photo-1554224155-6726b3ff858f'),
tags: [TAGS[0]]
}),
makeCourse({
id: 'aaaaaaa6-6666-4666-8666-aaaaaaaaaaaf',
title: 'Business Communication for Hybrid Teams',
description:
'Run sharper standups, write briefs that stick, and present decisions to execs. Six live workshops with peer feedback and rubrics.',
courseType: 'LIVE_CLASS',
rating: 4.1,
ratingCount: 39,
lessonCount: 12,
exerciseCount: 5,
price: '$129',
imageUrl: unsplash('photo-1552664730-d307ca884978'),
tags: [TAGS[4], TAGS[3], TAGS[7]]
}),
makeCourse({
id: 'aaaaaaa7-7777-4777-8777-aaaaaaaaaaa1',
title: 'Design Systems with Tokens & Figma',
description:
'From color ramps to multi-brand theming. Build a token pipeline that survives team changes, with hands-on Figma-to-code workflows.',
courseType: 'SELF_PACED',
rating: 4.7,
ratingCount: 87,
lessonCount: 18,
exerciseCount: 6,
price: '$179',
imageUrl: unsplash('photo-1581291518633-83b4ebd1d83e'),
tags: [TAGS[5], TAGS[3]]
}),
makeCourse({
id: 'aaaaaaa8-8888-4888-8888-aaaaaaaaaaa2',
title: 'TypeScript for Production Engineers',
description:
'Stop fighting the inferencer. Generics, type guards, conditional types, and migration patterns for sprawling JavaScript codebases.',
courseType: 'SELF_PACED',
rating: 4.6,
ratingCount: 156,
lessonCount: 22,
exerciseCount: 12,
price: '$229',
imageUrl: unsplash('photo-1517694712202-14dd9538aa97'),
tags: [TAGS[6], TAGS[3]]
}),
makeCourse({
id: 'aaaaaaa9-9999-4999-8999-aaaaaaaaaaa3',
title: 'Engineering Management 101',
description:
'The transition from staff engineer to engineering manager: 1:1s, performance feedback, hiring loops, and the calendar discipline nobody warns you about.',
courseType: 'LIVE_CLASS',
rating: 4.5,
ratingCount: 92,
lessonCount: 10,
exerciseCount: 4,
price: '$249',
imageUrl: unsplash('photo-1521737711867-e3b97375f902'),
tags: [TAGS[7], TAGS[4], TAGS[3]]
}),
makeCourse({
id: 'aaaaaaab-aaaa-4aaa-8aaa-aaaaaaaaaaa4',
title: 'Postgres Performance for Application Teams',
description:
'Indexes that earn their keep, EXPLAIN ANALYZE without the panic, and the partitioning strategies that keep your p99 honest.',
courseType: 'SELF_PACED',
rating: 4.8,
ratingCount: 64,
lessonCount: 16,
exerciseCount: 7,
price: '$159',
imageUrl: unsplash('photo-1544383835-bda2bc66a55d'),
tags: [TAGS[6]]
}),
makeCourse({
id: 'aaaaaaac-bbbb-4bbb-8bbb-aaaaaaaaaaa5',
title: 'Practical Calculus for Data Practitioners',
description:
'Limits, derivatives, integrals — taught through gradient descent, ROC curves, and the math that actually shows up in your notebooks.',
courseType: 'SELF_PACED',
rating: 4.4,
ratingCount: 41,
lessonCount: 14,
exerciseCount: 8,
price: '$99',
imageUrl: unsplash('photo-1635070041078-e363dbe005cb'),
tags: [TAGS[1], TAGS[2]]
}),
makeCourse({
id: 'aaaaaaad-cccc-4ccc-8ccc-aaaaaaaaaaa6',
title: 'Brand Visual Identity Workshop',
description:
'A four-week studio: research, moodboards, logo systems, and a packaged brand guideline ready for client handoff.',
courseType: 'LIVE_CLASS',
rating: 4.6,
ratingCount: 73,
lessonCount: 8,
exerciseCount: 3,
price: '$219',
imageUrl: unsplash('photo-1561070791-2526d30994b8'),
tags: [TAGS[5], TAGS[4]]
})
];
const courseIdsByTag = new Map<string, string[]>();
for (const tag of TAGS) {
courseIdsByTag.set(
tag.id,
COURSES.filter((course) => course.tags.some((courseTag) => courseTag.id === tag.id)).map((course) => course.id)
);
}
const CATEGORIES: TWidgetPayloadCategory[] = TAGS.filter((tag) => (courseIdsByTag.get(tag.id)?.length ?? 0) > 0).map(
(tag) => ({
tagId: tag.id,
name: tag.name,
slug: tag.slug,
color: tag.color,
courseIds: courseIdsByTag.get(tag.id) ?? []
})
);
export function makeFixturePayload(layoutType: TWidgetLayoutType): TWidgetPayload {
const config = getDefaultWidgetConfig();
if (layoutType === 'primary_course') {
config.layoutOptions.primaryCourse.featuredCourseId = COURSES[3].id;
config.layoutOptions.primaryCourse.eyebrowLabel = 'Featured course';
config.layoutOptions.primaryCourse.ctaLabel = 'Enroll today';
}
if (layoutType === 'editorial_spotlight') {
config.layoutOptions.editorialSpotlight.mainCourseId = COURSES[3].id;
}
if (layoutType === 'category_shelf') {
config.layoutOptions.categoryShelf.categoryTagIds = CATEGORIES.map((cat) => cat.tagId);
config.layoutOptions.categoryShelf.maxPerCategory = 4;
}
if (layoutType === 'card_grid') {
config.layoutOptions.cardGrid.maxCount = 9;
}
if (layoutType === 'tag_filter') {
config.layoutOptions.tagFilter.maxCount = 12;
}
if (layoutType === 'carousel') {
config.layoutOptions.carousel.maxCount = 8;
}
if (layoutType === 'compact_list') {
config.layoutOptions.compactList.maxCount = 6;
}
const featuredId =
layoutType === 'primary_course'
? config.layoutOptions.primaryCourse.featuredCourseId
: layoutType === 'editorial_spotlight'
? config.layoutOptions.editorialSpotlight.mainCourseId
: null;
const orderedCourses = featuredId
? [
COURSES.find((course) => course.id === featuredId)!,
...COURSES.filter((course) => course.id !== featuredId)
].map((course, index) => ({ ...course, featured: index === 0 }))
: COURSES;
return {
version: 'v1',
widgetId: '99999999-9999-4999-8999-999999999999',
publicKey: 'wgt_storybook_demo_key',
organization: {
id: 'aaaaaaaa-0000-4000-8000-aaaaaaaa0000',
name: 'Acme Academy',
siteName: 'acme',
customDomain: null
},
layoutType,
selectionMode: 'manual',
design: config,
planGatedFields: {
isPaidPlan: true,
canUseCustomColors: true,
canUseCustomCss: true,
canToggleBranding: true,
isBrandingForced: false,
availableThemes: ['classroomio', 'graphite', 'linen', 'spruce'],
selectedTheme: 'classroomio'
},
labels: {
loadMoreLabel: 'Load more',
poweredByLabel: 'Powered by Classroomio'
},
courses: orderedCourses,
tagPool: layoutType === 'tag_filter' ? TAGS : [],
categories: layoutType === 'category_shelf' ? CATEGORIES : [],
timestamp: Date.now()
};
}
@@ -0,0 +1,95 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import { onMount, onDestroy } from 'svelte';
import { CourseWidget, widgetStyles } from '@cio/ui/custom/widget-layouts';
import { FIELDS } from './fields';
import { makeFixturePayload } from './fixtures';
const { Story } = defineMeta({
title: 'Molecules/WidgetLayouts',
component: CourseWidget,
tags: ['autodocs'],
args: {
payload: makeFixturePayload('card_grid')
},
parameters: {
layout: 'fullscreen',
controls: {
include: FIELDS
}
}
});
</script>
<script lang="ts">
let styleEl: HTMLStyleElement | null = null;
onMount(() => {
styleEl = document.createElement('style');
styleEl.setAttribute('data-cio-widget-stories', '');
styleEl.textContent = widgetStyles;
document.head.appendChild(styleEl);
});
onDestroy(() => {
if (styleEl?.parentNode) {
styleEl.parentNode.removeChild(styleEl);
}
});
</script>
<Story name="Card grid" args={{ payload: makeFixturePayload('card_grid') }}>
{#snippet template(args)}
<div style="padding: 24px; background: #f5f6f8;">
<CourseWidget payload={args.payload} />
</div>
{/snippet}
</Story>
<Story name="Tag filter" args={{ payload: makeFixturePayload('tag_filter') }}>
{#snippet template(args)}
<div style="padding: 24px; background: #f5f6f8;">
<CourseWidget payload={args.payload} />
</div>
{/snippet}
</Story>
<Story name="Carousel" args={{ payload: makeFixturePayload('carousel') }}>
{#snippet template(args)}
<div style="padding: 24px; background: #f5f6f8;">
<CourseWidget payload={args.payload} />
</div>
{/snippet}
</Story>
<Story name="Primary course" args={{ payload: makeFixturePayload('primary_course') }}>
{#snippet template(args)}
<div style="padding: 24px; background: #f5f6f8;">
<CourseWidget payload={args.payload} />
</div>
{/snippet}
</Story>
<Story name="Compact list" args={{ payload: makeFixturePayload('compact_list') }}>
{#snippet template(args)}
<div style="padding: 24px; background: #f5f6f8; max-width: 480px;">
<CourseWidget payload={args.payload} />
</div>
{/snippet}
</Story>
<Story name="Editorial spotlight" args={{ payload: makeFixturePayload('editorial_spotlight') }}>
{#snippet template(args)}
<div style="padding: 24px; background: #f5f6f8;">
<CourseWidget payload={args.payload} />
</div>
{/snippet}
</Story>
<Story name="Category shelf" args={{ payload: makeFixturePayload('category_shelf') }}>
{#snippet template(args)}
<div style="padding: 24px; background: #f5f6f8;">
<CourseWidget payload={args.payload} />
</div>
{/snippet}
</Story>