feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
@@ -1,7 +1,9 @@
<script lang="ts">
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import { DotField } from '../animation/dot-field';
import { Waves } from '../animation/waves';
import { cn } from '../../tools';
import type { PublicCourseCalloutData } from './types';
import type { PublicCourseCalloutAnimation, PublicCourseCalloutData } from './types';
interface Props {
callout: PublicCourseCalloutData | null;
@@ -10,6 +12,7 @@
/** Shown when callout is null and variant === 'full'. */
lockedFallbackTitle?: string;
lockedFallbackDescription?: string;
animation?: PublicCourseCalloutAnimation;
class?: string;
}
@@ -18,45 +21,78 @@
variant = 'inline',
lockedFallbackTitle = 'This lesson is locked',
lockedFallbackDescription = 'The creator hasn’t shared a link for this lesson yet. Check back later.',
animation = 'waves',
class: className
}: Props = $props();
const cardBase = 'ui:rounded-xl ui:border ui:border-border ui:bg-card ui:text-card-foreground ui:transition-colors';
const motionClass = 'ui:pointer-events-none ui:absolute ui:inset-0 ui:z-0 ui:h-full ui:w-full';
const cardBase = 'ui:rounded-xl ui:border ui:border-primary/50 ui:text-card-foreground ui:transition-colors';
const rootClass = $derived(
variant === 'full'
? cn(
cardBase,
'ui:my-10 ui:mx-auto ui:w-full ui:max-w-xl ui:p-8 ui:text-center ui:border-dashed ui:bg-muted/30',
'ui:relative ui:overflow-hidden ui:my-10 ui:mx-auto ui:w-full ui:h-full ui:max-w-4xl ui:max-h-4xl ui:p-8 ui:flex ui:flex-col ui:items-center ui:justify-center ui:border-2 ui:border-primary/50 ui:border-dashed',
className
)
: cn(cardBase, 'ui:mt-10 ui:p-6', className)
: cn(cardBase, 'ui:relative ui:overflow-hidden ui:mt-10 ui:p-6', className)
);
</script>
{#snippet calloutMotionBackground()}
{#if animation === 'waves'}
<Waves class={motionClass} />
{:else if animation === 'dotted'}
<DotField
dotRadius={1.5}
dotSpacing={14}
cursorRadius={420}
bulgeOnly={true}
bulgeStrength={56}
glowRadius={140}
sparkle={false}
waveAmplitude={0}
maxGlowOpacity={0.5}
class={motionClass}
/>
{/if}
{/snippet}
{#if callout}
<aside class={rootClass}>
<h3 class={cn('ui:font-semibold ui:text-foreground', variant === 'full' ? 'ui:text-xl' : 'ui:text-base')}>
{callout.title}
</h3>
<p class={cn('ui:mt-2 ui:text-sm ui:text-muted-foreground', variant === 'full' && 'ui:mx-auto ui:max-w-md')}>
{callout.description}
</p>
<a
href={callout.buttonUrl}
target="_blank"
rel="noopener"
class={cn(
'ui:mt-4 ui:inline-flex ui:items-center ui:gap-1.5 ui:rounded-md ui:bg-primary ui:px-4 ui:py-2 ui:text-sm ui:font-medium ui:text-primary-foreground ui:transition-colors ui:hover:bg-primary/90'
)}
>
{callout.buttonLabel}
<ArrowUpRightIcon class="ui:size-4" />
</a>
{@render calloutMotionBackground()}
<div class="ui:relative ui:z-10 ui:flex ui:flex-col ui:items-center">
<h3 class={cn('', variant === 'full' ? 'ui:text-3xl' : 'ui:text-xl')}>
{callout.title}
</h3>
<p
class={cn(
'ui:mt-2 ui:text-md ui:text-muted-foreground ui:text-center',
variant === 'full' && 'ui:mx-auto ui:max-w-md'
)}
>
{callout.description}
</p>
<a
href={callout.buttonUrl}
target="_blank"
rel="noopener"
class={cn(
'ui:mt-4 ui:inline-flex ui:items-center ui:gap-1.5 ui:rounded-md ui:bg-primary ui:px-4 ui:py-2 ui:text-sm ui:font-medium ui:text-primary-foreground ui:transition-colors ui:hover:bg-primary/90'
)}
>
{callout.buttonLabel}
<ArrowUpRightIcon class="ui:size-4 custom" />
</a>
</div>
</aside>
{:else if variant === 'full'}
<aside class={rootClass}>
<h3 class="ui:text-xl ui:font-semibold ui:text-foreground">{lockedFallbackTitle}</h3>
<p class="ui:mt-2 ui:mx-auto ui:max-w-md ui:text-sm ui:text-muted-foreground">{lockedFallbackDescription}</p>
{@render calloutMotionBackground()}
<div class="ui:relative ui:z-10">
<h3 class="ui:text-xl ui:font-semibold ui:text-foreground">{lockedFallbackTitle}</h3>
<p class="ui:mt-2 ui:mx-auto ui:max-w-md ui:text-sm ui:text-muted-foreground">{lockedFallbackDescription}</p>
</div>
</aside>
{/if}
@@ -13,13 +13,14 @@
import { QuestionList } from '../exercise-question';
import { cn } from '../../tools';
import Callout from './callout.svelte';
import type { PublicCourseCalloutData, PublicExerciseViewData } from './types';
import type { PublicCourseCalloutAnimation, PublicCourseCalloutData, PublicExerciseViewData } from './types';
import type { PublicExerciseStoredAttempt } from './public-exercise-attempts-storage';
import { readPublicExerciseAttempts, writePublicExerciseAttempts } from './public-exercise-attempts-storage';
interface Props {
exercise: PublicExerciseViewData;
callout?: PublicCourseCalloutData | null;
calloutAnimation?: PublicCourseCalloutAnimation;
labels?: ExerciseQuestionLabels;
/** When set, completed attempts persist and hydrate from `localStorage`. */
attemptsPersistenceKey?: string | null;
@@ -36,6 +37,7 @@
let {
exercise,
callout = null,
calloutAnimation,
labels,
attemptsPersistenceKey = null,
formatAttemptOption = ({ attemptNumber, correct, total }) => `Attempt ${attemptNumber} - ${correct}/${total}`,
@@ -48,6 +50,10 @@
class: className
}: Props = $props();
const resolvedCalloutAnimation = $derived<PublicCourseCalloutAnimation>(
calloutAnimation ?? callout?.animation ?? 'waves'
);
let persistedAttempts = $state<PublicExerciseStoredAttempt[]>([]);
/** `'live'` = unsaved/new round; `'0'`.. = index into persisted attempts. */
let selectValue = $state<string>('live');
@@ -335,7 +341,7 @@
<article class={cn('ui:mx-auto ui:w-full ui:max-w-3xl ui:px-4 ui:py-8 ui:sm:px-6 ui:lg:py-10', className)}>
{#if !exercise.isUnlocked}
<Callout variant="full" {callout} />
<Callout variant="full" {callout} animation={resolvedCalloutAnimation} />
{:else}
{#if exercise.sectionTitle}
<div class="ui:text-xs ui:font-medium ui:uppercase ui:tracking-wide ui:text-muted-foreground">
@@ -417,6 +423,6 @@
{/if}
</div>
<Callout variant="inline" {callout} />
<Callout variant="inline" {callout} animation={resolvedCalloutAnimation} />
{/if}
</article>
@@ -34,6 +34,23 @@
const prevHref = $derived(prev ? hrefFor?.(prev) : undefined);
const nextHref = $derived(next ? hrefFor?.(next) : undefined);
function handleAnchorClick(event: MouseEvent, item: PublicCourseSidebarItem) {
if (
!onNavigate ||
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return;
}
event.preventDefault();
onNavigate(item);
}
</script>
{#snippet prevBody(item: PublicCourseSidebarItem, label: string)}
@@ -67,7 +84,7 @@
>
{#if prev}
{#if prevHref}
<a href={prevHref} class={cardClass} onclick={() => onNavigate?.(prev)}>
<a href={prevHref} class={cardClass} onclick={(event) => handleAnchorClick(event, prev)}>
{@render prevBody(prev, prevLabel)}
</a>
{:else}
@@ -81,7 +98,11 @@
{#if next}
{#if nextHref}
<a href={nextHref} class={cn(cardClass, 'ui:justify-end ui:text-right')} onclick={() => onNavigate?.(next)}>
<a
href={nextHref}
class={cn(cardClass, 'ui:justify-end ui:text-right')}
onclick={(event) => handleAnchorClick(event, next)}
>
{@render nextBody(next, nextLabel)}
</a>
{:else}
@@ -18,6 +18,7 @@ export {
export type { PublicExerciseStoredAttempt } from './public-exercise-attempts-storage';
export type {
PublicCourseCalloutAnimation,
PublicCourseCalloutData,
PublicCourseOrgData,
PublicCourseSidebarItem,
@@ -1,35 +1,43 @@
<script lang="ts">
import { cn } from '../../tools';
import { SafeHtmlContent } from '../safe-html-content';
import { MediaPlayer } from '../media-player';
import Callout from './callout.svelte';
import type { PublicCourseCalloutData, PublicLessonViewData } from './types';
import type { PublicCourseCalloutAnimation, PublicCourseCalloutData, PublicLessonViewData } from './types';
interface Props {
lesson: PublicLessonViewData;
/** Shown in the player captions menu; supplied by the host app (`$t(...)`) — see authenticated `lesson-video-player`. */
videoCaptionsLabel: string;
callout?: PublicCourseCalloutData | null;
calloutAnimation?: PublicCourseCalloutAnimation;
class?: string;
id?: string;
}
let { lesson, callout = null, class: className }: Props = $props();
let { lesson, videoCaptionsLabel, callout = null, calloutAnimation, class: className, id }: Props = $props();
const isYoutube = $derived(lesson.video?.type === 'youtube');
const resolvedCalloutAnimation = $derived<PublicCourseCalloutAnimation>(
calloutAnimation ?? callout?.animation ?? 'waves'
);
function toEmbedUrl(link: string): string {
try {
const url = new URL(link);
if (url.hostname.includes('youtu.be')) {
const videoId = url.pathname.replace(/^\//, '');
return `https://www.youtube.com/embed/${videoId}`;
}
if (url.hostname.includes('youtube.com')) {
const videoId = url.searchParams.get('v');
if (videoId) return `https://www.youtube.com/embed/${videoId}`;
}
return link;
} catch {
return link;
const captionTracks = $derived.by(() => {
const transcript = lesson.video?.transcript;
if (!transcript?.vttUrl) {
return [];
}
}
return [
{
kind: 'captions' as const,
src: transcript.vttUrl,
srclang: transcript.language,
label: videoCaptionsLabel,
default: false
}
];
});
</script>
<!--
@@ -39,26 +47,29 @@
a public link. The `prose` class (no `ui:` prefix) hooks into the dashboard's
app.css typography rules.
-->
<article class={cn('ui:mx-auto ui:w-full ui:max-w-3xl ui:px-4 ui:py-8 ui:sm:px-6 ui:lg:py-10', className)}>
<div {id} class={cn('ui:mx-auto ui:w-full ui:max-w-3xl ui:px-4 ui:py-8 ui:sm:px-6 ui:lg:py-10', className)}>
{#if !lesson.isUnlocked}
<Callout variant="full" {callout} />
<Callout variant="full" {callout} animation={resolvedCalloutAnimation} />
{:else}
{#if lesson.video}
<div class="ui:mb-6 ui:overflow-hidden ui:rounded-xl ui:bg-black">
{#if isYoutube}
<iframe
class="ui:aspect-video ui:w-full"
src={toEmbedUrl(lesson.video.link)}
title={lesson.title}
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowfullscreen
></iframe>
{:else}
<video class="ui:aspect-video ui:w-full" controls playsinline src={lesson.video.link}>
<track kind="captions" />
</video>
{/if}
<div class="ui:mb-6 ui:w-full ui:overflow-hidden ui:bg-muted ui:rounded-lg">
<MediaPlayer
source={{
type: lesson.video.type,
url: lesson.video.link,
metadata: {
...(lesson.video.metadata ?? {}),
title: lesson.video.metadata?.title ?? lesson.title
},
tracks: captionTracks
}}
options={{
maxHeight: '569px',
width: '100%',
controls: true,
playsinline: true
}}
/>
</div>
{/if}
@@ -80,6 +91,6 @@
{/if}
</div>
<Callout variant="inline" {callout} />
<Callout variant="inline" {callout} animation={resolvedCalloutAnimation} />
{/if}
</article>
</div>
@@ -2,7 +2,7 @@
import { cn } from '../../tools';
interface Props {
/** Course slug for attribution (passed as ?utm_source=...&utm_content=...). */
/** Course slug for attribution (passed as ?utm_content=...). */
courseSlug?: string | null;
/** Org siteName (or org name) for attribution. */
orgSlug?: string | null;
@@ -10,6 +10,12 @@
label?: string;
/** Localized "ClassroomIO" brand label. */
brand?: string;
/** Logo-only footer (narrow sidebars). */
compact?: boolean;
/** Row alignment inside the attribution strip. */
align?: 'center' | 'start';
/** Override default utm_source (default mirrors public-course pages). */
utmSource?: string;
class?: string;
}
@@ -18,13 +24,16 @@
orgSlug = null,
label = 'Powered by',
brand = 'ClassroomIO',
compact = false,
align = 'center',
utmSource = 'public-course',
class: className
}: Props = $props();
const href = $derived.by(() => {
const url = new URL('https://classroomio.com');
url.searchParams.set('utm_source', 'public-course');
url.searchParams.set('utm_source', utmSource);
url.searchParams.set('utm_medium', 'powered-by');
if (orgSlug) url.searchParams.set('utm_campaign', orgSlug);
@@ -32,22 +41,39 @@
return url.toString();
});
const rowAlignClass = $derived(align === 'start' ? 'ui:justify-start' : 'ui:justify-center');
</script>
<div
class={cn(
'ui:px-3 ui:flex ui:items-center ui:justify-center ui:gap-1 ui:py-3 ui:text-xs ui:text-muted-foreground ui:w-full',
className
)}
>
<img src="/logo-192.png" class="ui:size-6" alt="ClassroomIO" />
{label}
<a
{href}
target="_blank"
rel="noopener noreferrer"
class="ui:font-medium ui:text-muted-foreground ui:underline-offset-2 ui:hover:text-foreground ui:underline"
{#if compact}
<div class={cn('ui:flex ui:items-center ui:gap-1 ui:w-full', rowAlignClass, className)}>
<a
{href}
target="_blank"
rel="noopener noreferrer"
class="ui:leading-none ui:opacity-70 ui:hover:opacity-100"
aria-label={`${label} ${brand}`}
>
<img src="/logo-192.png" class="ui:size-6" alt="" />
</a>
</div>
{:else}
<div
class={cn(
'ui:px-3 ui:flex ui:items-center ui:gap-1 ui:text-xs ui:text-muted-foreground ui:w-full',
rowAlignClass,
className
)}
>
{brand}
</a>
</div>
<img src="/logo-192.png" class="ui:size-6" alt="ClassroomIO" />
{label}
<a
{href}
target="_blank"
rel="noopener noreferrer"
class="ui:font-medium ui:text-muted-foreground ui:underline-offset-2 ui:hover:text-foreground ui:underline"
>
{brand}
</a>
</div>
{/if}
@@ -7,6 +7,7 @@
import { Button } from '../../base/button';
import { cn } from '../../tools';
import type { PublicCourseOrgData, PublicCourseSidebarItem, PublicCourseSidebarSection } from './types';
import { ModeSwitcher } from '../../base/dark-mode';
interface Props {
sections: PublicCourseSidebarSection[];
@@ -120,7 +121,7 @@
<div class={cn('ui:min-h-dvh ui:bg-background ui:text-foreground', className)}>
<!--
Top header. Sticky so it stays visible as guests scroll long lessons.
Layout: [org logo · org name | course title] ··· [Explore courses] [Sign in]
Layout: [org logo · org name | course title] ··· [Explore courses] [Sign in] [Theme]
Height intentionally compact (h-12) to maximize content vertical space.
-->
<header
@@ -168,6 +169,7 @@
</Button>
<Button href={signInHref} variant="default" size="sm">{signInLabel}</Button>
{/if}
<ModeSwitcher />
</div>
</div>
</header>
@@ -186,7 +188,7 @@
{sections}
{activeSlug}
{hrefFor}
{onItemClick}
onItemClick={hrefFor ? undefined : onItemClick}
{showPoweredBy}
{courseSlug}
orgSlug={org?.siteName ?? org?.name ?? null}
@@ -204,7 +206,7 @@
prev={prevItem}
next={nextItem}
{hrefFor}
onNavigate={(item) => onItemClick?.(item)}
onNavigate={hrefFor ? undefined : (item) => onItemClick?.(item)}
prevLabel={footerPrevLabel}
nextLabel={footerNextLabel}
/>
@@ -236,7 +238,9 @@
{poweredByLabel}
{poweredByBrand}
onItemClick={(item) => {
onItemClick?.(item);
if (!hrefFor) {
onItemClick?.(item);
}
sheetOpen = false;
}}
/>
@@ -29,12 +29,29 @@
const titleClass = $derived(
cn(
'ui:text-wrap ui:transition-colors',
'ui:text-wrap ui:transition-colors ui:text-[13px]',
'ui:group-hover:text-primary',
active ? 'ui:text-primary' : 'ui:text-foreground/70 ui:group-hover:text-foreground',
locked && !active && 'ui:text-muted-foreground'
)
);
// function handleAnchorClick(event: MouseEvent) {
// if (
// !onClick ||
// event.defaultPrevented ||
// event.button !== 0 ||
// event.metaKey ||
// event.ctrlKey ||
// event.shiftKey ||
// event.altKey
// ) {
// return;
// }
// event.preventDefault();
// onClick();
// }
</script>
{#snippet body()}
@@ -43,7 +60,7 @@
{/snippet}
{#if href}
<a {href} class={rowClass} aria-current={active ? 'page' : undefined} onclick={onClick}>
<a {href} class={rowClass} aria-current={active ? 'page' : undefined}>
{@render body()}
</a>
{:else}
@@ -1,10 +1,13 @@
<script lang="ts">
import { mergeProps } from 'bits-ui';
import * as Tooltip from '../../base/tooltip';
import { cn } from '../../tools';
import PoweredBy from './powered-by.svelte';
import SidebarRow from './sidebar-row.svelte';
import type { PublicCourseSidebarItem, PublicCourseSidebarSection } from './types';
interface Props {
// Sections
sections: PublicCourseSidebarSection[];
/** Slug of the active item. Matches against `item.slug`. */
activeSlug?: string | null;
@@ -43,27 +46,47 @@
<div class={cn('ui:flex ui:h-full ui:flex-col ui:bg-sidebar', className)}>
<nav class="ui:flex ui:flex-1 ui:flex-col ui:gap-6 ui:py-6 ui:px-2" aria-label="Course outline">
{#each sections as section (section.id)}
<div class="ui:flex ui:flex-col ui:gap-2">
<h2
class="ui:px-3 ui:text-xs ui:font-medium ui:uppercase ui:text-[11px] ui:text-muted-foreground ui:tracking-wider ui:transition-colors ui:whitespace-nowrap"
>
{section.title}
</h2>
<div class="ui:flex ui:flex-col">
{#each section.items as item, index (item.id)}
<SidebarRow
number={index + 1}
title={item.title}
active={item.slug === activeSlug}
locked={!item.isUnlocked}
href={hrefFor?.(item)}
onClick={() => onItemClick?.(item)}
/>
{/each}
<Tooltip.Provider delayDuration={300}>
{#each sections as section (section.id)}
<div class="ui:flex ui:min-w-0 ui:flex-col ui:gap-2">
<h2 class="ui:m-0 ui:min-w-0 ui:w-full">
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<span
{...mergeProps(
{
class: cn(
'ui:block ui:w-full ui:min-w-0 ui:truncate ui:px-3 ui:font-medium ui:uppercase ui:text-[11px] ui:text-muted-foreground ui:transition-colors'
)
},
props
)}
>
{section.title}
</span>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="top" sideOffset={6} class="ui:max-w-xs">
{section.title}
</Tooltip.Content>
</Tooltip.Root>
</h2>
<div class="ui:flex ui:flex-col">
{#each section.items as item, index (item.id)}
<SidebarRow
number={index + 1}
title={item.title}
active={item.slug === activeSlug}
locked={!item.isUnlocked}
href={hrefFor?.(item)}
onClick={() => onItemClick?.(item)}
/>
{/each}
</div>
</div>
</div>
{/each}
{/each}
</Tooltip.Provider>
</nav>
{#if showPoweredBy}
@@ -13,11 +13,15 @@
import type { ExerciseQuestionLabels, ExerciseQuestionModel } from '@cio/question-types';
/** Background motion behind the public course marketing callout. */
export type PublicCourseCalloutAnimation = 'dotted' | 'waves' | 'none';
export interface PublicCourseCalloutData {
title: string;
description: string;
buttonLabel: string;
buttonUrl: string;
animation?: PublicCourseCalloutAnimation;
}
/** Org / creator info shown in the top header (logo, name) of public course pages. */
@@ -52,6 +56,16 @@ export interface PublicLessonViewData {
video: {
type: 'youtube' | 'generic' | 'upload' | 'google_drive';
link: string;
metadata?: {
svid?: string;
thumbnailUrl?: string;
title?: string;
};
transcript?: {
vttUrl: string;
vttUrlExpiresAt: string;
language: string;
} | null;
} | null;
isUnlocked: boolean;
}