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:
@@ -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
Reference in New Issue
Block a user