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
+13
View File
@@ -22,6 +22,10 @@ export * from './teacher-course-welcome';
export * from './student-org-invite';
export * from './student-program-welcome';
export * from './program-goal-reminder';
export * from './quiz-assigned';
export * from './session';
export * from './submission-received';
export * from './submission-graded';
import type { newsfeedCommentEmail, newsfeedPostEmail } from './newsfeed';
@@ -36,6 +40,10 @@ import type { studentProvePaymentEmail } from './student-prove-payment';
import type { studentOrgInviteEmail } from './student-org-invite';
import type { studentProgramWelcomeEmail } from './student-program-welcome';
import type { programGoalReminderEmail } from './program-goal-reminder';
import type { quizAssignedEmail } from './quiz-assigned';
import type { sessionReminderEmail, sessionUpdatedEmail } from './session';
import type { submissionReceivedEmail } from './submission-received';
import type { submissionGradedEmail } from './submission-graded';
import type { teacherCourseWelcomeEmail } from './teacher-course-welcome';
import type { teacherStudentBuyRequestEmail } from './teacher-student-buy-request';
import type { teacherStudentJoinedEmail } from './teacher-student-joined';
@@ -66,4 +74,9 @@ export type EmailSchemas = {
studentOrgInvite: typeof studentOrgInviteEmail.template.schema;
studentProgramWelcome: typeof studentProgramWelcomeEmail.template.schema;
programGoalReminder: typeof programGoalReminderEmail.template.schema;
quizAssigned: typeof quizAssignedEmail.template.schema;
sessionReminder: typeof sessionReminderEmail.template.schema;
sessionUpdated: typeof sessionUpdatedEmail.template.schema;
submissionReceived: typeof submissionReceivedEmail.template.schema;
submissionGraded: typeof submissionGradedEmail.template.schema;
};
+4 -2
View File
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const inviteTeacherEmail = defineEmail({
id: 'inviteTeacher',
@@ -12,7 +13,8 @@ export const inviteTeacherEmail = defineEmail({
orgSiteName: z.string().min(1),
roleName: z.string().min(1),
expiresAt: z.string().min(1),
inviteLink: z.url()
inviteLink: z.url(),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
@@ -24,6 +26,6 @@ export const inviteTeacherEmail = defineEmail({
</div>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});
+7 -4
View File
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const newsfeedPostEmail = defineEmail({
id: 'newsfeedPost',
@@ -11,7 +12,8 @@ export const newsfeedPostEmail = defineEmail({
teacherName: z.string().min(1),
content: z.string().min(1),
postLink: z.url(),
orgName: z.string().min(1)
orgName: z.string().min(1),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
@@ -22,7 +24,7 @@ export const newsfeedPostEmail = defineEmail({
</div>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});
@@ -33,7 +35,8 @@ export const newsfeedCommentEmail = defineEmail({
courseTitle: z.string().min(1),
comment: z.string().min(1),
postLink: z.url(),
orgName: z.string().min(1)
orgName: z.string().min(1),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
@@ -44,6 +47,6 @@ export const newsfeedCommentEmail = defineEmail({
</div>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const programGoalReminderEmail = defineEmail({
id: 'programGoalReminder',
@@ -13,7 +14,8 @@ export const programGoalReminderEmail = defineEmail({
daysUntilDue: z.number().int(),
completedCount: z.number().int(),
requiredCount: z.number().int(),
loginUrl: z.string().min(1)
loginUrl: z.string().min(1),
branding: ZEmailBranding
}),
render: (fields) => {
const dueLine =
@@ -30,11 +32,11 @@ export const programGoalReminderEmail = defineEmail({
<p>This is a reminder that the goal <strong>${fields.goalTitle}</strong> in your program <strong>${fields.programName}</strong> at ${fields.orgName} needs your attention.</p>
${dueLine}
<p>Your progress so far: <strong>${progress}</strong>.</p>
<p><a href="${fields.loginUrl}">Open ClassroomIO</a> to keep going.</p>
<p><a href="${fields.loginUrl}">Open the LMS</a> to keep going.</p>
<p>Cheers,</p>
<p>${fields.orgName}</p>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});
@@ -0,0 +1,30 @@
import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const quizAssignedEmail = defineEmail({
id: 'quizAssigned',
subject: 'You have a quiz to complete',
schema: z.object({
orgName: z.string().min(1),
courseName: z.string().min(1),
exerciseTitle: z.string().min(1),
quizUrl: z.string().min(1),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
<p>Hi there,</p>
<p>A quiz — <strong>${fields.exerciseTitle}</strong> — has been assigned to you in <strong>${fields.courseName}</strong> at <strong>${fields.orgName}</strong>.</p>
<div>
<a class="button" href="${fields.quizUrl}">Take the quiz</a>
</div>
<p>Cheers,</p>
<p>${fields.orgName}</p>
`;
return getDefaultTemplate(content, fields.branding);
}
});
+61
View File
@@ -0,0 +1,61 @@
import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const sessionReminderEmail = defineEmail({
id: 'sessionReminder',
subject: 'Reminder: your live session is coming up',
schema: z.object({
orgName: z.string().min(1),
courseName: z.string().min(1),
sessionTitle: z.string().min(1),
sessionTimeLabel: z.string().min(1),
whenLabel: z.string().min(1),
joinUrl: z.string().min(1),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
<p>Hi there,</p>
<p>Your live session <strong>${fields.sessionTitle}</strong> in <strong>${fields.courseName}</strong> starts <strong>${fields.whenLabel}</strong>.</p>
<p><strong>When:</strong> ${fields.sessionTimeLabel}</p>
<div>
<a class="button" href="${fields.joinUrl}">Join the session</a>
</div>
<p>See you there,</p>
<p>${fields.orgName}</p>
`;
return getDefaultTemplate(content, fields.branding);
}
});
export const sessionUpdatedEmail = defineEmail({
id: 'sessionUpdated',
subject: 'Updated: your live session details changed',
schema: z.object({
orgName: z.string().min(1),
courseName: z.string().min(1),
sessionTitle: z.string().min(1),
sessionTimeLabel: z.string().min(1),
joinUrl: z.string().min(1),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
<p>Hi there,</p>
<p>The live session <strong>${fields.sessionTitle}</strong> in <strong>${fields.courseName}</strong> has been updated.</p>
<p><strong>New time:</strong> ${fields.sessionTimeLabel}</p>
<p>The attached calendar invite will update the event already on your calendar.</p>
<div>
<a class="button" href="${fields.joinUrl}">Join the session</a>
</div>
<p>Cheers,</p>
<p>${fields.orgName}</p>
`;
return getDefaultTemplate(content, fields.branding);
}
});
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const studentCourseCompletionEmail = defineEmail({
id: 'studentCourseCompletion',
@@ -11,7 +12,8 @@ export const studentCourseCompletionEmail = defineEmail({
courseName: z.string().min(1),
studentName: z.string().min(1),
certificateUrl: z.string().url(),
customMessage: z.string().nullable().optional()
customMessage: z.string().nullable().optional(),
branding: ZEmailBranding
}),
render: (fields) => {
const customBlock =
@@ -28,6 +30,6 @@ export const studentCourseCompletionEmail = defineEmail({
<p>Cheers,<br/>${fields.orgName}</p>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const studentCourseInviteEmail = defineEmail({
id: 'studentCourseInvite',
@@ -10,7 +11,8 @@ export const studentCourseInviteEmail = defineEmail({
orgName: z.string().min(1),
courseName: z.string().min(1),
inviteLink: z.string().url(),
expiresAt: z.string().min(1)
expiresAt: z.string().min(1),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
@@ -22,6 +24,6 @@ export const studentCourseInviteEmail = defineEmail({
</div>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const studentCourseWelcomeEmail = defineEmail({
id: 'studentCourseWelcome',
@@ -9,18 +10,28 @@ export const studentCourseWelcomeEmail = defineEmail({
schema: z.object({
orgName: z.string().min(1),
courseName: z.string().min(1),
loginUrl: z.string().min(1)
loginUrl: z.string().min(1),
customMessage: z.string().optional(),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
const hasCustomMessage = !!fields.customMessage && fields.customMessage.trim().length > 0;
const intro = hasCustomMessage
? fields.customMessage
: `
<p>Hi there,</p>
<p>You now have access to <strong>${fields.courseName}</strong> in <strong>${fields.orgName}</strong>.</p>
<p><a href="${fields.loginUrl}">Sign in to ClassroomIO</a> to open the course and get started.</p>
<p>If you run into any issues, reach out to your instructor(s).</p>
<p>Cheers,</p>
<p>${fields.orgName}</p>
`;
return getDefaultTemplate(content);
const content = `
${intro}
<p><a href="${fields.loginUrl}">Sign in to the LMS</a> to open the course and start learning.</p>
`;
return getDefaultTemplate(content, fields.branding);
}
});
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const studentOrgInviteEmail = defineEmail({
id: 'studentOrgInvite',
@@ -11,7 +12,8 @@ export const studentOrgInviteEmail = defineEmail({
orgName: z.string().min(1),
inviteLink: z.url(),
expiresAt: z.string().min(1),
courseNames: z.string().optional()
courseNames: z.string().optional(),
branding: ZEmailBranding
}),
render: (fields) => {
const courseLine = fields.courseNames
@@ -28,6 +30,6 @@ export const studentOrgInviteEmail = defineEmail({
</div>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const studentProgramWelcomeEmail = defineEmail({
id: 'studentProgramWelcome',
@@ -9,18 +10,19 @@ export const studentProgramWelcomeEmail = defineEmail({
schema: z.object({
orgName: z.string().min(1),
programName: z.string().min(1),
loginUrl: z.string().min(1)
loginUrl: z.string().min(1),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
<p>Hi there,</p>
<p>You now have access to <strong>${fields.programName}</strong> in <strong>${fields.orgName}</strong>.</p>
<p><a href="${fields.loginUrl}">Sign in to ClassroomIO</a> to open the program and get started.</p>
<p><a href="${fields.loginUrl}">Sign in to the LMS</a> to open the program and start learning.</p>
<p>If you run into any issues, reach out to your instructor(s).</p>
<p>Cheers,</p>
<p>${fields.orgName}</p>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const studentProvePaymentEmail = defineEmail({
id: 'studentProvePayment',
@@ -10,7 +11,8 @@ export const studentProvePaymentEmail = defineEmail({
courseName: z.string().min(1),
teacherEmail: z.email(),
studentFullname: z.string().min(1),
orgName: z.string().min(1)
orgName: z.string().min(1),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
@@ -21,6 +23,6 @@ export const studentProvePaymentEmail = defineEmail({
<p>${fields.orgName}</p>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});
@@ -0,0 +1,54 @@
import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const submissionGradedEmail = defineEmail({
id: 'submissionGraded',
subject: 'Your exercise submission has been updated',
schema: z.object({
orgName: z.string().min(1),
studentName: z.string().min(1),
exerciseTitle: z.string().min(1),
courseName: z.string().min(1),
statusText: z.string().min(1),
exerciseLink: z.string().min(1),
score: z.string().optional(),
lessonTitle: z.string().optional(),
branding: ZEmailBranding
}),
render: (fields) => {
let content = `
<p>Hello ${fields.studentName},</p>
<p>The status of your submitted exercise on <strong>${fields.exerciseTitle}</strong> has been updated to <strong>${fields.statusText}</strong>.</p>
`;
if (fields.score) {
content += `
<p>Your score was <strong>${fields.score}</strong>.</p>
<div>
<a class="button" href="${fields.exerciseLink}">View your result</a>
</div>
`;
} else {
content += `
<div>
<a class="button" href="${fields.exerciseLink}">Open exercise</a>
</div>
`;
}
if (fields.lessonTitle) {
content += `
<p>This exercise is for <strong>${fields.lessonTitle}</strong> in a course you are taking titled <strong>${fields.courseName}</strong>.</p>
`;
} else {
content += `
<p>This exercise is in a course you are taking titled <strong>${fields.courseName}</strong>.</p>
`;
}
return getDefaultTemplate(content, fields.branding);
}
});
@@ -0,0 +1,30 @@
import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const submissionReceivedEmail = defineEmail({
id: 'submissionReceived',
subject: 'New exercise submission',
schema: z.object({
orgName: z.string().min(1),
studentName: z.string().min(1),
exerciseTitle: z.string().min(1),
exerciseLink: z.string().min(1),
submissionLink: z.string().min(1),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
<p>Hello,</p>
<p>A student <strong>${fields.studentName}</strong> just submitted an exercise <a href="${fields.exerciseLink}">${fields.exerciseTitle}</a>.</p>
<p>You can get started grading by clicking "Open Submissions".</p>
<div>
<a class="button" href="${fields.submissionLink}">Open Submissions</a>
</div>
`;
return getDefaultTemplate(content, fields.branding);
}
});
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const teacherCourseWelcomeEmail = defineEmail({
id: 'teacherCourseWelcome',
@@ -10,7 +11,8 @@ export const teacherCourseWelcomeEmail = defineEmail({
name: z.string().min(1),
orgName: z.string().min(1),
courseName: z.string().min(1),
inviteLink: z.url()
inviteLink: z.url(),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
@@ -22,6 +24,6 @@ export const teacherCourseWelcomeEmail = defineEmail({
</div>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const teacherStudentBuyRequestEmail = defineEmail({
id: 'teacherStudentBuyRequest',
@@ -9,7 +10,8 @@ export const teacherStudentBuyRequestEmail = defineEmail({
schema: z.object({
courseName: z.string().min(1),
studentEmail: z.string().email(),
studentFullname: z.string().min(1)
studentFullname: z.string().min(1),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
@@ -22,6 +24,6 @@ export const teacherStudentBuyRequestEmail = defineEmail({
</p>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});
@@ -2,6 +2,7 @@ import * as z from 'zod';
import { defineEmail } from '../send';
import { getDefaultTemplate } from '../templates';
import { ZEmailBranding } from '../core/branding';
export const teacherStudentJoinedEmail = defineEmail({
id: 'teacherStudentJoined',
@@ -9,7 +10,8 @@ export const teacherStudentJoinedEmail = defineEmail({
schema: z.object({
courseName: z.string().min(1),
studentName: z.string().min(1),
studentEmail: z.email()
studentEmail: z.email(),
branding: ZEmailBranding
}),
render: (fields) => {
const content = `
@@ -19,6 +21,6 @@ export const teacherStudentJoinedEmail = defineEmail({
<p>If you run into any issues, please don't fail to reach out to us, we'd love to make your teaching experience as easy as possible.</p>
`;
return getDefaultTemplate(content);
return getDefaultTemplate(content, fields.branding);
}
});