feat: live class sessions, CE workflow, and docs (#683)

Adds live-class session scheduling on lessons (join link, date/time,
timezone) with automatic 24h and 1h reminder emails plus a calendar
invite (.ics), a time-change notification with an updated invite, a
teacher-authored welcome email and session invite on enrollment, and a
"Notify students" quiz nudge.

LMS gets an upcoming live-sessions card and Join buttons, plus a
reusable live-session card component (3 states) in @cio/ui with a
Storybook story. The sidebar content tree now reflects sequential
progression locking like the content list, and the course-completion
confetti regression is fixed.

Org-based emails no longer reference ClassroomIO by name. Docs add
live-class, welcome-email, and course-progression guides, expand the
certificates guide (design, signatures, corrected labels), move
plan-required callouts to the top, flatten how-to-guides to the docs
root with redirects, and add screenshots. Includes an add-docs-image
skill.
This commit is contained in:
Rotimi Best
2026-06-22 05:43:48 +01:00
committed by GitHub
parent 3547901de6
commit 81294a47f9
193 changed files with 5477 additions and 805 deletions
@@ -24,6 +24,7 @@
size: {
default: 'ui:h-9 ui:px-4 ui:py-2 ui:has-[>svg]:px-3',
sm: 'ui:h-8 ui:gap-1.5 ui:rounded-md ui:px-3 ui:has-[>svg]:px-2.5',
xs: 'ui:h-7 ui:gap-1 ui:rounded-md ui:px-2.5 ui:text-xs ui:has-[>svg]:px-2',
lg: 'ui:h-10 ui:rounded-md ui:px-6 ui:has-[>svg]:px-4',
icon: 'ui:size-9',
'icon-2xs': 'ui:size-4',
@@ -0,0 +1,2 @@
export { default as LiveSessionCard } from './live-session-card.svelte';
export type { LiveSessionLabels } from './live-session-card.svelte';
@@ -0,0 +1,293 @@
<script lang="ts" module>
export interface LiveSessionLabels {
liveNow: string;
liveSession: string;
providerFallback: string;
started: string;
minAgo: string;
joinNow: string;
copyLink: string;
copy: string;
addToCalendar: string;
addGoogle: string;
addOutlook: string;
addOffice365: string;
addYahoo: string;
addApple: string;
startsIn: string;
sessionEnded: string;
}
</script>
<script lang="ts">
import { onDestroy } from 'svelte';
import { Button } from '../../base/button';
import { CopyButton } from '../../base/copy-button';
import * as ButtonGroup from '../../base/button-group';
import * as DropdownMenu from '../../base/dropdown-menu';
import { RadioIcon } from '../moving-icons';
import VideoIcon from '@lucide/svelte/icons/video';
import CalendarPlusIcon from '@lucide/svelte/icons/calendar-plus';
import ClockIcon from '@lucide/svelte/icons/clock';
import HourglassIcon from '@lucide/svelte/icons/hourglass';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
interface Props {
title: string;
callUrl: string;
lessonAt: string;
labels: LiveSessionLabels;
timezone?: string | null;
durationMinutes?: number;
status?: 'live' | 'upcoming' | 'ended';
}
let {
title,
callUrl,
lessonAt,
labels,
timezone = null,
durationMinutes = 60,
status: statusOverride = undefined
}: Props = $props();
let now = $state(Date.now());
const ticker = setInterval(() => (now = Date.now()), 30_000);
onDestroy(() => clearInterval(ticker));
const startMs = $derived(new Date(lessonAt).getTime());
const endMs = $derived(startMs + durationMinutes * 60_000);
const status = $derived(statusOverride ?? (now < startMs ? 'upcoming' : now < endMs ? 'live' : 'ended'));
const tz = $derived(timezone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
const dateLabel = $derived(
new Intl.DateTimeFormat('en-US', {
timeZone: tz,
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZoneName: 'short'
}).format(new Date(lessonAt))
);
const provider = $derived.by(() => {
try {
const host = new URL(callUrl).hostname;
if (host.includes('zoom')) return 'Zoom';
if (host.includes('meet.google')) return 'Google Meet';
if (host.includes('teams')) return 'Microsoft Teams';
if (host.includes('webex')) return 'Webex';
return labels.providerFallback;
} catch {
return labels.providerFallback;
}
});
function duration(ms: number): string {
let secs = Math.max(0, Math.floor(ms / 1000));
const days = Math.floor(secs / 86400);
secs -= days * 86400;
const hours = Math.floor(secs / 3600);
secs -= hours * 3600;
const mins = Math.floor(secs / 60);
const parts: string[] = [];
if (days) parts.push(`${days}d`);
if (hours || days) parts.push(`${hours}h`);
parts.push(`${mins}m`);
return parts.join(' ');
}
const startsIn = $derived(duration(startMs - now));
const startedAgo = $derived(`${Math.max(1, Math.floor((now - startMs) / 60_000))} ${labels.minAgo}`);
function pad(value: number): string {
return String(value).padStart(2, '0');
}
function toIcsUtc(ms: number): string {
const d = new Date(ms);
return (
`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}` +
`T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`
);
}
function icsEscape(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
}
function addToCalendar() {
const ics = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//ClassroomIO//Live Session//EN',
'BEGIN:VEVENT',
`UID:session-${startMs}@classroomio`,
`DTSTAMP:${toIcsUtc(now)}`,
`DTSTART:${toIcsUtc(startMs)}`,
`DTEND:${toIcsUtc(endMs)}`,
`SUMMARY:${icsEscape(title)}`,
`DESCRIPTION:${icsEscape(`Join your live session: ${callUrl}`)}`,
`URL:${icsEscape(callUrl)}`,
'BEGIN:VALARM',
'ACTION:DISPLAY',
'DESCRIPTION:Reminder',
'TRIGGER:-PT1H',
'END:VALARM',
'END:VEVENT',
'END:VCALENDAR'
].join('\r\n');
const blob = new Blob([ics], { type: 'text/calendar;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'session.ics';
link.click();
URL.revokeObjectURL(url);
}
const calendarDetails = $derived(`Join your live session: ${callUrl}`);
const googleCalendarUrl = $derived(
'https://calendar.google.com/calendar/render?action=TEMPLATE' +
`&text=${encodeURIComponent(title)}` +
`&dates=${toIcsUtc(startMs)}/${toIcsUtc(endMs)}` +
`&details=${encodeURIComponent(calendarDetails)}` +
`&location=${encodeURIComponent(callUrl)}`
);
const outlookCalendarUrl = $derived(
'https://outlook.live.com/calendar/0/deeplink/compose?path=/calendar/action/compose&rru=addevent' +
`&subject=${encodeURIComponent(title)}` +
`&body=${encodeURIComponent(calendarDetails)}` +
`&startdt=${new Date(startMs).toISOString()}` +
`&enddt=${new Date(endMs).toISOString()}` +
`&location=${encodeURIComponent(callUrl)}`
);
const office365CalendarUrl = $derived(
'https://outlook.office.com/calendar/0/deeplink/compose?path=/calendar/action/compose&rru=addevent' +
`&subject=${encodeURIComponent(title)}` +
`&body=${encodeURIComponent(calendarDetails)}` +
`&startdt=${new Date(startMs).toISOString()}` +
`&enddt=${new Date(endMs).toISOString()}` +
`&location=${encodeURIComponent(callUrl)}`
);
const yahooCalendarUrl = $derived(
'https://calendar.yahoo.com/?v=60&view=d&type=20' +
`&title=${encodeURIComponent(title)}` +
`&st=${toIcsUtc(startMs)}&et=${toIcsUtc(endMs)}` +
`&desc=${encodeURIComponent(calendarDetails)}` +
`&in_loc=${encodeURIComponent(callUrl)}`
);
function openCalendar(url: string) {
window.open(url, '_blank', 'noreferrer');
}
</script>
{#snippet heading()}
<h3 class="ui:mt-0.5 ui:flex ui:items-center ui:gap-1.5 ui:text-sm ui:font-semibold">
{labels.liveSession}
<RadioIcon size={13} color="#ef4444" />
</h3>
{/snippet}
{#snippet addToCalendarGroup()}
<ButtonGroup.Root>
<Button variant="outline" size="xs" onclick={() => openCalendar(googleCalendarUrl)}>
<CalendarPlusIcon size={13} />
{labels.addToCalendar}
</Button>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} type="button" variant="outline" size="xs" aria-label={labels.addToCalendar}>
<ChevronDownIcon size={13} />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start">
<DropdownMenu.Item onclick={() => openCalendar(googleCalendarUrl)}>{labels.addGoogle}</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => openCalendar(outlookCalendarUrl)}>{labels.addOutlook}</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => openCalendar(office365CalendarUrl)}>{labels.addOffice365}</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => openCalendar(yahooCalendarUrl)}>{labels.addYahoo}</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={addToCalendar}>{labels.addApple}</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</ButtonGroup.Root>
{/snippet}
<div class="ui:bg-card ui:text-card-foreground ui:border-primary/30 ui:rounded-md ui:border ui:p-3">
{#if status === 'live'}
<div
class="ui:text-red-600 ui:flex ui:items-center ui:gap-1.5 ui:text-[11px] ui:font-semibold ui:tracking-wide ui:uppercase"
>
<span class="ui:inline-block ui:h-2 ui:w-2 ui:rounded-full ui:bg-red-500"></span>
{labels.liveNow}
</div>
{@render heading()}
<div
class="ui:text-muted-foreground ui:mt-1 ui:flex ui:flex-wrap ui:items-center ui:gap-x-3 ui:gap-y-0.5 ui:text-xs"
>
<span class="ui:flex ui:items-center ui:gap-1"><ClockIcon size={12} /> {labels.started} {startedAgo}</span>
<span class="ui:flex ui:items-center ui:gap-1"><VideoIcon size={12} /> {provider}</span>
</div>
<div class="ui:mt-3 ui:flex ui:flex-wrap ui:items-center ui:gap-2">
<a href={callUrl} target="_blank" rel="noreferrer">
<Button size="xs"><VideoIcon size={13} />{labels.joinNow}</Button>
</a>
<CopyButton text={callUrl} variant="outline" size="xs">
{labels.copyLink}
</CopyButton>
</div>
<div class="ui:mt-2.5 ui:flex ui:items-center ui:gap-2">
<p class="ui:bg-muted ui:text-muted-foreground ui:flex-1 ui:truncate ui:rounded ui:px-2.5 ui:py-1.5 ui:text-xs">
{callUrl}
</p>
<CopyButton text={callUrl} variant="outline" size="xs">
{labels.copy}
</CopyButton>
</div>
{:else if status === 'upcoming'}
{@render heading()}
<div
class="ui:text-muted-foreground ui:mt-1 ui:flex ui:flex-wrap ui:items-center ui:gap-x-3 ui:gap-y-0.5 ui:text-xs"
>
<span class="ui:flex ui:items-center ui:gap-1"><ClockIcon size={12} /> {dateLabel}</span>
<span class="ui:flex ui:items-center ui:gap-1"><VideoIcon size={12} /> {provider}</span>
</div>
<div class="ui:mt-3 ui:flex ui:flex-wrap ui:items-center ui:gap-2">
{@render addToCalendarGroup()}
<CopyButton text={callUrl} variant="outline" size="xs">
{labels.copyLink}
</CopyButton>
</div>
<div
class="ui:text-muted-foreground ui:border-primary/30 ui:mt-3 ui:flex ui:items-center ui:gap-1.5 ui:border-t ui:pt-2.5 ui:text-xs"
>
<HourglassIcon size={12} />
{labels.startsIn}
<span class="ui:text-foreground ui:font-semibold">{startsIn}</span>
</div>
{:else}
<div class="ui:text-muted-foreground ui:text-[11px] ui:font-semibold ui:tracking-wide ui:uppercase">
{labels.sessionEnded}
</div>
{@render heading()}
<div class="ui:text-muted-foreground ui:mt-1 ui:flex ui:items-center ui:gap-1 ui:text-xs">
<ClockIcon size={12} />
{dateLabel}
</div>
{/if}
</div>
@@ -27,6 +27,7 @@ export { default as PeopleIcon } from './people.svelte';
export { default as PersonIcon } from './person.svelte';
export { default as PremiumIcon } from './premium.svelte';
export { default as PreviewIcon } from './preview.svelte';
export { default as RadioIcon } from './radio.svelte';
export { default as ReviewIcon } from './review.svelte';
export { default as SettingsIcon } from './settings.svelte';
export { default as SetupIcon } from './setup.svelte';
@@ -0,0 +1,91 @@
<script>
/**
* Broadcast / "live" indicator. Standard moving-icon behavior: static by
* default, plays a brief signal animation on hover via `isHovered`. The
* center is filled so it reads as a recording dot.
*
* @typedef {Object} Props
* @property {string} [color]
* @property {number} [size]
* @property {number} [strokeWidth]
* @property {boolean} [isHovered]
* @property {string} [class]
*/
/** @type {Props} */
let { color = 'currentColor', size = 24, strokeWidth = 2, isHovered = false, class: className = '' } = $props();
function handleMouseEnter() {
if (isHovered) return;
isHovered = true;
setTimeout(() => {
isHovered = false;
}, 700);
}
</script>
<div class={className} aria-label="live session" role="img" onmouseenter={handleMouseEnter}>
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke={color}
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
class="radio-icon"
class:animate={isHovered}
>
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9" class="radio-level radio-line-3" />
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5" class="radio-level radio-line-2" />
<circle cx="12" cy="12" r="2" fill={color} stroke="none" class="radio-level radio-line-1" />
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5" class="radio-level radio-line-2" />
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19" class="radio-level radio-line-3" />
</svg>
</div>
<style>
div {
display: inline-block;
line-height: 0;
}
.radio-icon {
overflow: visible;
}
.radio-level {
opacity: 1;
transition: opacity 0.2s ease;
}
.radio-icon.animate .radio-level {
animation: fadeInSequence 0.6s ease forwards;
}
.radio-icon.animate .radio-line-1 {
opacity: 0;
animation-delay: 0.15s;
}
.radio-icon.animate .radio-line-2 {
opacity: 0;
animation-delay: 0.25s;
}
.radio-icon.animate .radio-line-3 {
opacity: 0;
animation-delay: 0.35s;
}
@keyframes fadeInSequence {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
</style>
@@ -11,7 +11,5 @@
<span class="ui:text-foreground ui:text-md">ClassroomIO</span>
</a>
<p class="ui:text-muted-foreground ui:text-sm">
The Most Customizable Platform for Customer, Partner, and Employee Education
</p>
<p class="ui:text-muted-foreground ui:text-sm">One Platform for Customer, Partner, and Employee Training</p>
</div>
File diff suppressed because one or more lines are too long