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
+7
View File
@@ -61,3 +61,10 @@ company/
# Claude Code local artifacts # Claude Code local artifacts
.claude/skills .claude/skills
.claude/scheduled_tasks.lock .claude/scheduled_tasks.lock
# Local db-agent playground (not committed)
apps/db-agent
# Generated htpasswd files (hashes are safe to store in secrets/CI, plaintext never committed)
.htpasswd
*.htpasswd
-6
View File
@@ -1,6 +0,0 @@
services:
- domain: cio-fe
port: 5173
- domain: cio-be
port: 3002
log_mode: minimal
+2
View File
@@ -6,6 +6,8 @@ Use this file to keep a single prioritized view of all PRDs.
| Rank | List # | PRD | Status | | Rank | List # | PRD | Status |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| - | - | `prd/super-admin` | draft |
| - | - | `prd/embed-link-platforms` | draft |
| 1 | - | `prd/notification-system` | todo | | 1 | - | `prd/notification-system` | todo |
| 2 | 14 | `prd/course-templates` | todo | | 2 | 14 | `prd/course-templates` | todo |
| 3 | 5 | `prd/course-import` | todo | | 3 | 5 | `prd/course-import` | todo |
+1 -1
View File
@@ -1,5 +1,5 @@
<a href="https://classroomio.com/"> <a href="https://classroomio.com/">
<img alt="ClassroomIO is a no-code tool that allows you build and scale your online bootcamp with ease." src="https://raw.githubusercontent.com/classroomio/classroomio/main/apps/website/static/classroomio-opengraph-image.png" /> <img alt="ClassroomIO is a no-code tool that allows you build and scale your online bootcamp with ease." src="https://raw.githubusercontent.com/classroomio/classroomio/main/apps/website/static/classroomio-opengraph-image.jpg" />
<h1 align="center">ClassroomIO.com</h1> <h1 align="center">ClassroomIO.com</h1>
<p align="center"> <p align="center">
The Open Source Learning Management System for Companies The Open Source Learning Management System for Companies
+8
View File
@@ -35,6 +35,7 @@ import { courseAiTutorRouter } from '@api/routes/course/ai-tutor';
import { authMiddleware } from '@api/middlewares/auth'; import { authMiddleware } from '@api/middlewares/auth';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key'; import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { cloneCourse } from '@api/services/course/clone'; import { cloneCourse } from '@api/services/course/clone';
import { sanitizeHtml } from '@cio/core/utils/sanitize-html';
import { complianceRouter } from '@api/routes/course/compliance'; import { complianceRouter } from '@api/routes/course/compliance';
import { contentRouter } from '@api/routes/course/content'; import { contentRouter } from '@api/routes/course/content';
import { courseMemberMiddleware } from '@api/middlewares/course-member'; import { courseMemberMiddleware } from '@api/middlewares/course-member';
@@ -329,6 +330,13 @@ export const courseRouter = new Hono()
const validatedData = c.req.valid('json'); const validatedData = c.req.valid('json');
const { tagIds, ...courseData } = validatedData; const { tagIds, ...courseData } = validatedData;
if (courseData.metadata?.welcomeEmailMessage) {
courseData.metadata = {
...courseData.metadata,
welcomeEmailMessage: sanitizeHtml(courseData.metadata.welcomeEmailMessage)
};
}
const result = await updateCourse(courseId, courseData); const result = await updateCourse(courseId, courseData);
if (tagIds !== undefined) { if (tagIds !== undefined) {
+28
View File
@@ -29,8 +29,14 @@ import {
initVideoRecordingUpload initVideoRecordingUpload
} from '@api/services/exercise/video-recording'; } from '@api/services/exercise/video-recording';
import {
getNotifyCourseExerciseStatusService,
notifyCourseExerciseService
} from '@api/services/course/notify-exercise';
import { Hono } from '@api/utils/hono'; import { Hono } from '@api/utils/hono';
import { authMiddleware } from '@api/middlewares/auth'; import { authMiddleware } from '@api/middlewares/auth';
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key'; import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
import { courseMemberMiddleware } from '@api/middlewares/course-member'; import { courseMemberMiddleware } from '@api/middlewares/course-member';
import { courseMemberOrAutomationKeyMiddleware } from '@api/middlewares/course-member-or-automation-key'; import { courseMemberOrAutomationKeyMiddleware } from '@api/middlewares/course-member-or-automation-key';
@@ -82,6 +88,28 @@ export const exerciseRouter = new Hono()
return handleError(c, error, 'Failed to fetch exercise submissions overview'); return handleError(c, error, 'Failed to fetch exercise submissions overview');
} }
}) })
.post('/:exerciseId/notify', authMiddleware, courseTeamMemberMiddleware, async (c) => {
try {
const courseId = c.req.param('courseId')!;
const exerciseId = c.req.param('exerciseId')!;
const result = await notifyCourseExerciseService(courseId, exerciseId);
return c.json({ success: true, data: result }, 202);
} catch (error) {
return handleError(c, error, 'Failed to notify students');
}
})
.get('/:exerciseId/notify/:jobId', authMiddleware, courseTeamMemberMiddleware, async (c) => {
try {
const jobId = c.req.param('jobId')!;
const pollCount = Number.parseInt(c.req.query('pollCount') ?? '0', 10) || 0;
const envelope = await getNotifyCourseExerciseStatusService(jobId, pollCount);
return c.json({ success: true, data: envelope }, 200);
} catch (error) {
return handleError(c, error, 'Failed to fetch notification status');
}
})
.get( .get(
'/:exerciseId', '/:exerciseId',
authOrAutomationKeyMiddleware, authOrAutomationKeyMiddleware,
+19
View File
@@ -38,6 +38,8 @@ import { Hono } from '@api/utils/hono';
import { ZLessonDownloadContent } from '@cio/utils/validation/course'; import { ZLessonDownloadContent } from '@cio/utils/validation/course';
import { authMiddleware } from '@api/middlewares/auth'; import { authMiddleware } from '@api/middlewares/auth';
import { courseMemberMiddleware } from '@api/middlewares/course-member'; import { courseMemberMiddleware } from '@api/middlewares/course-member';
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
import { notifyCourseSessionUpdateService } from '@api/services/course/notify-session';
import { generateLessonPdf } from '@api/utils/lesson'; import { generateLessonPdf } from '@api/utils/lesson';
import { getGroupMemberIdByCourseAndProfile } from '@cio/db/queries/group'; import { getGroupMemberIdByCourseAndProfile } from '@cio/db/queries/group';
import { handleError } from '@api/utils/errors'; import { handleError } from '@api/utils/errors';
@@ -121,6 +123,23 @@ export const lessonRouter = new Hono()
} }
} }
) )
.post(
'/:lessonId/notify-session-update',
authMiddleware,
courseTeamMemberMiddleware,
zValidator('param', ZLessonGetParam),
async (c) => {
try {
const courseId = c.req.param('courseId')!;
const { lessonId } = c.req.valid('param');
const result = await notifyCourseSessionUpdateService(courseId, lessonId);
return c.json({ success: true, data: result }, 202);
} catch (error) {
return handleError(c, error, 'Failed to notify session update');
}
}
)
.delete('/:lessonId', authMiddleware, courseMemberMiddleware, zValidator('param', ZLessonGetParam), async (c) => { .delete('/:lessonId', authMiddleware, courseMemberMiddleware, zValidator('param', ZLessonGetParam), async (c) => {
try { try {
const { lessonId } = c.req.valid('param'); const { lessonId } = c.req.valid('param');
@@ -3,9 +3,19 @@ import { runProgramGoalEvaluationSweep, runProgramGoalReminderScan } from '@api/
import { Hono } from '@api/utils/hono'; import { Hono } from '@api/utils/hono';
import { apiKeyMiddleware } from '@api/middlewares/api-key'; import { apiKeyMiddleware } from '@api/middlewares/api-key';
import { enqueueSessionReminderScan } from '@cio/jobs';
import { handleError } from '@api/utils/errors'; import { handleError } from '@api/utils/errors';
export const internalComplianceRouter = new Hono() export const internalComplianceRouter = new Hono()
.post('/send-session-reminders', apiKeyMiddleware, async (c) => {
try {
const jobId = await enqueueSessionReminderScan();
return c.json({ success: true, data: { jobId: jobId ?? null } }, 200);
} catch (error) {
return handleError(c, error, 'Failed to enqueue session reminder scan');
}
})
.post('/check-expiry', apiKeyMiddleware, async (c) => { .post('/check-expiry', apiKeyMiddleware, async (c) => {
try { try {
const data = await runComplianceExpiryCheck(); const data = await runComplianceExpiryCheck();
+41 -10
View File
@@ -29,11 +29,12 @@ import crypto from 'node:crypto';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url'; import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { getCourseTeachers } from '@cio/db/queries/course/people'; import { getCourseTeachers } from '@cio/db/queries/course/people';
import { getProfileById } from '@cio/db/queries/auth'; import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName } from '@cio/email'; import { buildEmailFromName, buildEmailBranding, type EmailBranding } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs'; import { enqueueTransactionalEmail } from '@api/services/jobs';
import { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth'; import { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth';
import { generateSlug } from '@cio/utils/functions'; import { generateSlug } from '@cio/utils/functions';
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance'; import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
import { getWelcomeSessionIcs } from './session-invite';
import { db } from '@cio/db/drizzle'; import { db } from '@cio/db/drizzle';
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics'; import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
@@ -358,20 +359,26 @@ async function sendStudentJoinEmails(input: {
courseName: string; courseName: string;
orgName: string; orgName: string;
org: OrgUrlInfo; org: OrgUrlInfo;
branding: EmailBranding;
studentId: string; studentId: string;
studentEmail: string; studentEmail: string;
welcomeEmailMessage?: string | null;
}) { }) {
try { try {
const loginUrl = getDashboardBaseUrl(input.org); const loginUrl = getDashboardBaseUrl(input.org);
const ics = await getWelcomeSessionIcs(input.courseId);
await enqueueTransactionalEmail('studentCourseWelcome', { await enqueueTransactionalEmail('studentCourseWelcome', {
to: input.studentEmail, to: input.studentEmail,
fields: { fields: {
orgName: input.orgName, orgName: input.orgName,
courseName: input.courseName, courseName: input.courseName,
loginUrl loginUrl,
customMessage: input.welcomeEmailMessage ?? undefined,
branding: input.branding
}, },
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`), from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-welcome:${input.courseId}:${input.studentId}` idempotencyKey: `course-welcome:${input.courseId}:${input.studentId}`,
ics
}); });
} catch (error) { } catch (error) {
console.error('Failed to enqueue student welcome email:', error); console.error('Failed to enqueue student welcome email:', error);
@@ -395,7 +402,8 @@ async function sendStudentJoinEmails(input: {
fields: { fields: {
courseName: input.courseName, courseName: input.courseName,
studentName, studentName,
studentEmail: input.studentEmail studentEmail: input.studentEmail,
branding: input.branding
}, },
from: buildEmailFromName('ClassroomIO'), from: buildEmailFromName('ClassroomIO'),
idempotencyKey: `teacher-student-joined:${input.courseId}:${input.studentId}` idempotencyKey: `teacher-student-joined:${input.courseId}:${input.studentId}`
@@ -414,6 +422,7 @@ async function createEmailInviteAndSend(input: {
courseName: string; courseName: string;
courseSlug: string; courseSlug: string;
org: OrgUrlInfo; org: OrgUrlInfo;
branding: EmailBranding;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
shouldSend: boolean; shouldSend: boolean;
}) { }) {
@@ -450,7 +459,8 @@ async function createEmailInviteAndSend(input: {
orgName: input.orgName, orgName: input.orgName,
courseName: input.courseName, courseName: input.courseName,
inviteLink: createdInvite.inviteLink, inviteLink: createdInvite.inviteLink,
expiresAt: getExpiryLabel(createdInvite.expiresAt) expiresAt: getExpiryLabel(createdInvite.expiresAt),
branding: input.branding
}, },
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`), from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-invite-email:${createdInvite.id}` idempotencyKey: `course-invite-email:${createdInvite.id}`
@@ -549,6 +559,11 @@ export async function createStudentInvite(courseId: string, createdByProfileId:
const orgName = courseOrgData.orgName || 'ClassroomIO'; const orgName = courseOrgData.orgName || 'ClassroomIO';
const courseName = courseOrgData.courseTitle || course[0].title || 'Course'; const courseName = courseOrgData.courseTitle || course[0].title || 'Course';
const orgBranding = buildEmailBranding({
name: courseOrgData.orgName,
avatarUrl: courseOrgData.orgAvatarUrl,
theme: courseOrgData.orgTheme
});
const inviteResults = await Promise.all( const inviteResults = await Promise.all(
recipients.valid.map((recipientEmail) => recipients.valid.map((recipientEmail) =>
@@ -561,6 +576,7 @@ export async function createStudentInvite(courseId: string, createdByProfileId:
courseName, courseName,
courseSlug, courseSlug,
org: orgUrlInfo, org: orgUrlInfo,
branding: orgBranding,
metadata: data.metadata, metadata: data.metadata,
shouldSend: data.sendEmail shouldSend: data.sendEmail
}) })
@@ -630,7 +646,8 @@ export async function enrollInCourse(
throw new AppError('This course is not available for enrollment', ErrorCodes.VALIDATION_ERROR, 400); throw new AppError('This course is not available for enrollment', ErrorCodes.VALIDATION_ERROR, 400);
} }
const courseMetadata = (courseWithRelations.metadata as { allowNewStudent?: boolean } | null) ?? null; const courseMetadata =
(courseWithRelations.metadata as { allowNewStudent?: boolean; welcomeEmailMessage?: string | null } | null) ?? null;
const isInternalEnrollmentOnly = org.settings?.internalEnrollmentOnly ?? false; const isInternalEnrollmentOnly = org.settings?.internalEnrollmentOnly ?? false;
const orgMemberId = await getOrganizationMemberIdByOrgAndProfile(org.id, user.id); const orgMemberId = await getOrganizationMemberIdByOrgAndProfile(org.id, user.id);
@@ -677,8 +694,10 @@ export async function enrollInCourse(
courseName: title, courseName: title,
orgName: org.name, orgName: org.name,
org: { siteName: org.siteName, customDomain: org.customDomain, isCustomDomainVerified: org.isCustomDomainVerified }, org: { siteName: org.siteName, customDomain: org.customDomain, isCustomDomainVerified: org.isCustomDomainVerified },
branding: buildEmailBranding({ name: org.name, avatarUrl: org.avatarUrl, theme: org.theme }),
studentId: user.id, studentId: user.id,
studentEmail: normalizedEmail studentEmail: normalizedEmail,
welcomeEmailMessage: courseMetadata?.welcomeEmailMessage
}); });
trackServerEvent({ trackServerEvent({
@@ -944,7 +963,9 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
orgName: organization.name, orgName: organization.name,
orgSiteName: organization.siteName, orgSiteName: organization.siteName,
orgCustomDomain: organization.customDomain, orgCustomDomain: organization.customDomain,
orgIsCustomDomainVerified: organization.isCustomDomainVerified orgIsCustomDomainVerified: organization.isCustomDomainVerified,
orgAvatarUrl: organization.avatarUrl,
orgTheme: organization.theme
}; };
} }
@@ -1025,7 +1046,11 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
orgName: organization.name, orgName: organization.name,
orgSiteName: organization.siteName, orgSiteName: organization.siteName,
orgCustomDomain: organization.customDomain, orgCustomDomain: organization.customDomain,
orgIsCustomDomainVerified: organization.isCustomDomainVerified orgIsCustomDomainVerified: organization.isCustomDomainVerified,
orgAvatarUrl: organization.avatarUrl,
orgTheme: organization.theme,
welcomeEmailMessage:
(course.metadata as { welcomeEmailMessage?: string | null } | null)?.welcomeEmailMessage ?? null
}; };
}); });
@@ -1041,8 +1066,14 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
customDomain: result.orgCustomDomain, customDomain: result.orgCustomDomain,
isCustomDomainVerified: result.orgIsCustomDomainVerified isCustomDomainVerified: result.orgIsCustomDomainVerified
}, },
branding: buildEmailBranding({
name: result.orgName,
avatarUrl: result.orgAvatarUrl,
theme: result.orgTheme
}),
studentId: user.id, studentId: user.id,
studentEmail: normalizedEmail studentEmail: normalizedEmail,
welcomeEmailMessage: result.welcomeEmailMessage
}); });
trackServerEvent({ trackServerEvent({
@@ -0,0 +1,90 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { getCourseById, getCourseWithOrgData } from '@cio/db/queries/course';
import { getExerciseById } from '@cio/db/queries/exercise';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { buildEmailFromName, buildEmailBranding } from '@cio/email';
import { QUEUE_NAMES, enqueueNotifyCourseExercise, getQueueJobEnvelope, type JobEnvelope } from '@cio/jobs';
const NOTIFY_DOMAIN = 'notify-course-exercise';
/**
* Enqueue a background job that emails every course member a "quiz assigned"
* nudge with a deep link to take the exercise. Returns the job id the
* dashboard polls for completion.
*/
export async function notifyCourseExerciseService(courseId: string, exerciseId: string): Promise<{ jobId: string }> {
const [courseRows, orgData, exercise] = await Promise.all([
getCourseById(courseId),
getCourseWithOrgData(courseId),
getExerciseById(exerciseId)
]);
const course = courseRows?.[0];
if (!course) {
throw new AppError('Course not found', ErrorCodes.NOT_FOUND, 404);
}
if (!orgData) {
throw new AppError('Course organization not found', ErrorCodes.NOT_FOUND, 404);
}
if (!exercise || exercise.courseId !== courseId) {
throw new AppError('Exercise not found for this course', ErrorCodes.NOT_FOUND, 404);
}
const baseUrl = getDashboardBaseUrl({
siteName: orgData.orgSiteName,
customDomain: orgData.orgCustomDomain,
isCustomDomainVerified: orgData.orgIsCustomDomainVerified
});
// PUBLIC courses are taken on the anonymous slug-based org-site route;
// every other type uses the authenticated id-based LMS route.
let quizUrl: string;
if (course.type === 'PUBLIC') {
if (!course.slug || !exercise.slug) {
throw new AppError('Public course/exercise is missing a slug to link to', ErrorCodes.VALIDATION_ERROR, 400);
}
quizUrl = `${baseUrl}/course/${course.slug}/lesson/${exercise.slug}`;
} else {
quizUrl = `${baseUrl}/courses/${courseId}/exercises/${exerciseId}`;
}
const orgName = orgData.orgName || 'ClassroomIO';
const jobId = await enqueueNotifyCourseExercise({
courseId,
exerciseTitle: exercise.title,
courseName: course.title,
orgName,
fromName: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
quizUrl,
branding: buildEmailBranding({
name: orgData.orgName,
avatarUrl: orgData.orgAvatarUrl,
theme: orgData.orgTheme
})
});
if (!jobId) {
throw new AppError('Failed to enqueue notification job', ErrorCodes.INTERNAL_ERROR, 500);
}
return { jobId };
}
/**
* Read the status of a notify job (BullMQ-backed envelope). Throws 404 if the
* job is gone (retention swept it).
*/
export async function getNotifyCourseExerciseStatusService(jobId: string, pollCount = 0): Promise<JobEnvelope> {
const envelope = await getQueueJobEnvelope(QUEUE_NAMES.notifications, jobId, NOTIFY_DOMAIN, pollCount);
if (!envelope) {
throw new AppError('Notification job not found', ErrorCodes.NOT_FOUND, 404);
}
return envelope;
}
@@ -0,0 +1,16 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { enqueueNotifyCourseSessionUpdate } from '@cio/jobs';
/**
* Enqueue a background job that emails every course member an updated calendar
* invite after a teacher changes a live session. Returns the job id.
*/
export async function notifyCourseSessionUpdateService(courseId: string, lessonId: string): Promise<{ jobId: string }> {
const jobId = await enqueueNotifyCourseSessionUpdate({ courseId, lessonId });
if (!jobId) {
throw new AppError('Failed to enqueue session update notification', ErrorCodes.INTERNAL_ERROR, 500);
}
return { jobId };
}
@@ -2,7 +2,7 @@ import { AppError, ErrorCodes } from '@api/utils/errors';
import { getCourseTeachers } from '@cio/db/queries/course/people'; import { getCourseTeachers } from '@cio/db/queries/course/people';
import { getCourseWithOrgData } from '@cio/db/queries/course'; import { getCourseWithOrgData } from '@cio/db/queries/course';
import { buildEmailFromName } from '@cio/email'; import { buildEmailFromName, buildEmailBranding } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs'; import { enqueueTransactionalEmail } from '@api/services/jobs';
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics'; import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
@@ -27,6 +27,11 @@ export async function createPaymentRequest(data: PaymentRequestData) {
const courseName = course.courseTitle || ''; const courseName = course.courseTitle || '';
const orgName = course.orgName || 'ClassroomIO'; const orgName = course.orgName || 'ClassroomIO';
const branding = buildEmailBranding({
name: course.orgName,
avatarUrl: course.orgAvatarUrl,
theme: course.orgTheme
});
const groupId = course.groupId; const groupId = course.groupId;
if (!groupId) { if (!groupId) {
@@ -48,7 +53,8 @@ export async function createPaymentRequest(data: PaymentRequestData) {
fields: { fields: {
courseName, courseName,
studentEmail: data.studentEmail, studentEmail: data.studentEmail,
studentFullname: data.studentFullname studentFullname: data.studentFullname,
branding
}, },
from: buildEmailFromName('ClassroomIO'), from: buildEmailFromName('ClassroomIO'),
idempotencyKey: `payment-request:teacher:${data.courseId}:${data.studentEmail}` idempotencyKey: `payment-request:teacher:${data.courseId}:${data.studentEmail}`
@@ -64,7 +70,8 @@ export async function createPaymentRequest(data: PaymentRequestData) {
courseName, courseName,
teacherEmail, teacherEmail,
studentFullname: data.studentFullname, studentFullname: data.studentFullname,
orgName orgName,
branding
}, },
from: buildEmailFromName(`${orgName} - ClassroomIO`), from: buildEmailFromName(`${orgName} - ClassroomIO`),
replyTo: teacherEmail, replyTo: teacherEmail,
+21 -5
View File
@@ -14,9 +14,10 @@ import type { TGroupmember } from '@cio/db/types';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url'; import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { getCourseWithOrgData } from '@cio/db/queries/course'; import { getCourseWithOrgData } from '@cio/db/queries/course';
import { getProfileById } from '@cio/db/queries/auth'; import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName } from '@cio/email'; import { buildEmailFromName, buildEmailBranding } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs'; import { enqueueTransactionalEmail } from '@api/services/jobs';
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance'; import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
import { getWelcomeSessionIcs } from './session-invite';
/** /**
* Gets all course members (people) for a course * Gets all course members (people) for a course
@@ -68,6 +69,11 @@ export async function addMember(
if (courseOrgData) { if (courseOrgData) {
const courseName = courseOrgData.courseTitle || ''; const courseName = courseOrgData.courseTitle || '';
const orgName = courseOrgData.orgName || 'ClassroomIO'; const orgName = courseOrgData.orgName || 'ClassroomIO';
const branding = buildEmailBranding({
name: courseOrgData.orgName,
avatarUrl: courseOrgData.orgAvatarUrl,
theme: courseOrgData.orgTheme
});
const loginUrl = getDashboardBaseUrl({ const loginUrl = getDashboardBaseUrl({
siteName: courseOrgData.orgSiteName, siteName: courseOrgData.orgSiteName,
customDomain: courseOrgData.orgCustomDomain, customDomain: courseOrgData.orgCustomDomain,
@@ -97,10 +103,13 @@ export async function addMember(
fields: { fields: {
orgName, orgName,
courseName, courseName,
loginUrl loginUrl,
customMessage: courseOrgData.welcomeEmailMessage ?? undefined,
branding
}, },
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`), from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-people-student-welcome:${courseId}:${studentEmail}` idempotencyKey: `course-people-student-welcome:${courseId}:${studentEmail}`,
ics: await getWelcomeSessionIcs(courseId)
}); });
} catch (emailError) { } catch (emailError) {
console.error('Failed to enqueue student welcome email:', emailError); console.error('Failed to enqueue student welcome email:', emailError);
@@ -119,7 +128,8 @@ export async function addMember(
fields: { fields: {
courseName, courseName,
studentName, studentName,
studentEmail studentEmail,
branding
}, },
from: buildEmailFromName('ClassroomIO'), from: buildEmailFromName('ClassroomIO'),
idempotencyKey: `course-people-teacher-joined:${courseId}:${studentEmail}` idempotencyKey: `course-people-teacher-joined:${courseId}:${studentEmail}`
@@ -182,6 +192,11 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
const courseName = courseOrgData.courseTitle || ''; const courseName = courseOrgData.courseTitle || '';
const orgName = courseOrgData.orgName || 'ClassroomIO'; const orgName = courseOrgData.orgName || 'ClassroomIO';
const orgSiteName = courseOrgData.orgSiteName || ''; const orgSiteName = courseOrgData.orgSiteName || '';
const branding = buildEmailBranding({
name: courseOrgData.orgName,
avatarUrl: courseOrgData.orgAvatarUrl,
theme: courseOrgData.orgTheme
});
// Add all members // Add all members
const addedMembers = await Promise.all(members.map((member) => addCourseMember(courseId, member))); const addedMembers = await Promise.all(members.map((member) => addCourseMember(courseId, member)));
@@ -217,7 +232,8 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
name, name,
orgName, orgName,
courseName, courseName,
inviteLink inviteLink,
branding
}, },
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`), from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
idempotencyKey: `teacher-course-welcome:${courseId}:${email}` idempotencyKey: `teacher-course-welcome:${courseId}:${email}`
@@ -0,0 +1,37 @@
import { getCourseById, getUpcomingSessionsForCourseIds } from '@cio/db/queries/course';
import { buildSessionIcs } from '@cio/email';
/**
* Build the calendar invite (.ics) for a course's next upcoming live session,
* for attaching to the welcome email on enrollment. Returns undefined when the
* course is not a LIVE_CLASS or has no upcoming session. The invite reuses the
* same UID as the session reminders, so calendars treat them as one event.
*/
export async function getWelcomeSessionIcs(courseId: string): Promise<string | undefined> {
try {
const courseRows = await getCourseById(courseId);
const course = courseRows?.[0];
if (!course || course.type !== 'LIVE_CLASS') {
return undefined;
}
const upcoming = (await getUpcomingSessionsForCourseIds([courseId])).get(courseId);
if (!upcoming) {
return undefined;
}
return buildSessionIcs({
uid: `session-${upcoming.lessonId}@classroomio`,
sequence: 0,
method: 'PUBLISH',
start: upcoming.lessonAt,
title: upcoming.lessonTitle,
description: `Join your live session: ${upcoming.callUrl}`,
url: upcoming.callUrl,
alarmsBeforeMinutes: [1440, 60]
});
} catch (error) {
console.error('getWelcomeSessionIcs error:', error);
return undefined;
}
}
+4 -1
View File
@@ -20,6 +20,8 @@ export interface EnqueueTemplateEmailInput<TId extends EmailId> extends CommonOp
fields: z.infer<EmailSchemaFor<TId>>; fields: z.infer<EmailSchemaFor<TId>>;
from?: string; from?: string;
replyTo?: string; replyTo?: string;
/** Optional iCalendar (.ics) body attached as a text/calendar part. */
ics?: string;
} }
export interface EnqueueRawEmailInput extends CommonOptions { export interface EnqueueRawEmailInput extends CommonOptions {
@@ -81,7 +83,8 @@ export async function enqueueTransactionalEmail<TId extends EmailId>(
to: recipient, to: recipient,
fields: validatedFields, fields: validatedFields,
from: input.from, from: input.from,
replyTo: input.replyTo replyTo: input.replyTo,
ics: input.ics
}, },
{ idempotencyKey: recipientKey(input.idempotencyKey, recipient, recipients.length) } { idempotencyKey: recipientKey(input.idempotencyKey, recipient, recipients.length) }
); );
+15 -3
View File
@@ -19,7 +19,7 @@ import {
} from '@cio/db/queries/newsfeed'; } from '@cio/db/queries/newsfeed';
import { env } from '@cio/core/config/env'; import { env } from '@cio/core/config/env';
import { buildEmailFromName } from '@cio/email'; import { buildEmailFromName, buildEmailBranding } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs'; import { enqueueTransactionalEmail } from '@api/services/jobs';
/** /**
@@ -359,6 +359,11 @@ async function sendNewsfeedPostEmail(feedId: string, authorId: string) {
const orgName = feedData.organization?.name || 'ClassroomIO'; const orgName = feedData.organization?.name || 'ClassroomIO';
const orgSiteName = feedData.organization?.siteName || 'app'; const orgSiteName = feedData.organization?.siteName || 'app';
const branding = buildEmailBranding({
name: feedData.organization?.name,
avatarUrl: feedData.organization?.avatarUrl,
theme: feedData.organization?.theme
});
const postLink = `https://${orgSiteName}.${TENANT_ROOT_DOMAIN}/courses/${feedData.courseId}?feedId=${feedData.feedId}`; const postLink = `https://${orgSiteName}.${TENANT_ROOT_DOMAIN}/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
const recipients = feedData.courseMembers const recipients = feedData.courseMembers
@@ -374,7 +379,8 @@ async function sendNewsfeedPostEmail(feedId: string, authorId: string) {
teacherName: feedData.author?.fullname || 'A teacher', teacherName: feedData.author?.fullname || 'A teacher',
content: feedData.content || '', content: feedData.content || '',
postLink, postLink,
orgName orgName,
branding
}, },
from: buildEmailFromName(`${orgName} - ClassroomIO`), from: buildEmailFromName(`${orgName} - ClassroomIO`),
replyTo: feedData.author?.email || 'noreply@classroomio.com', replyTo: feedData.author?.email || 'noreply@classroomio.com',
@@ -403,6 +409,11 @@ async function sendNewsfeedCommentEmail(feedId: string, commentContent: string)
const orgName = feedData.organization?.name || 'ClassroomIO'; const orgName = feedData.organization?.name || 'ClassroomIO';
const orgSiteName = feedData.organization?.siteName || 'app'; const orgSiteName = feedData.organization?.siteName || 'app';
const branding = buildEmailBranding({
name: feedData.organization?.name,
avatarUrl: feedData.organization?.avatarUrl,
theme: feedData.organization?.theme
});
const postLink = `https://${orgSiteName}.${TENANT_ROOT_DOMAIN}/courses/${feedData.courseId}?feedId=${feedData.feedId}`; const postLink = `https://${orgSiteName}.${TENANT_ROOT_DOMAIN}/courses/${feedData.courseId}?feedId=${feedData.feedId}`;
await enqueueTransactionalEmail('newsfeedComment', { await enqueueTransactionalEmail('newsfeedComment', {
@@ -411,7 +422,8 @@ async function sendNewsfeedCommentEmail(feedId: string, commentContent: string)
courseTitle: feedData.courseTitle, courseTitle: feedData.courseTitle,
comment: commentContent, comment: commentContent,
postLink, postLink,
orgName orgName,
branding
}, },
from: buildEmailFromName(`${orgName} - ClassroomIO`), from: buildEmailFromName(`${orgName} - ClassroomIO`),
replyTo: 'noreply@classroomio.com' replyTo: 'noreply@classroomio.com'
+25 -10
View File
@@ -5,7 +5,7 @@ import type {
TImportAudienceMembers TImportAudienceMembers
} from '@cio/utils/validation/organization'; } from '@cio/utils/validation/organization';
import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group'; import { addGroupMembers, enrollUsersInCourseGroups, getExistingGroupMembers } from '@cio/db/queries/group';
import { buildEmailFromName } from '@cio/email'; import { buildEmailFromName, buildEmailBranding } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs'; import { enqueueTransactionalEmail } from '@api/services/jobs';
import { addProgramMember, getExistingProgramMembers, getProgramsByOrg } from '@cio/db/queries/program'; import { addProgramMember, getExistingProgramMembers, getProgramsByOrg } from '@cio/db/queries/program';
import { import {
@@ -30,6 +30,7 @@ import crypto from 'node:crypto';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url'; import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { getProfilesByEmails } from '@cio/db/queries/auth'; import { getProfilesByEmails } from '@cio/db/queries/auth';
import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance'; import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance';
import { getWelcomeSessionIcs } from '../course/session-invite';
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const ORG_INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; const ORG_INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
@@ -62,8 +63,11 @@ function generateToken(): string {
return crypto.randomBytes(32).toString('base64url'); return crypto.randomBytes(32).toString('base64url');
} }
function buildInviteLink(token: string): string { function buildInviteLink(
return `${getDashboardBaseUrl()}/invite/${encodeURIComponent(token)}`; token: string,
org?: { siteName?: string | null; customDomain?: string | null; isCustomDomainVerified?: boolean | null }
): string {
return `${getDashboardBaseUrl(org)}/invite/${encodeURIComponent(token)}`;
} }
function getExpiryLabel(expiresAtIso: string): string { function getExpiryLabel(expiresAtIso: string): string {
@@ -170,6 +174,7 @@ async function enrollAudienceStudentProfilesInCourses(
const validGroupIds = courseGroups.map((cg) => cg.groupId).filter(Boolean) as string[]; const validGroupIds = courseGroups.map((cg) => cg.groupId).filter(Boolean) as string[];
const courseTitleByGroupId = new Map(courseGroups.map((cg) => [cg.groupId, cg.courseTitle])); const courseTitleByGroupId = new Map(courseGroups.map((cg) => [cg.groupId, cg.courseTitle]));
const welcomeMessageByGroupId = new Map(courseGroups.map((cg) => [cg.groupId, cg.welcomeEmailMessage]));
const validProfiles = uniqueProfileIds.filter((id) => validProfileIds.has(id)); const validProfiles = uniqueProfileIds.filter((id) => validProfileIds.has(id));
const pairs = validProfiles.flatMap((profileId) => validGroupIds.map((groupId) => ({ groupId, profileId }))); const pairs = validProfiles.flatMap((profileId) => validGroupIds.map((groupId) => ({ groupId, profileId })));
@@ -198,6 +203,10 @@ async function enrollAudienceStudentProfilesInCourses(
const loginUrl = getDashboardBaseUrl(organization); const loginUrl = getDashboardBaseUrl(organization);
if (shouldSendEmail && toInsert.length > 0) { if (shouldSendEmail && toInsert.length > 0) {
const icsByGroupId = new Map(
await Promise.all(courseGroups.map(async (cg) => [cg.groupId, await getWelcomeSessionIcs(cg.courseId)] as const))
);
const emailPromises = toInsert const emailPromises = toInsert
.filter((p) => profileEmailMap.get(p.profileId)) .filter((p) => profileEmailMap.get(p.profileId))
.map(async (p) => { .map(async (p) => {
@@ -208,10 +217,13 @@ async function enrollAudienceStudentProfilesInCourses(
fields: { fields: {
orgName: organization.name, orgName: organization.name,
courseName: courseTitleByGroupId.get(p.groupId) || 'Course', courseName: courseTitleByGroupId.get(p.groupId) || 'Course',
loginUrl loginUrl,
customMessage: welcomeMessageByGroupId.get(p.groupId) ?? undefined,
branding: buildEmailBranding(organization)
}, },
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`), from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
idempotencyKey: `audience-course-welcome:${p.groupId}:${p.profileId}` idempotencyKey: `audience-course-welcome:${p.groupId}:${p.profileId}`,
ics: icsByGroupId.get(p.groupId)
}); });
emailsSent++; emailsSent++;
} catch (emailError) { } catch (emailError) {
@@ -290,7 +302,8 @@ async function enrollAudienceStudentProfilesInPrograms(
fields: { fields: {
orgName: organization.name, orgName: organization.name,
programName: programNameById.get(pair.programId) || 'Program', programName: programNameById.get(pair.programId) || 'Program',
loginUrl loginUrl,
branding: buildEmailBranding(organization)
}, },
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`), from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
idempotencyKey: `audience-program-welcome:${pair.programId}:${pair.profileId}` idempotencyKey: `audience-program-welcome:${pair.programId}:${pair.profileId}`
@@ -387,7 +400,7 @@ async function createStudentOrgInvitesAndSendEmails(input: {
const invite = inviteByEmail.get(email)!; const invite = inviteByEmail.get(email)!;
const token = tokenByEmail.get(email)!; const token = tokenByEmail.get(email)!;
try { try {
const inviteLink = buildInviteLink(token); const inviteLink = buildInviteLink(token, organization);
await enqueueTransactionalEmail('studentOrgInvite', { await enqueueTransactionalEmail('studentOrgInvite', {
to: email, to: email,
fields: { fields: {
@@ -395,7 +408,8 @@ async function createStudentOrgInvitesAndSendEmails(input: {
orgName: organization.name, orgName: organization.name,
inviteLink, inviteLink,
expiresAt: getExpiryLabel(expiresAt), expiresAt: getExpiryLabel(expiresAt),
courseNames: accessNamesLabel courseNames: accessNamesLabel,
branding: buildEmailBranding(organization)
}, },
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`), from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
idempotencyKey: `student-org-invite:${invite.id}` idempotencyKey: `student-org-invite:${invite.id}`
@@ -677,7 +691,7 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
let emailSent = false; let emailSent = false;
try { try {
const inviteLink = buildInviteLink(token); const inviteLink = buildInviteLink(token, organization);
await enqueueTransactionalEmail('studentOrgInvite', { await enqueueTransactionalEmail('studentOrgInvite', {
to: emailToUse, to: emailToUse,
fields: { fields: {
@@ -685,7 +699,8 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
orgName: organization.name, orgName: organization.name,
inviteLink, inviteLink,
expiresAt: getExpiryLabel(expiresAt), expiresAt: getExpiryLabel(expiresAt),
courseNames: accessNamesLabel courseNames: accessNamesLabel,
branding: buildEmailBranding(organization)
}, },
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`), from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
idempotencyKey: `student-org-invite:${invite.id}` idempotencyKey: `student-org-invite:${invite.id}`
+9 -4
View File
@@ -32,6 +32,7 @@ import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org'; import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org';
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile'; import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
import { enqueueTransactionalEmail } from '@api/services/jobs'; import { enqueueTransactionalEmail } from '@api/services/jobs';
import { buildEmailBranding } from '@cio/email';
import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance'; import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance';
type OrganizationInviteStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED' | 'ACCEPTED'; type OrganizationInviteStatus = 'ACTIVE' | 'EXPIRED' | 'REVOKED' | 'ACCEPTED';
@@ -60,8 +61,11 @@ function normalizeEmails(emails: string[]): string[] {
return [...new Set(emails.map((email) => email.toLowerCase().trim()).filter(Boolean))]; return [...new Set(emails.map((email) => email.toLowerCase().trim()).filter(Boolean))];
} }
function buildInviteLink(token: string): string { function buildInviteLink(
return `${getDashboardBaseUrl()}/invite/${encodeURIComponent(token)}`; token: string,
org?: { siteName?: string | null; customDomain?: string | null; isCustomDomainVerified?: boolean | null }
): string {
return `${getDashboardBaseUrl(org)}/invite/${encodeURIComponent(token)}`;
} }
function getRoleLabel(roleId: number): string { function getRoleLabel(roleId: number): string {
@@ -245,7 +249,7 @@ export async function inviteTeamMembers(orgId: string, emails: string[], roleId:
} }
}); });
const inviteLink = buildInviteLink(token); const inviteLink = buildInviteLink(token, organization);
try { try {
await enqueueTransactionalEmail('inviteTeacher', { await enqueueTransactionalEmail('inviteTeacher', {
@@ -256,7 +260,8 @@ export async function inviteTeamMembers(orgId: string, emails: string[], roleId:
orgSiteName: organization.siteName, orgSiteName: organization.siteName,
roleName, roleName,
expiresAt: getExpiryLabel(expiresAt), expiresAt: getExpiryLabel(expiresAt),
inviteLink inviteLink,
branding: buildEmailBranding(organization)
}, },
idempotencyKey: `org-invite-teacher:${invite.id}` idempotencyKey: `org-invite-teacher:${invite.id}`
}); });
+7 -1
View File
@@ -1,6 +1,7 @@
import { AppError, ErrorCodes } from '@api/utils/errors'; import { AppError, ErrorCodes } from '@api/utils/errors';
import { enqueueTransactionalEmail } from '@api/services/jobs'; import { enqueueTransactionalEmail } from '@api/services/jobs';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url'; import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
import { buildEmailBranding } from '@cio/email';
import { import {
countAssignmentsByStatus, countAssignmentsByStatus,
createProgramGoal, createProgramGoal,
@@ -425,7 +426,12 @@ export async function runProgramGoalReminderScan(): Promise<{
daysUntilDue, daysUntilDue,
completedCount: row.assignment.completedCount ?? 0, completedCount: row.assignment.completedCount ?? 0,
requiredCount: row.assignment.requiredCount ?? 0, requiredCount: row.assignment.requiredCount ?? 0,
loginUrl: `${loginUrl}/lms/programs/${row.programId}` loginUrl: `${loginUrl}/lms/programs/${row.programId}`,
branding: buildEmailBranding({
name: row.organizationName,
avatarUrl: row.organizationAvatarUrl,
theme: row.organizationTheme
})
}, },
idempotencyKey: `program-goal-reminder:${row.assignment.id}:${daysUntilDue}:${dayKey}` idempotencyKey: `program-goal-reminder:${row.assignment.id}:${daysUntilDue}:${dayKey}`
}); });
+61 -50
View File
@@ -12,8 +12,8 @@ import type {
TSubmissionGradesUpdate, TSubmissionGradesUpdate,
TSubmissionUpdate TSubmissionUpdate
} from '@cio/utils/validation/submission'; } from '@cio/utils/validation/submission';
import { buildEmailFromName } from '@cio/email'; import { buildEmailBranding, buildEmailFromName } from '@cio/email';
import { enqueueRawEmail } from '@api/services/jobs'; import { enqueueTransactionalEmail } from '@api/services/jobs';
import { import {
createSubmission, createSubmission,
deleteSubmission, deleteSubmission,
@@ -34,7 +34,7 @@ import {
getQuestionTypeByTypename, getQuestionTypeByTypename,
requiresManualGrading requiresManualGrading
} from '@cio/question-types'; } from '@cio/question-types';
import { getCourseById, getOrganizationByCourseId } from '@cio/db/queries/course'; import { getCourseById, getCourseWithOrgData } from '@cio/db/queries/course';
import { getCourseTeachers, getProfileByGroupMemberId } from '@cio/db/queries/course/people'; import { getCourseTeachers, getProfileByGroupMemberId } from '@cio/db/queries/course/people';
import { getExerciseById, getExerciseWithRelationsOptimized } from '@cio/db/queries/exercise'; import { getExerciseById, getExerciseWithRelationsOptimized } from '@cio/db/queries/exercise';
import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group'; import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
@@ -578,7 +578,8 @@ export async function createSubmissionService(
.map((question) => question.questionType?.typename ?? '') .map((question) => question.questionType?.typename ?? '')
.filter((typename) => typename.length > 0) .filter((typename) => typename.length > 0)
); );
const gradingState: SubmissionGradingState = 'queued'; const gradingState: SubmissionGradingState =
overallStatus === 'manual_required' || overallStatus === 'hybrid' ? 'awaiting_manual' : 'queued';
const submissionData: TNewSubmission = { const submissionData: TNewSubmission = {
courseId, courseId,
@@ -869,7 +870,6 @@ export async function updateSubmissionGradesBatch(
const currentGradingState = resolveSubmissionGradingState(submission); const currentGradingState = resolveSubmissionGradingState(submission);
const targetGradingState: SubmissionGradingState = 'completed'; const targetGradingState: SubmissionGradingState = 'completed';
const targetStatusId = projectLegacyStatusId(targetGradingState); const targetStatusId = projectLegacyStatusId(targetGradingState);
if (!isAllowedGradingTransition(currentGradingState, targetGradingState)) { if (!isAllowedGradingTransition(currentGradingState, targetGradingState)) {
throw new AppError('Invalid submission workflow transition', ErrorCodes.VALIDATION_ERROR, 400); throw new AppError('Invalid submission workflow transition', ErrorCodes.VALIDATION_ERROR, 400);
} }
@@ -965,13 +965,17 @@ async function sendSubmissionUpdateEmail(submissionId: string, newStatusId: numb
return; return;
} }
// Get organization name // Get organization name and URL info
const orgResult = await getOrganizationByCourseId(fullSubmission.courseId || ''); const orgResult = await getCourseWithOrgData(fullSubmission.courseId || '');
const orgName = orgResult?.orgName || 'ClassroomIO'; const orgName = orgResult?.orgName || 'ClassroomIO';
const statusText = LEGACY_BOARD_STATUS_LABELS[newStatusId] || 'Updated'; const statusText = LEGACY_BOARD_STATUS_LABELS[newStatusId] || 'Updated';
const baseUrl = getDashboardBaseUrl(); const baseUrl = getDashboardBaseUrl({
siteName: orgResult?.orgSiteName,
customDomain: orgResult?.orgCustomDomain,
isCustomDomainVerified: orgResult?.orgIsCustomDomainVerified
});
const exerciseLink = `${baseUrl}/courses/${fullSubmission.courseId}/exercises/${fullSubmission.exercise.id}`; const exerciseLink = `${baseUrl}/courses/${fullSubmission.courseId}/exercises/${fullSubmission.exercise.id}`;
// Calculate total and max points // Calculate total and max points
@@ -979,37 +983,33 @@ async function sendSubmissionUpdateEmail(submissionId: string, newStatusId: numb
const totalMark = answers.reduce((sum: number, a: any) => sum + (a.point || 0), 0); const totalMark = answers.reduce((sum: number, a: any) => sum + (a.point || 0), 0);
const maxMark = (fullSubmission.exercise.questions || []).reduce((sum: number, q: any) => sum + (q.points || 0), 0); const maxMark = (fullSubmission.exercise.questions || []).reduce((sum: number, q: any) => sum + (q.points || 0), 0);
let content = ` const isGraded = newStatusId === 3;
<p>Hello ${fullSubmission.groupmember.profile.fullname || 'Student'},</p> const score = isGraded ? `${totalMark}/${maxMark}` : undefined;
<p>The status of your submitted exercise on <strong>${fullSubmission.exercise.title}</strong> has been updated to ${statusText}</p>
`;
if (newStatusId === 3) { try {
content += ` await enqueueTransactionalEmail('submissionGraded', {
<p>Your score was ${totalMark}/${maxMark}</p>
<a class="button" href="${exerciseLink}">View your Result</a>
`;
} else {
content += `<a class="button" href="${exerciseLink}">Open Exercise</a>`;
}
if (fullSubmission.lesson?.title) {
content += `
<p>This exercise is for <strong>${fullSubmission.lesson.title}</strong> in a course you are taking titled <strong>${course.title}</strong></p>
`;
} else {
content += `
<p>This exercise is in a course you are taking titled <strong>${course.title}</strong></p>
`;
}
await enqueueRawEmail({
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
to: fullSubmission.groupmember.profile.email, to: fullSubmission.groupmember.profile.email,
subject: 'Submission Update', fields: {
content, orgName,
studentName: fullSubmission.groupmember.profile.fullname || 'Student',
exerciseTitle: fullSubmission.exercise.title,
courseName: course.title,
statusText,
exerciseLink,
score,
lessonTitle: fullSubmission.lesson?.title,
branding: buildEmailBranding({
name: orgResult?.orgName,
avatarUrl: orgResult?.orgAvatarUrl,
theme: orgResult?.orgTheme
})
},
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
idempotencyKey: `submission-update:${submissionId}:${newStatusId}` idempotencyKey: `submission-update:${submissionId}:${newStatusId}`
}); });
} catch (error) {
console.error('Failed to enqueue submission graded email:', error);
}
} }
/** /**
@@ -1042,32 +1042,43 @@ async function sendExerciseSubmissionUpdateEmail(courseId: string, exerciseId: s
return; return;
} }
// Get organization name // Get organization name and URL info
const orgResult = await getOrganizationByCourseId(courseId); const orgResult = await getCourseWithOrgData(courseId);
const orgName = orgResult?.orgName || 'ClassroomIO'; const orgName = orgResult?.orgName || 'ClassroomIO';
const baseUrl = getDashboardBaseUrl(); const baseUrl = getDashboardBaseUrl({
siteName: orgResult?.orgSiteName,
customDomain: orgResult?.orgCustomDomain,
isCustomDomainVerified: orgResult?.orgIsCustomDomainVerified
});
const exerciseLink = `${baseUrl}/courses/${courseId}/exercises/${exerciseId}`; const exerciseLink = `${baseUrl}/courses/${courseId}/exercises/${exerciseId}`;
const submissionLink = `${baseUrl}/courses/${courseId}/submissions`; const submissionLink = `${baseUrl}/courses/${courseId}/submissions`;
const content = ` const studentName = student.fullname || student.username || 'A student';
<p>Hello,</p>
<p>A student ${student.fullname || student.username} just submitted an exercise <a href="${exerciseLink}">${exercise.title}</a></p>
<p>You can get started grading by clicking "Open Submissions"</p>
<div>
<a class="button" href="${submissionLink}">Open Submissions</a>
</div>
`;
const tutorEmails = tutorsResult.map((tutor) => tutor.email).filter((email): email is string => Boolean(email)); const tutorEmails = tutorsResult.map((tutor) => tutor.email).filter((email): email is string => Boolean(email));
if (tutorEmails.length === 0) return; if (tutorEmails.length === 0) return;
await enqueueRawEmail({ try {
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`), await enqueueTransactionalEmail('submissionReceived', {
to: tutorEmails, to: tutorEmails,
subject: `[Submitted]: ${exercise.title}`, fields: {
content, orgName,
studentName,
exerciseTitle: exercise.title,
exerciseLink,
submissionLink,
branding: buildEmailBranding({
name: orgResult?.orgName,
avatarUrl: orgResult?.orgAvatarUrl,
theme: orgResult?.orgTheme
})
},
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
idempotencyKey: `exercise-submitted:${courseId}:${exerciseId}:${submittedBy}` idempotencyKey: `exercise-submitted:${courseId}:${exerciseId}:${submittedBy}`
}); });
} catch (error) {
console.error('Failed to enqueue submission received email:', error);
}
} }
+7 -2
View File
@@ -5,7 +5,7 @@ import {
} from '@cio/db/queries/course/course'; } from '@cio/db/queries/course/course';
import { setMemberCertificateEarned, setMemberCertificationEmailSent } from '@cio/db/queries/course/people'; import { setMemberCertificateEarned, setMemberCertificationEmailSent } from '@cio/db/queries/course/people';
import { getProfileById } from '@cio/db/queries/auth'; import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName } from '@cio/email'; import { buildEmailFromName, buildEmailBranding } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs'; import { enqueueTransactionalEmail } from '@api/services/jobs';
export function calcCourseProgressPercent(params: { export function calcCourseProgressPercent(params: {
@@ -81,7 +81,12 @@ export function scheduleCertificationCompletionWork(params: {
courseName: courseRow.title, courseName: courseRow.title,
studentName: profile?.fullname || studentEmail, studentName: profile?.fullname || studentEmail,
certificateUrl, certificateUrl,
customMessage: courseRow.certificate?.emailMessage ?? null customMessage: courseRow.certificate?.emailMessage ?? null,
branding: buildEmailBranding({
name: courseRow.orgName,
avatarUrl: courseRow.orgAvatarUrl,
theme: courseRow.orgTheme
})
}, },
from: buildEmailFromName(`${courseRow.orgName} (via ClassroomIO.com)`), from: buildEmailFromName(`${courseRow.orgName} (via ClassroomIO.com)`),
idempotencyKey: `course-completion:${groupMemberId}` idempotencyKey: `course-completion:${groupMemberId}`
+1 -1
View File
@@ -1,5 +1,5 @@
<a href="https://classroomio.com/"> <a href="https://classroomio.com/">
<img alt="A flexible, user-friendly platform for creating, managing, and delivering courses for companies and training organisations" src="https://brand.cdn.clsrio.com/og/classroomio-og.png" /> <img alt="A flexible, user-friendly platform for creating, managing, and delivering trainings for companies and training organisations" src="https://brand.cdn.clsrio.com/og/classroomio-opengraph.jpg" />
<h1 align="center">ClassroomIO</h1> <h1 align="center">ClassroomIO</h1>
<p align="center"> <p align="center">
The Open Source Teaching Platform for Bootcamps The Open Source Teaching Platform for Bootcamps
@@ -28,6 +28,8 @@ import {
import { mapZodErrorsToTranslations } from '$lib/utils/validation'; import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { snackbar } from '$features/ui/snackbar/store'; import { snackbar } from '$features/ui/snackbar/store';
import { toast } from '@cio/ui/base/sonner';
import { t } from '$lib/utils/functions/translations';
/** /**
* API class for exercise operations * API class for exercise operations
@@ -60,6 +62,74 @@ export class ExerciseApi extends BaseApiWithErrors {
}); });
} }
/**
* Notifies all course members that a quiz has been assigned. Runs as a
* background job; shows a sonner spinner that polls until the job is done.
*/
async notifyStudents(courseId: string, exerciseId: string) {
const toastId = toast.loading(t.get('course.navItem.lessons.exercises.all_exercises.notify.sending'), {
position: 'bottom-right'
});
try {
const res = await classroomio.course[':courseId'].exercise[':exerciseId'].notify.$post({
param: { courseId, exerciseId }
});
const body = await res.json();
if (!body.success) {
throw new Error('Failed to start notification');
}
const jobId = body.data.jobId;
const maxPolls = 60;
for (let pollCount = 0; pollCount < maxPolls; pollCount++) {
const statusRes = await classroomio.course[':courseId'].exercise[':exerciseId'].notify[':jobId'].$get({
param: { courseId, exerciseId, jobId },
query: { pollCount: String(pollCount) }
});
const statusBody = await statusRes.json();
if (!statusBody.success) {
throw new Error('Failed to read notification status');
}
const { job, nextPollMs } = statusBody.data;
if (job.status === 'completed') {
const notified = (job.result as { notified?: number } | null)?.notified ?? 0;
toast.success(t.get('course.navItem.lessons.exercises.all_exercises.notify.sent', { count: notified }), {
id: toastId,
position: 'bottom-right'
});
return;
}
if (job.status === 'failed' || job.status === 'canceled') {
toast.error(t.get('course.navItem.lessons.exercises.all_exercises.notify.failed'), {
id: toastId,
position: 'bottom-right'
});
return;
}
await new Promise((resolve) => setTimeout(resolve, nextPollMs || 2000));
}
toast.error(t.get('course.navItem.lessons.exercises.all_exercises.notify.failed'), {
id: toastId,
position: 'bottom-right'
});
} catch (error) {
console.error('notifyStudents error:', error);
toast.error(t.get('course.navItem.lessons.exercises.all_exercises.notify.failed'), {
id: toastId,
position: 'bottom-right'
});
}
}
/** /**
* Creates a new exercise * Creates a new exercise
*/ */
@@ -140,6 +140,7 @@
{/each} {/each}
</select> </select>
<Field.Description>{$t('course.certification.final_exercise_helper')}</Field.Description> <Field.Description>{$t('course.certification.final_exercise_helper')}</Field.Description>
<Field.Description>{$t('course.certification.final_exercise_multiple_attempts_note')}</Field.Description>
</Field.Field> </Field.Field>
{#if courseApi.course?.certificate?.requiredExerciseId} {#if courseApi.course?.certificate?.requiredExerciseId}
@@ -4,21 +4,34 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import * as Dialog from '@cio/ui/base/dialog'; import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import { Spinner } from '@cio/ui/base/spinner';
import { Confetti } from '$features/ui'; import { Confetti } from '$features/ui';
import { setConfetti } from '$features/ui/confetti/store'; import { setConfetti } from '$features/ui/confetti/store';
import { ContentType } from '@cio/utils/constants/content';
import { courseCompletionModal, closeCourseCompletionModal } from '$features/course/store/course-completion-modal'; import { courseCompletionModal, closeCourseCompletionModal } from '$features/course/store/course-completion-modal';
import { getContentRoute } from '$features/course/utils/content';
import { formatBlockerMessage } from '$features/course/utils/certificate-utils';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
let open = $state(false); let open = $state(false);
let activeCourseId = $state(''); let activeCourseId = $state('');
let activeStep = $state<'checking' | 'eligible' | 'not-eligible'>('checking');
let activeEvaluation = $state<Record<string, unknown> | null>(null);
let activeExerciseId = $state<string | undefined>(undefined);
onMount(() => { onMount(() => {
return courseCompletionModal.subscribe((v) => { return courseCompletionModal.subscribe((v) => {
if (v?.open && v.courseId) { if (v?.open && v.courseId) {
activeCourseId = v.courseId; activeCourseId = v.courseId;
activeStep = v.step;
activeEvaluation = v.evaluation as Record<string, unknown> | null;
activeExerciseId = v.exerciseId;
open = true; open = true;
if (v.step === 'eligible' && !v.evaluation?.certificateEarnedAt) {
setConfetti(true); setConfetti(true);
setTimeout(() => setConfetti(false), 5500); setTimeout(() => setConfetti(false), 5500);
}
} else { } else {
open = false; open = false;
} }
@@ -37,6 +50,24 @@
onOpenChange(false); onOpenChange(false);
goto(resolve(`/courses/${activeCourseId}/certificates`, {})); goto(resolve(`/courses/${activeCourseId}/certificates`, {}));
} }
function goToExercise() {
if (!activeCourseId || !activeExerciseId) return;
onOpenChange(false);
const path = getContentRoute(activeCourseId, { id: activeExerciseId, type: ContentType.Exercise });
if (path) {
goto(resolve(path, {}));
}
}
function formatSingleBlocker() {
if (!activeEvaluation) return '';
const blockers = (activeEvaluation as Record<string, unknown>).blockers as
| Array<Record<string, unknown>>
| undefined;
if (!blockers || blockers.length === 0) return '';
return formatBlockerMessage(blockers[0] as Parameters<typeof formatBlockerMessage>[0]);
}
</script> </script>
<div class="pointer-events-none fixed inset-0 z-100"> <div class="pointer-events-none fixed inset-0 z-100">
@@ -45,6 +76,12 @@
<Dialog.Root {open} {onOpenChange}> <Dialog.Root {open} {onOpenChange}>
<Dialog.Content class="ui:max-w-md"> <Dialog.Content class="ui:max-w-md">
{#if activeStep === 'checking'}
<div class="flex flex-col items-center justify-center gap-4 py-8">
<Spinner class="ui:size-8" />
<p class="ui:text-muted-foreground text-sm">{$t('course.completion.modal.checking')}</p>
</div>
{:else if activeStep === 'eligible'}
<Dialog.Header> <Dialog.Header>
<Dialog.Title>{$t('course.completion.modal.title')}</Dialog.Title> <Dialog.Title>{$t('course.completion.modal.title')}</Dialog.Title>
<Dialog.Description>{$t('course.completion.modal.subtitle')}</Dialog.Description> <Dialog.Description>{$t('course.completion.modal.subtitle')}</Dialog.Description>
@@ -53,5 +90,19 @@
<Button variant="outline" onclick={() => onOpenChange(false)}>{$t('course.completion.modal.later')}</Button> <Button variant="outline" onclick={() => onOpenChange(false)}>{$t('course.completion.modal.later')}</Button>
<Button onclick={goToCertificate}>{$t('course.completion.modal.view_certificate')}</Button> <Button onclick={goToCertificate}>{$t('course.completion.modal.view_certificate')}</Button>
</Dialog.Footer> </Dialog.Footer>
{:else if activeStep === 'not-eligible'}
<Dialog.Header>
<Dialog.Title>{$t('course.completion.modal.not_eligible_title')}</Dialog.Title>
</Dialog.Header>
<div class="ui:text-muted-foreground px-6 pb-4 text-center text-sm">
<p>{formatSingleBlocker()}</p>
</div>
<Dialog.Footer class="ui:gap-2">
<Button variant="outline" onclick={() => onOpenChange(false)}>{$t('course.completion.modal.later')}</Button>
{#if activeExerciseId}
<Button onclick={goToExercise}>{$t('course.completion.modal.retake_exercise')}</Button>
{/if}
</Dialog.Footer>
{/if}
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -11,7 +11,7 @@
import { snackbar } from '$features/ui/snackbar/store'; import { snackbar } from '$features/ui/snackbar/store';
import { classroomio } from '$lib/utils/services/api'; import { classroomio } from '$lib/utils/services/api';
import type { CertificationEvaluationData } from '$features/course/utils/types'; import type { CertificationEvaluationData } from '$features/course/utils/types';
import { normalizeCertificateIssuedAt } from '$features/course/utils/certificate-utils'; import { normalizeCertificateIssuedAt, formatBlockerMessage } from '$features/course/utils/certificate-utils';
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal'; import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
let isLoading = $state(false); let isLoading = $state(false);
@@ -21,41 +21,6 @@
let courseId = $derived(courseApi.course?.id ?? ''); let courseId = $derived(courseApi.course?.id ?? '');
function formatBlockerMessage(blocker: CertificationEvaluationData['blockers'][number]): string {
const p = blocker.params ?? {};
switch (blocker.code) {
case 'CERT_PROGRESS':
return t.get('course.certification.blocker_progress', {
current: Number(p.current ?? 0),
required: Number(p.required ?? 0)
});
case 'CERT_DEADLINE_PASSED':
return t.get('course.certification.blocker_deadline');
case 'CERT_NO_CONTENT':
return t.get('course.certification.blocker_no_content');
case 'CERT_FINAL_EXERCISE_NOT_SUBMITTED':
return t.get('course.certification.blocker_final_not_submitted', {
exerciseTitle: String(p.exerciseTitle ?? '')
});
case 'CERT_FINAL_EXERCISE_PENDING_GRADE':
return t.get('course.certification.blocker_final_pending', {
exerciseTitle: String(p.exerciseTitle ?? '')
});
case 'CERT_FINAL_EXERCISE_SCORE':
return t.get('course.certification.blocker_final_score', {
exerciseTitle: String(p.exerciseTitle ?? ''),
bestPercent: Number(p.bestPercent ?? 0),
requiredPercent: Number(p.requiredPercent ?? 0)
});
case 'CERT_FINAL_EXERCISE_MISCONFIGURED':
return t.get('course.certification.blocker_final_misconfigured', {
exerciseTitle: String(p.exerciseTitle ?? '')
});
default:
return blocker.code;
}
}
function buildBody() { function buildBody() {
return { return {
studentName: $profile.fullname || 'Recipient', studentName: $profile.fullname || 'Recipient',
@@ -3,6 +3,7 @@
import * as Sidebar from '@cio/ui/base/sidebar'; import * as Sidebar from '@cio/ui/base/sidebar';
import EyeIcon from '@lucide/svelte/icons/eye'; import EyeIcon from '@lucide/svelte/icons/eye';
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import { Waves } from '@cio/ui/custom/animation';
import { page } from '$app/state'; import { page } from '$app/state';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org'; import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { isStudentExperience } from '$lib/utils/store/app'; import { isStudentExperience } from '$lib/utils/store/app';
@@ -58,9 +59,22 @@
<span class="grow"></span> <span class="grow"></span>
<Button variant="outline" size="sm" onclick={toggleAiAssistant}> <Button
<SparklesIcon size={14} /> size="sm"
{$t('course.navItems.nav_ai_assistant')} onclick={toggleAiAssistant}
class="ui:bg-primary ui:text-primary-foreground relative overflow-hidden border-0"
>
<Waves
lineColor="rgba(255,255,255,0.55)"
xGap={8}
yGap={12}
waveAmpX={18}
waveAmpY={9}
waveSpeedX={0.04}
waveSpeedY={0.02}
/>
<SparklesIcon size={14} class="relative z-10" />
<span class="relative z-10">{$t('course.navItems.nav_ai_assistant')}</span>
</Button> </Button>
{#if !$isStudentExperience} {#if !$isStudentExperience}
@@ -53,7 +53,7 @@
shouldDeleteExercise?: boolean; shouldDeleteExercise?: boolean;
exerciseId: string; exerciseId: string;
goBack?: () => void; goBack?: () => void;
/** Self-paced + all auto-gradable question types: enforce points &gt; 0 */ /** All auto-gradable question types (any course): enforce points &gt; 0 for scoring */
requiresPositivePointsForAutoGrade?: boolean; requiresPositivePointsForAutoGrade?: boolean;
selfPacedCourse?: boolean; selfPacedCourse?: boolean;
/** Public course restricts picker to auto-gradable question types only. */ /** Public course restricts picker to auto-gradable question types only. */
@@ -40,7 +40,11 @@
import { ContentType } from '@cio/utils/constants/content'; import { ContentType } from '@cio/utils/constants/content';
import { getOrderedNavigableContent } from '$features/course/utils/content'; import { getOrderedNavigableContent } from '$features/course/utils/content';
import { isOrgStudent } from '$lib/utils/store/app'; import { isOrgStudent } from '$lib/utils/store/app';
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal'; import {
openCourseCompletionModal,
updateCourseCompletionModal,
closeCourseCompletionModal
} from '$features/course/store/course-completion-modal';
import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils'; import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils';
import { toExerciseQuestionModel } from './question-type-utils'; import { toExerciseQuestionModel } from './question-type-utils';
import { getExerciseQuestionLabels } from './question-labels'; import { getExerciseQuestionLabels } from './question-labels';
@@ -152,7 +156,19 @@
$isOrgStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete); $isOrgStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete);
if (allComplete && courseApi.course?.id) { if (allComplete && courseApi.course?.id) {
const requiredExerciseId = courseApi.course?.certificate?.requiredExerciseId ?? undefined;
openCourseCompletionModal(courseApi.course.id); openCourseCompletionModal(courseApi.course.id);
const certRes = await courseApi.getCertificationEvaluation(courseApi.course.id);
if (certRes?.data) {
if (certRes.data.isNewCompletion) {
updateCourseCompletionModal(courseApi.course.id, 'eligible', certRes.data, requiredExerciseId);
} else {
updateCourseCompletionModal(courseApi.course.id, 'not-eligible', certRes.data, requiredExerciseId);
}
} else {
closeCourseCompletionModal();
}
} }
} }
@@ -286,7 +286,7 @@
? $t('course.navItem.lessons.add_lesson.lock') ? $t('course.navItem.lessons.add_lesson.lock')
: $t('course.navItem.lessons.add_lesson.unlock')} : $t('course.navItem.lessons.add_lesson.unlock')}
{@const canRenderJoinButton = {@const canRenderJoinButton =
item.type === ContentType.Lesson && isLiveCourse && Boolean(item.callUrl) && !itemLocked} item.type === ContentType.Lesson && isLiveCourse && Boolean(item.callUrl) && !itemLocked && !isEditingItem}
{@const canRenderContinueButton = isStudentView && !itemLocked} {@const canRenderContinueButton = isStudentView && !itemLocked}
{@const isLockPending = Boolean(lockPendingItemKeys[getItemLockKey(item)])} {@const isLockPending = Boolean(lockPendingItemKeys[getItemLockKey(item)])}
{@const actionDisabled = Boolean($contentEditingStore && !isEditingItem)} {@const actionDisabled = Boolean($contentEditingStore && !isEditingItem)}
@@ -14,7 +14,11 @@
import { ContentType } from '@cio/utils/constants/content'; import { ContentType } from '@cio/utils/constants/content';
import { snackbar } from '$features/ui/snackbar/store'; import { snackbar } from '$features/ui/snackbar/store';
import type { CourseContentItem } from '$features/course/utils/types'; import type { CourseContentItem } from '$features/course/utils/types';
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal'; import {
openCourseCompletionModal,
updateCourseCompletionModal,
closeCourseCompletionModal
} from '$features/course/store/course-completion-modal';
import { updateLessonCompletionInCourseContent } from '$features/course/utils/content-completion'; import { updateLessonCompletionInCourseContent } from '$features/course/utils/content-completion';
interface Props { interface Props {
@@ -135,7 +139,19 @@
navigableContentItems.every((item) => item.isComplete); navigableContentItems.every((item) => item.isComplete);
if (allComplete) { if (allComplete) {
const requiredExerciseId = courseApi.course?.certificate?.requiredExerciseId ?? undefined;
openCourseCompletionModal(courseId); openCourseCompletionModal(courseId);
const certRes = await courseApi.getCertificationEvaluation(courseId);
if (certRes?.data) {
if (certRes.data.isNewCompletion) {
updateCourseCompletionModal(courseId, 'eligible', certRes.data, requiredExerciseId);
} else {
updateCourseCompletionModal(courseId, 'not-eligible', certRes.data, requiredExerciseId);
}
} else {
closeCourseCompletionModal();
}
} }
} else { } else {
snackbar.error('snackbar.lessons.error.try_later'); snackbar.error('snackbar.lessons.error.try_later');
@@ -2,10 +2,13 @@
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import LockIcon from '@lucide/svelte/icons/lock'; import LockIcon from '@lucide/svelte/icons/lock';
import { InputField } from '@cio/ui/custom/input-field'; import { InputField } from '@cio/ui/custom/input-field';
import { RadioIcon } from '@cio/ui/custom/moving-icons';
import { CircleCheckIcon } from '$features/ui/icons'; import { CircleCheckIcon } from '$features/ui/icons';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import CourseContentIcon from '$features/course/components/course-content-icon.svelte'; import CourseContentIcon from '$features/course/components/course-content-icon.svelte';
import type { CourseContentItem } from '$features/course/utils/types'; import type { CourseContentItem } from '$features/course/utils/types';
import { ContentType } from '@cio/utils/constants/content';
import { t } from '$lib/utils/functions/translations';
import { cn } from '@cio/ui/tools'; import { cn } from '@cio/ui/tools';
interface Props { interface Props {
@@ -62,6 +65,16 @@
<CourseContentIcon type={item.type} size={iconSize} /> <CourseContentIcon type={item.type} size={iconSize} />
{/if} {/if}
{#if item.type === ContentType.Lesson && item.callUrl}
<span
class="shrink-0"
title={$t('course.navItem.lessons.session.live_indicator')}
aria-label={$t('course.navItem.lessons.session.live_indicator')}
>
<RadioIcon size={iconSize} color="#ef4444" />
</span>
{/if}
<a <a
href={resolve(href, {})} href={resolve(href, {})}
class={cn( class={cn(
@@ -517,7 +517,7 @@
? $t('course.navItem.lessons.add_lesson.lock') ? $t('course.navItem.lessons.add_lesson.lock')
: $t('course.navItem.lessons.add_lesson.unlock')} : $t('course.navItem.lessons.add_lesson.unlock')}
{@const canRenderJoinButton = {@const canRenderJoinButton =
item.type === ContentType.Lesson && isLiveCourse && Boolean(item.callUrl) && !itemLocked} item.type === ContentType.Lesson && isLiveCourse && Boolean(item.callUrl) && !itemLocked && !isEditingItem}
{@const canRenderContinueButton = isStudentView && !itemLocked} {@const canRenderContinueButton = isStudentView && !itemLocked}
{@const isLockPending = Boolean(lockPendingItemKeys[getItemLockKey(item)])} {@const isLockPending = Boolean(lockPendingItemKeys[getItemLockKey(item)])}
{@const actionDisabled = Boolean($contentEditingStore && !isEditingItem)} {@const actionDisabled = Boolean($contentEditingStore && !isEditingItem)}
@@ -1,14 +1,123 @@
<script lang="ts"> <script lang="ts">
import { lessonApi } from '$features/course/api'; import { lessonApi, courseApi } from '$features/course/api';
import { InputField } from '@cio/ui/custom/input-field'; import { InputField } from '@cio/ui/custom/input-field';
import { Checkbox } from '@cio/ui/base/checkbox'; import { Checkbox } from '@cio/ui/base/checkbox';
import { Button } from '@cio/ui/base/button';
import * as Dialog from '@cio/ui/base/dialog';
import * as Field from '@cio/ui/base/field'; import * as Field from '@cio/ui/base/field';
import * as Select from '@cio/ui/base/select'; import * as Select from '@cio/ui/base/select';
import * as Tooltip from '@cio/ui/base/tooltip';
import InfoIcon from '@lucide/svelte/icons/info';
import { toast } from '@cio/ui/base/sonner';
import { classroomio } from '$lib/utils/services/api';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { getBrowserTimezone, instantToZonedWallClock, zonedWallClockToInstant } from '$lib/utils/functions/date';
import { getVideoTitle, isHlsUploadVideo, type LessonVideo } from './video/video-card-utils'; import { getVideoTitle, isHlsUploadVideo, type LessonVideo } from './video/video-card-utils';
type LessonCompletionPolicy = 'manual' | 'video_watch' | 'none'; type LessonCompletionPolicy = 'manual' | 'video_watch' | 'none';
// ── Live session (Zoom link + scheduled time + timezone) ──────────────────
const isLiveClass = $derived(courseApi.course?.type === 'LIVE_CLASS');
const timezoneOptions = (() => {
try {
return (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf?.('timeZone') ?? [];
} catch {
return [];
}
})();
let sessionCallUrl = $state('');
let sessionWallClock = $state('');
let sessionTimezone = $state(getBrowserTimezone());
let sessionInitializedFor = $state<string | null>(null);
let isSavingSession = $state(false);
let confirmOpen = $state(false);
$effect(() => {
const lesson = lessonApi.lesson;
if (!lesson || sessionInitializedFor === lesson.id) return;
const tz = courseApi.course?.metadata?.sessionTimezone || getBrowserTimezone();
sessionTimezone = tz;
sessionCallUrl = lesson.callUrl ?? '';
sessionWallClock = instantToZonedWallClock(lesson.lessonAt, tz);
sessionInitializedFor = lesson.id;
});
const hadPriorSession = $derived(Boolean(lessonApi.lesson?.lessonAt && lessonApi.lesson?.callUrl));
function computeNewInstant(): string {
return sessionWallClock ? zonedWallClockToInstant(sessionWallClock, sessionTimezone) : '';
}
function sessionChanged(): boolean {
const currentTz = courseApi.course?.metadata?.sessionTimezone || getBrowserTimezone();
return (
sessionCallUrl !== (lessonApi.lesson?.callUrl ?? '') ||
computeNewInstant() !== (lessonApi.lesson?.lessonAt ?? '') ||
sessionTimezone !== currentTz
);
}
async function persistSession() {
const courseId = courseApi.course?.id;
const lessonId = lessonApi.lesson?.id;
if (!courseId || !lessonId) return;
isSavingSession = true;
try {
await lessonApi.update(courseId, lessonId, {
callUrl: sessionCallUrl || undefined,
lessonAt: computeNewInstant() || undefined
});
const currentTz = courseApi.course?.metadata?.sessionTimezone || '';
if (sessionTimezone && sessionTimezone !== currentTz) {
await courseApi.update(courseId, {
metadata: { ...(courseApi.course?.metadata ?? {}), sessionTimezone }
});
}
sessionInitializedFor = null; // re-sync baselines from refreshed lesson/course
} finally {
isSavingSession = false;
}
}
async function notifySessionUpdate() {
const courseId = courseApi.course?.id;
const lessonId = lessonApi.lesson?.id;
if (!courseId || !lessonId) return;
try {
await classroomio.course[':courseId'].lesson[':lessonId']['notify-session-update'].$post({
param: { courseId, lessonId }
});
toast.success(t.get('course.navItem.lessons.session.notified'));
} catch {
toast.error(t.get('course.navItem.lessons.session.notify_failed'));
}
}
async function handleSaveSession() {
if (!sessionChanged()) return;
if (hadPriorSession) {
confirmOpen = true;
return;
}
await persistSession();
toast.success(t.get('course.navItem.lessons.session.saved'));
}
async function confirmSaveAndNotify() {
confirmOpen = false;
await persistSession();
await notifySessionUpdate();
}
const completionPolicy = $derived(lessonApi.lesson?.completionPolicy ?? 'manual'); const completionPolicy = $derived(lessonApi.lesson?.completionPolicy ?? 'manual');
const videoWatchThreshold = $derived(lessonApi.lesson?.videoWatchThreshold ?? 95); const videoWatchThreshold = $derived(lessonApi.lesson?.videoWatchThreshold ?? 95);
const videos = $derived(lessonApi.lesson?.videos ?? []); const videos = $derived(lessonApi.lesson?.videos ?? []);
@@ -63,6 +172,59 @@
<div class="p-4"> <div class="p-4">
<Field.Group class="w-full max-w-xl"> <Field.Group class="w-full max-w-xl">
{#if isLiveClass}
<Field.Set>
<Field.Legend>{$t('course.navItem.lessons.session.heading')}</Field.Legend>
<Field.Description>{$t('course.navItem.lessons.session.intro')}</Field.Description>
<Field.Field>
<InputField
type="url"
label={$t('course.navItem.lessons.session.link_label')}
placeholder="https://zoom.us/j/..."
value={sessionCallUrl}
onInputChange={(e) => (sessionCallUrl = (e.currentTarget as HTMLInputElement).value)}
/>
</Field.Field>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start">
<Field.Field class="sm:flex-1">
<Field.Label for="session-datetime">{$t('course.navItem.lessons.session.datetime_label')}</Field.Label>
<input
id="session-datetime"
type="datetime-local"
class="ui:border-input ui:bg-background ui:text-foreground ui:shadow-xs ui:flex ui:h-9 ui:w-full ui:rounded-md ui:border ui:px-3 ui:py-1 ui:text-sm ui:outline-none"
value={sessionWallClock}
oninput={(e) => (sessionWallClock = (e.currentTarget as HTMLInputElement).value)}
/>
</Field.Field>
<Field.Field class="sm:flex-1">
<Field.Label for="session-timezone">{$t('course.navItem.lessons.session.timezone_label')}</Field.Label>
<select
id="session-timezone"
class="ui:border-input ui:bg-background ui:text-foreground ui:shadow-xs ui:flex ui:h-9 ui:w-full ui:rounded-md ui:border ui:px-3 ui:py-1 ui:text-sm ui:outline-none"
value={sessionTimezone}
onchange={(e) => (sessionTimezone = (e.currentTarget as HTMLSelectElement).value)}
>
{#each timezoneOptions as tz (tz)}
<option value={tz}>{tz}</option>
{/each}
</select>
<Field.Description>{$t('course.navItem.lessons.session.timezone_helper')}</Field.Description>
</Field.Field>
</div>
<div>
<Button size="sm" onclick={handleSaveSession} loading={isSavingSession}>
{$t('course.navItem.lessons.session.save')}
</Button>
</div>
</Field.Set>
<Field.Separator />
{/if}
<Field.Set> <Field.Set>
<Field.Legend>{$t('course.navItem.lessons.settings.progression.heading')}</Field.Legend> <Field.Legend>{$t('course.navItem.lessons.settings.progression.heading')}</Field.Legend>
<Field.Description>{$t('course.navItem.lessons.settings.progression.intro')}</Field.Description> <Field.Description>{$t('course.navItem.lessons.settings.progression.intro')}</Field.Description>
@@ -72,9 +234,36 @@
<Field.Group> <Field.Group>
<Field.Field> <Field.Field>
<div class="flex items-center gap-1.5">
<Field.Label for="lesson-completion-policy"> <Field.Label for="lesson-completion-policy">
{$t('course.navItem.lessons.completion_policy.label')} {$t('course.navItem.lessons.completion_policy.label')}
</Field.Label> </Field.Label>
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger aria-label={$t('course.navItem.lessons.settings.progression.option_guide_heading')}>
<InfoIcon size={14} class="ui:text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="right" class="max-w-xs">
<ul class="list-disc space-y-1 pl-4 text-xs">
<li>
<span class="font-medium">{$t('course.navItem.lessons.completion_policy.manual_label')}:</span>
{$t('course.navItem.lessons.completion_policy.manual_description')}
</li>
<li>
<span class="font-medium"
>{$t('course.navItem.lessons.completion_policy.video_watch_label')}:</span
>
{$t('course.navItem.lessons.completion_policy.video_watch_description')}
</li>
<li>
<span class="font-medium">{$t('course.navItem.lessons.completion_policy.none_label')}:</span>
{$t('course.navItem.lessons.completion_policy.none_description')}
</li>
</ul>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</div>
<Select.Root type="single" value={completionPolicy} onValueChange={handleCompletionPolicyChange}> <Select.Root type="single" value={completionPolicy} onValueChange={handleCompletionPolicyChange}>
<Select.Trigger id="lesson-completion-policy" class="w-full max-w-xs"> <Select.Trigger id="lesson-completion-policy" class="w-full max-w-xs">
{policyTriggerLabel(completionPolicy)} {policyTriggerLabel(completionPolicy)}
@@ -108,30 +297,6 @@
</Select.Root> </Select.Root>
</Field.Field> </Field.Field>
<div class="space-y-2 text-sm">
<p class="font-medium">{$t('course.navItem.lessons.settings.progression.option_guide_heading')}</p>
<ul class="ui:text-muted-foreground list-disc space-y-1 pl-5">
<li>
<span class="ui:text-foreground font-medium">
{$t('course.navItem.lessons.completion_policy.manual_label')}:
</span>
{$t('course.navItem.lessons.completion_policy.manual_description')}
</li>
<li>
<span class="ui:text-foreground font-medium">
{$t('course.navItem.lessons.completion_policy.video_watch_label')}:
</span>
{$t('course.navItem.lessons.completion_policy.video_watch_description')}
</li>
<li>
<span class="ui:text-foreground font-medium">
{$t('course.navItem.lessons.completion_policy.none_label')}:
</span>
{$t('course.navItem.lessons.completion_policy.none_description')}
</li>
</ul>
</div>
{#if completionPolicy === 'video_watch'} {#if completionPolicy === 'video_watch'}
<Field.Field> <Field.Field>
<InputField <InputField
@@ -181,3 +346,20 @@
</Field.Set> </Field.Set>
</Field.Group> </Field.Group>
</div> </div>
<Dialog.Root bind:open={confirmOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.session.confirm_title')}</Dialog.Title>
<Dialog.Description>{$t('course.navItem.lessons.session.confirm_body')}</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button variant="outline" onclick={() => (confirmOpen = false)} disabled={isSavingSession}>
{$t('course.navItem.lessons.session.confirm_cancel')}
</Button>
<Button onclick={confirmSaveAndNotify} loading={isSavingSession}>
{$t('course.navItem.lessons.session.confirm_save')}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,35 @@
<script lang="ts">
import { LiveSessionCard, type LiveSessionLabels } from '@cio/ui/custom/live-session-card';
import { t } from '$lib/utils/functions/translations';
interface Props {
title: string;
callUrl: string;
lessonAt: string;
timezone?: string | null;
durationMinutes?: number;
}
let { title, callUrl, lessonAt, timezone = null, durationMinutes = 60 }: Props = $props();
const labels: LiveSessionLabels = {
liveNow: t.get('course.navItem.lessons.session.live_now'),
liveSession: t.get('course.navItem.lessons.session.live_event'),
providerFallback: t.get('course.navItem.lessons.session.live_session'),
started: t.get('course.navItem.lessons.session.started'),
minAgo: t.get('course.navItem.lessons.session.min_ago'),
joinNow: t.get('course.navItem.lessons.session.join_now'),
copyLink: t.get('course.navItem.lessons.session.copy_link'),
copy: t.get('course.navItem.lessons.session.copy'),
addToCalendar: t.get('course.navItem.lessons.session.add_to_calendar'),
addGoogle: t.get('course.navItem.lessons.session.add_google'),
addOutlook: t.get('course.navItem.lessons.session.add_outlook'),
addOffice365: t.get('course.navItem.lessons.session.add_office365'),
addYahoo: t.get('course.navItem.lessons.session.add_yahoo'),
addApple: t.get('course.navItem.lessons.session.add_apple'),
startsIn: t.get('course.navItem.lessons.session.starts_in'),
sessionEnded: t.get('course.navItem.lessons.session.session_ended')
};
</script>
<LiveSessionCard {title} {callUrl} {lessonAt} {timezone} {durationMinutes} {labels} />
@@ -12,6 +12,7 @@
import { CircleCheckIcon } from '$features/ui/icons'; import { CircleCheckIcon } from '$features/ui/icons';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { IconButton } from '@cio/ui/custom/icon-button'; import { IconButton } from '@cio/ui/custom/icon-button';
import { RadioIcon } from '@cio/ui/custom/moving-icons';
import CourseContentIcon from '$features/course/components/course-content-icon.svelte'; import CourseContentIcon from '$features/course/components/course-content-icon.svelte';
interface Props { interface Props {
@@ -29,6 +30,16 @@
const canOpenContentModal = $derived(Boolean(onOpenContentModal) && !isStudent); const canOpenContentModal = $derived(Boolean(onOpenContentModal) && !isStudent);
</script> </script>
{#snippet liveSessionDot()}
<span
class="shrink-0"
title={$t('course.navItem.lessons.session.live_indicator')}
aria-label={$t('course.navItem.lessons.session.live_indicator')}
>
<RadioIcon size={14} color="#ef4444" />
</span>
{/snippet}
<Sidebar.MenuSub class={className}> <Sidebar.MenuSub class={className}>
{#if contentData.grouped} {#if contentData.grouped}
{#each contentData.sections as section (section.id)} {#each contentData.sections as section (section.id)}
@@ -74,7 +85,7 @@
<Sidebar.MenuSubButton isActive={(path || page.url.pathname).includes(contentItem.id)}> <Sidebar.MenuSubButton isActive={(path || page.url.pathname).includes(contentItem.id)}>
{#snippet child({ props })} {#snippet child({ props })}
{@const isContentLocked = (contentItem.isUnlocked ?? true) === false} {@const isContentLocked = (contentItem.isUnlocked ?? true) === false}
{@const isLockedForStudent = isStudent && isContentLocked} {@const isLockedForStudent = isStudent && (isContentLocked || contentItem.accessible === false)}
<a <a
href={resolve(getContentRoute(id, contentItem), {})} href={resolve(getContentRoute(id, contentItem), {})}
aria-disabled={isLockedForStudent} aria-disabled={isLockedForStudent}
@@ -97,7 +108,9 @@
<CircleCheckIcon size={16} filled /> <CircleCheckIcon size={16} filled />
</span> </span>
{/if} {/if}
{#if isContentLocked} {#if contentItem.type === ContentType.Lesson && contentItem.callUrl}
{@render liveSessionDot()}
{:else if isContentLocked || isLockedForStudent}
<span <span
class="shrink-0" class="shrink-0"
title={$t('course.navItem.lessons.add_lesson.lock')} title={$t('course.navItem.lessons.add_lesson.lock')}
@@ -124,7 +137,7 @@
<Sidebar.MenuSubButton isActive={(path || page.url.pathname).includes(contentItem.id)}> <Sidebar.MenuSubButton isActive={(path || page.url.pathname).includes(contentItem.id)}>
{#snippet child({ props })} {#snippet child({ props })}
{@const isContentLocked = (contentItem.isUnlocked ?? true) === false} {@const isContentLocked = (contentItem.isUnlocked ?? true) === false}
{@const isLockedForStudent = isStudent && isContentLocked} {@const isLockedForStudent = isStudent && (isContentLocked || contentItem.accessible === false)}
<a <a
href={resolve(getContentRoute(id, contentItem), {})} href={resolve(getContentRoute(id, contentItem), {})}
aria-disabled={isLockedForStudent} aria-disabled={isLockedForStudent}
@@ -145,7 +158,9 @@
<CircleCheckIcon size={16} filled /> <CircleCheckIcon size={16} filled />
</span> </span>
{/if} {/if}
{#if isContentLocked} {#if contentItem.type === ContentType.Lesson && contentItem.callUrl}
{@render liveSessionDot()}
{:else if isContentLocked || isLockedForStudent}
<span <span
class="shrink-0" class="shrink-0"
title={$t('course.navItem.lessons.add_lesson.lock')} title={$t('course.navItem.lessons.add_lesson.lock')}
@@ -350,13 +350,14 @@
return typeIds.length > 0 && typeIds.every((id) => isAutoGradableQuestionType(getQuestionTypeKeyFromId(id))); return typeIds.length > 0 && typeIds.every((id) => isAutoGradableQuestionType(getQuestionTypeKeyFromId(id)));
} }
/** When true, every question must have points &gt; 0 before save */ const isFullyAutoGradable = $derived(isExerciseFullyAutoGradable(activeQuestionTypeIds));
const requiresPositivePointsForAutoGrade = $derived(
isSelfPacedCourse && isExerciseFullyAutoGradable(activeQuestionTypeIds) /** When true, every question must have points &gt; 0 before save (for meaningful auto-grading) */
); const requiresPositivePointsForAutoGrade = $derived(isFullyAutoGradable);
const teacherAutoGradeBadge = $derived.by(() => { const teacherAutoGradeBadge = $derived.by(() => {
if ($isOrgStudent || !isSelfPacedCourse || activeQuestionTypeIds.length === 0) return null; if ($isOrgStudent || isPublicCourse || activeQuestionTypeIds.length === 0) return null;
return isExerciseFullyAutoGradable(activeQuestionTypeIds) ? ('auto' as const) : ('manual' as const); return isFullyAutoGradable ? ('auto' as const) : ('manual' as const);
}); });
async function scrollToExerciseElement(elementId: string) { async function scrollToExerciseElement(elementId: string) {
@@ -498,6 +499,16 @@
{$t('course.navItem.lessons.exercises.all_exercises.reorder')} {$t('course.navItem.lessons.exercises.all_exercises.reorder')}
</DropdownMenu.Item> </DropdownMenu.Item>
{/if} {/if}
<DropdownMenu.Item
disabled={!courseApi.course?.id || !exerciseId}
onclick={() => {
if (courseApi.course?.id && exerciseId) {
exerciseApi.notifyStudents(courseApi.course.id, exerciseId);
}
}}
>
{$t('course.navItem.lessons.exercises.all_exercises.notify.action')}
</DropdownMenu.Item>
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Item <DropdownMenu.Item
class="text-red-600 focus:text-red-600 dark:text-red-400" class="text-red-600 focus:text-red-600 dark:text-red-400"
@@ -55,6 +55,7 @@
import { loadDraft, clearDraft } from '$features/course/utils/lesson-draft'; import { loadDraft, clearDraft } from '$features/course/utils/lesson-draft';
import { getOrderedNavigableContent } from '$features/course/utils/content'; import { getOrderedNavigableContent } from '$features/course/utils/content';
import StudentContentLockedNotice from '$features/course/components/student-content-locked-notice.svelte'; import StudentContentLockedNotice from '$features/course/components/student-content-locked-notice.svelte';
import LiveSessionCard from '$features/course/components/lesson/live-session-card.svelte';
interface Props { interface Props {
courseId: string; courseId: string;
@@ -94,6 +95,7 @@
const isLessonUnlocked = $derived(!isTeacherLocked && !isProgressionLocked); const isLessonUnlocked = $derived(!isTeacherLocked && !isProgressionLocked);
const lessonSlug = $derived(lessonApi.lesson?.slug ?? ''); const lessonSlug = $derived(lessonApi.lesson?.slug ?? '');
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC'); const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
const isLiveSessionLesson = $derived(Boolean(lessonApi.lesson?.callUrl && lessonApi.lesson?.lessonAt));
function setModeQueryParam(value: (typeof MODES)[keyof typeof MODES]) { function setModeQueryParam(value: (typeof MODES)[keyof typeof MODES]) {
const params = new SvelteURLSearchParams($page.url.searchParams); const params = new SvelteURLSearchParams($page.url.searchParams);
@@ -445,9 +447,18 @@
<Page.Body> <Page.Body>
{#snippet child()} {#snippet child()}
<div <div class={`overflow-x-hidden pb-6 ${mode === MODES.edit ? 'lg:w-full xl:w-11/12' : 'mx-auto w-full max-w-3xl'}`}>
class={`overflow-x-hidden py-6 pb-20 ${mode === MODES.edit ? 'lg:w-full xl:w-11/12' : 'mx-auto w-full max-w-3xl'}`} {#if isLiveSessionLesson && mode === MODES.view && !($isOrgStudent && !isLessonUnlocked)}
> <div class="mb-4">
<LiveSessionCard
title={lessonTitle}
callUrl={lessonApi.lesson?.callUrl ?? ''}
lessonAt={lessonApi.lesson?.lessonAt ?? ''}
timezone={courseApi.course?.metadata?.sessionTimezone}
/>
</div>
{/if}
{#if $isOrgStudent && !isLessonUnlocked} {#if $isOrgStudent && !isLessonUnlocked}
<StudentContentLockedNotice <StudentContentLockedNotice
reason={isProgressionLocked ? 'progression_locked' : 'teacher_locked'} reason={isProgressionLocked ? 'progression_locked' : 'teacher_locked'}
@@ -5,6 +5,7 @@
import { Label } from '@cio/ui/base/label'; import { Label } from '@cio/ui/base/label';
import { Switch } from '@cio/ui/base/switch'; import { Switch } from '@cio/ui/base/switch';
import * as RadioGroup from '@cio/ui/base/radio-group'; import * as RadioGroup from '@cio/ui/base/radio-group';
import * as Select from '@cio/ui/base/select';
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw'; import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right'; import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
import XIcon from '@lucide/svelte/icons/x'; import XIcon from '@lucide/svelte/icons/x';
@@ -15,7 +16,7 @@
import { TextareaField } from '@cio/ui/custom/textarea-field'; import { TextareaField } from '@cio/ui/custom/textarea-field';
import { InputField } from '@cio/ui/custom/input-field'; import { InputField } from '@cio/ui/custom/input-field';
import * as Field from '@cio/ui/base/field'; import * as Field from '@cio/ui/base/field';
import { UpgradeBanner, UnsavedChanges, UploadWidget } from '$features/ui'; import { UpgradeBanner, UnsavedChanges, UploadWidget, TextEditor } from '$features/ui';
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import { settings } from '$features/course/utils/settings-store'; import { settings } from '$features/course/utils/settings-store';
@@ -159,7 +160,8 @@
lessonDownload: $settings.lessonDownload, lessonDownload: $settings.lessonDownload,
allowNewStudent: $settings.allowNewStudents ?? false, allowNewStudent: $settings.allowNewStudents ?? false,
isContentGroupingEnabled: $settings.isContentGroupingEnabled, isContentGroupingEnabled: $settings.isContentGroupingEnabled,
progressionMode: $settings.progressionMode progressionMode: $settings.progressionMode,
welcomeEmailMessage: $settings.welcomeEmailMessage?.trim() ? $settings.welcomeEmailMessage : null
} as NonNullable<Course['metadata']>; } as NonNullable<Course['metadata']>;
const updatedCourse = { const updatedCourse = {
@@ -224,7 +226,8 @@
allowNewStudents: !!course.metadata?.allowNewStudent, allowNewStudents: !!course.metadata?.allowNewStudent,
isContentGroupingEnabled: course.metadata?.isContentGroupingEnabled ?? true, isContentGroupingEnabled: course.metadata?.isContentGroupingEnabled ?? true,
progressionMode: course.metadata?.progressionMode ?? 'free', progressionMode: course.metadata?.progressionMode ?? 'free',
callout: normalizeCallout(course.callout) callout: normalizeCallout(course.callout),
welcomeEmailMessage: course.metadata?.welcomeEmailMessage ?? ''
}); });
}); });
} }
@@ -350,7 +353,7 @@
<Field.Group> <Field.Group>
<Field.Field> <Field.Field>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Button onclick={widgetControl}> <Button variant="secondary" onclick={widgetControl}>
{$t('course.navItem.settings.replace')} {$t('course.navItem.settings.replace')}
</Button> </Button>
<Button variant="outline" onclick={deleteBannerImage}> <Button variant="outline" onclick={deleteBannerImage}>
@@ -443,6 +446,83 @@
<Field.Separator /> <Field.Separator />
<Field.Set>
<Field.Legend>{$t('course.navItem.settings.type')}</Field.Legend>
<Field.Description>
{$t('course.navItem.settings.course_type_desc')}
<a
href="https://classroomio.com/docs/course-types"
target="_blank"
rel="noopener noreferrer"
class="ui:text-primary underline"
>
{$t('course.navItem.settings.course_type_learn_more')}
</a>
</Field.Description>
<Field.Field>
<Select.Root
type="single"
value={$settings.type}
onValueChange={(value) => {
if (!value) return;
$settings.type = value as TCourseType;
hasUnsavedChanges = true;
}}
>
<Select.Trigger class="w-full">
{$t(`course.navItem.settings.${$settings.type.toLowerCase()}`)}
</Select.Trigger>
<Select.Content>
<Select.Group>
<Select.Item value="SELF_PACED" label={$t('course.navItem.settings.self_paced')}>
{$t('course.navItem.settings.self_paced')}
</Select.Item>
<Select.Item value="LIVE_CLASS" label={$t('course.navItem.settings.live_class')}>
{$t('course.navItem.settings.live_class')}
</Select.Item>
<Select.Item value="COMPLIANCE" label={$t('course.navItem.settings.compliance')}>
{$t('course.navItem.settings.compliance')}
</Select.Item>
<Select.Item value="PUBLIC" label={$t('course.navItem.settings.public')}>
{$t('course.navItem.settings.public')}
</Select.Item>
</Select.Group>
</Select.Content>
</Select.Root>
</Field.Field>
{#if courseApi.errors.type}
<div
class="ui:mt-2 ui:rounded-md ui:border ui:border-destructive/30 ui:bg-destructive/5 ui:p-3 ui:text-sm ui:text-destructive"
role="alert"
>
<div class="ui:font-medium">{$t('course.navItem.settings.convert_to_public_blocked')}</div>
<p class="ui:mt-1 ui:text-destructive/90">{courseApi.errors.type}</p>
</div>
{/if}
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('course.navItem.settings.welcome_email.title')}</Field.Legend>
<Field.Description>{$t('course.navItem.settings.welcome_email.description')}</Field.Description>
<div class="w-full max-w-md">
<TextEditor
content={$settings.welcomeEmailMessage}
placeholder={$t('course.navItem.settings.welcome_email.placeholder')}
class="w-full"
editorClass="h-auto! max-h-[200px] min-h-[120px]"
onChange={(text) => {
$settings.welcomeEmailMessage = text;
hasUnsavedChanges = true;
}}
/>
</div>
</Field.Set>
<Field.Separator />
<Field.Set> <Field.Set>
<Field.Legend>{$t('course.navItem.settings.tags.title')}</Field.Legend> <Field.Legend>{$t('course.navItem.settings.tags.title')}</Field.Legend>
<Field.Description>{$t('course.navItem.settings.tags.description')}</Field.Description> <Field.Description>{$t('course.navItem.settings.tags.description')}</Field.Description>
@@ -592,50 +672,6 @@
</Field.Field> </Field.Field>
</Field.Set> </Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('course.navItem.settings.type')}</Field.Legend>
<Field.Description>{$t('course.navItem.settings.course_type_desc')}</Field.Description>
<Field.Field>
<RadioGroup.Root
value={$settings.type}
onValueChange={(value) => {
$settings.type = value as TCourseType;
if (hasUnsavedChanges) return;
hasUnsavedChanges = true;
}}
>
<div class="mb-3 flex items-center space-x-2">
<RadioGroup.Item value={'SELF_PACED' as TCourseType} id="self-paced" />
<Label for="self-paced">{$t('course.navItem.settings.self_paced')}</Label>
</div>
<div class="mb-3 flex items-center space-x-2">
<RadioGroup.Item value={'LIVE_CLASS' as TCourseType} id="live-class" />
<Label for="live-class">{$t('course.navItem.settings.live_class')}</Label>
</div>
<div class="mb-3 flex items-center space-x-2">
<RadioGroup.Item value={'COMPLIANCE' as TCourseType} id="compliance-course" />
<Label for="compliance-course">{$t('course.navItem.settings.compliance')}</Label>
</div>
<div class="flex items-center space-x-2">
<RadioGroup.Item value={'PUBLIC' as TCourseType} id="public-course" />
<Label for="public-course">{$t('course.navItem.settings.public')}</Label>
</div>
</RadioGroup.Root>
</Field.Field>
{#if courseApi.errors.type}
<div
class="ui:mt-2 ui:rounded-md ui:border ui:border-destructive/30 ui:bg-destructive/5 ui:p-3 ui:text-sm ui:text-destructive"
role="alert"
>
<div class="ui:font-medium">{$t('course.navItem.settings.convert_to_public_blocked')}</div>
<p class="ui:mt-1 ui:text-destructive/90">{courseApi.errors.type}</p>
</div>
{/if}
</Field.Set>
{#if $settings.type === 'PUBLIC' && $settings.callout} {#if $settings.type === 'PUBLIC' && $settings.callout}
<Field.Separator /> <Field.Separator />
@@ -1,14 +1,30 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import type { CertificationEvaluationData } from '$features/course/utils/types';
export type ModalStep = 'checking' | 'eligible' | 'not-eligible';
export type CourseCompletionModalState = { export type CourseCompletionModalState = {
open: boolean; open: boolean;
courseId: string; courseId: string;
step: ModalStep;
evaluation: CertificationEvaluationData | null;
/** Exercise to retake when not-eligible */
exerciseId?: string;
}; };
export const courseCompletionModal = writable<CourseCompletionModalState | null>(null); export const courseCompletionModal = writable<CourseCompletionModalState | null>(null);
export function openCourseCompletionModal(courseId: string) { export function openCourseCompletionModal(courseId: string) {
courseCompletionModal.set({ open: true, courseId }); courseCompletionModal.set({ open: true, courseId, step: 'checking', evaluation: null });
}
export function updateCourseCompletionModal(
courseId: string,
step: ModalStep,
evaluation: CertificationEvaluationData,
exerciseId?: string
) {
courseCompletionModal.set({ open: true, courseId, step, evaluation, exerciseId });
} }
export function closeCourseCompletionModal() { export function closeCourseCompletionModal() {
@@ -1,5 +1,6 @@
import { resolveTemplateId } from '@cio/certificates'; import { resolveTemplateId } from '@cio/certificates';
import { DEFAULT_CERTIFICATE_THEME_ID, type CertificateThemeId } from './constants'; import { DEFAULT_CERTIFICATE_THEME_ID, type CertificateThemeId } from './constants';
import { t } from '$lib/utils/functions/translations';
/** /**
* Resolves a stored theme/template id. Maps legacy 6-theme ids onto the new * Resolves a stored theme/template id. Maps legacy 6-theme ids onto the new
@@ -12,6 +13,46 @@ export function parseCertificateThemeId(raw: string | null | undefined): Certifi
return resolveTemplateId(raw); return resolveTemplateId(raw);
} }
export type BlockerData = {
code: string;
params?: Record<string, string | number>;
};
export function formatBlockerMessage(blocker: BlockerData): string {
const p = blocker.params ?? {};
switch (blocker.code) {
case 'CERT_PROGRESS':
return t.get('course.certification.blocker_progress', {
current: Number(p.current ?? 0),
required: Number(p.required ?? 0)
});
case 'CERT_DEADLINE_PASSED':
return t.get('course.certification.blocker_deadline');
case 'CERT_NO_CONTENT':
return t.get('course.certification.blocker_no_content');
case 'CERT_FINAL_EXERCISE_NOT_SUBMITTED':
return t.get('course.certification.blocker_final_not_submitted', {
exerciseTitle: String(p.exerciseTitle ?? '')
});
case 'CERT_FINAL_EXERCISE_PENDING_GRADE':
return t.get('course.certification.blocker_final_pending', {
exerciseTitle: String(p.exerciseTitle ?? '')
});
case 'CERT_FINAL_EXERCISE_SCORE':
return t.get('course.certification.blocker_final_score', {
exerciseTitle: String(p.exerciseTitle ?? ''),
bestPercent: Number(p.bestPercent ?? 0),
requiredPercent: Number(p.requiredPercent ?? 0)
});
case 'CERT_FINAL_EXERCISE_MISCONFIGURED':
return t.get('course.certification.blocker_final_misconfigured', {
exerciseTitle: String(p.exerciseTitle ?? '')
});
default:
return blocker.code;
}
}
/** Normalizes DB/API timestamps to an ISO-8601 UTC string accepted by Zod `.datetime()`. */ /** Normalizes DB/API timestamps to an ISO-8601 UTC string accepted by Zod `.datetime()`. */
export function normalizeCertificateIssuedAt(value: string | null | undefined): string { export function normalizeCertificateIssuedAt(value: string | null | undefined): string {
if (!value) return new Date().toISOString(); if (!value) return new Date().toISOString();
@@ -15,6 +15,7 @@ type CourseSettings = {
isContentGroupingEnabled: boolean; isContentGroupingEnabled: boolean;
progressionMode: 'free' | 'sequential'; progressionMode: 'free' | 'sequential';
callout: TCourseCallout | null; callout: TCourseCallout | null;
welcomeEmailMessage: string;
}; };
export const settings = writable<CourseSettings>({ export const settings = writable<CourseSettings>({
@@ -34,5 +35,6 @@ export const settings = writable<CourseSettings>({
isPublished: false, isPublished: false,
isContentGroupingEnabled: true, isContentGroupingEnabled: true,
progressionMode: 'free', progressionMode: 'free',
callout: null callout: null,
welcomeEmailMessage: ''
}); });
@@ -0,0 +1,56 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import { RadioIcon } from '@cio/ui/custom/moving-icons';
import ClockIcon from '@lucide/svelte/icons/clock';
import VideoIcon from '@lucide/svelte/icons/video';
import { t } from '$lib/utils/functions/translations';
interface SessionItem {
lessonId: string;
courseTitle: string;
lessonTitle: string;
callUrl: string;
lessonAt: string;
timezone?: string | null;
}
let { sessions }: { sessions: SessionItem[] } = $props();
function dateLabel(iso: string, tz?: string | null): string {
try {
return new Intl.DateTimeFormat('en-US', {
timeZone: tz || Intl.DateTimeFormat().resolvedOptions().timeZone,
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(iso));
} catch {
return new Date(iso).toUTCString();
}
}
</script>
{#if sessions.length}
<section class="ui:bg-card ui:text-card-foreground ui:border-primary/30 rounded border p-4">
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold">
<RadioIcon size={16} color="#ef4444" />
{$t('dashboard.upcoming_sessions_heading')}
</h3>
<ul class="space-y-3">
{#each sessions as session (session.lessonId)}
<li class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="truncate text-sm font-medium">{session.lessonTitle}</p>
<p class="ui:text-muted-foreground flex items-center gap-1.5 truncate text-xs">
<ClockIcon size={12} class="shrink-0" />
{dateLabel(session.lessonAt, session.timezone)} · {session.courseTitle}
</p>
</div>
<a href={session.callUrl} target="_blank" rel="noreferrer" class="shrink-0">
<Button size="sm"><VideoIcon size={14} />{$t('course.navItem.lessons.session.join')}</Button>
</a>
</li>
{/each}
</ul>
</section>
{/if}
@@ -19,12 +19,14 @@
import { coursesApi } from '$features/course/api'; import { coursesApi } from '$features/course/api';
import * as ResourceListRow from '@cio/ui/custom/resource-list-row'; import * as ResourceListRow from '@cio/ui/custom/resource-list-row';
import { CourseListRow } from '$features/course/components'; import { CourseListRow } from '$features/course/components';
import UpcomingSessionsCard from '$features/lms/components/upcoming-sessions-card.svelte';
import CoursePublicBadge from '$features/course/components/course-public-badge.svelte'; import CoursePublicBadge from '$features/course/components/course-public-badge.svelte';
import CoursePreviewModal from '$features/lms/components/course-preview-modal.svelte'; import CoursePreviewModal from '$features/lms/components/course-preview-modal.svelte';
import type { RecommendedCourses } from '$features/course/types'; import type { RecommendedCourses } from '$features/course/types';
import { IconButton } from '@cio/ui/custom/icon-button'; import { IconButton } from '@cio/ui/custom/icon-button';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link'; import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import CopyIcon from '@lucide/svelte/icons/copy'; import CopyIcon from '@lucide/svelte/icons/copy';
import VideoIcon from '@lucide/svelte/icons/video';
import { copyPublicCoursePageUrl, openCoursePreview } from '$features/course/utils/course-preview'; import { copyPublicCoursePageUrl, openCoursePreview } from '$features/course/utils/course-preview';
import { import {
getStudentCourseComplianceDate, getStudentCourseComplianceDate,
@@ -59,6 +61,27 @@
let inProgressCourses = $derived(coursesApi.enrolledCourses.filter((course) => !isStudentCourseComplete(course))); let inProgressCourses = $derived(coursesApi.enrolledCourses.filter((course) => !isStudentCourseComplete(course)));
let highlightedCourses = $derived.by(() => getHighlightedCourses(inProgressCourses)); let highlightedCourses = $derived.by(() => getHighlightedCourses(inProgressCourses));
let currentCourse = $derived(highlightedCourses[0] ?? coursesApi.enrolledCourses[0] ?? null); let currentCourse = $derived(highlightedCourses[0] ?? coursesApi.enrolledCourses[0] ?? null);
let shouldShowCertificateHero = $derived.by(() => {
if (!currentCourse || coursesApi.enrolledCourses.length !== 1) {
return false;
}
return isStudentCourseComplete(currentCourse) && currentCourse.certificateEarnedAt != null;
});
let upcomingSessions = $derived(
coursesApi.enrolledCourses
.filter((course) => course.type === 'LIVE_CLASS' && course.upcomingSession)
.map((course) => ({
lessonId: course.upcomingSession!.lessonId,
courseTitle: course.title,
lessonTitle: course.upcomingSession!.lessonTitle,
callUrl: course.upcomingSession!.callUrl,
lessonAt: course.upcomingSession!.lessonAt,
timezone: course.upcomingSession!.sessionTimezone
}))
.sort((a, b) => new Date(a.lessonAt).getTime() - new Date(b.lessonAt).getTime())
);
$effect(() => { $effect(() => {
if (!$profile.id || !$currentOrg.id) return; if (!$profile.id || !$currentOrg.id) return;
@@ -127,6 +150,12 @@
goto(`/courses/${id}/lessons?next=true`); goto(`/courses/${id}/lessons?next=true`);
} }
function gotoCourseCertificates(id: string | undefined) {
if (!id) return;
goto(`/courses/${id}/certificates`);
}
function showPublishedPublicCourseLinks(course: EnrolledCourse | null) { function showPublishedPublicCourseLinks(course: EnrolledCourse | null) {
if (!course || course.type !== 'PUBLIC') { if (!course || course.type !== 'PUBLIC') {
return false; return false;
@@ -167,16 +196,28 @@
</script> </script>
<div class="space-y-6 pb-8"> <div class="space-y-6 pb-8">
{#if upcomingSessions.length}
<UpcomingSessionsCard sessions={upcomingSessions} />
{/if}
<BlurFade delay={0.05} once> <BlurFade delay={0.05} once>
<section class="ui:border-primary/20 ui:bg-primary/10 rounded border p-4 md:p-6"> <section class="ui:border-primary/20 ui:bg-primary/10 rounded border p-4 md:p-6">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="max-w-xl space-y-2"> <div class="max-w-xl space-y-2">
<p class="ui:text-primary text-xs font-semibold tracking-[0.18em] uppercase"> <p class="ui:text-primary text-xs font-semibold tracking-[0.18em] uppercase">
{#if shouldShowCertificateHero}
{$t('dashboard.get_your_certificate')}
{:else}
{$t('dashboard.pick_up_where_you_left_off')} {$t('dashboard.pick_up_where_you_left_off')}
{/if}
</p> </p>
<h2 class="text-xl font-semibold tracking-tight"> <h2 class="text-xl font-semibold tracking-tight">
{#if currentCourse} {#if currentCourse}
{#if shouldShowCertificateHero}
{currentCourse.title}
{:else}
{$t('dashboard.course_awaits_you', { title: currentCourse.title })} {$t('dashboard.course_awaits_you', { title: currentCourse.title })}
{/if}
{:else} {:else}
{$t('dashboard.learning_awaits_you')} {$t('dashboard.learning_awaits_you')}
{/if} {/if}
@@ -205,10 +246,19 @@
<Button <Button
variant="outline" variant="outline"
class="shrink-0" class="shrink-0"
onclick={() => (currentCourse ? gotoCourse(currentCourse.id) : goto('/lms/explore'))} onclick={() =>
currentCourse
? shouldShowCertificateHero
? gotoCourseCertificates(currentCourse.id)
: gotoCourse(currentCourse.id)
: goto('/lms/explore')}
> >
{#if currentCourse} {#if currentCourse}
{#if shouldShowCertificateHero}
{$t('dashboard.my_certificate')}
{:else}
{$t('dashboard.continue_learning')} {$t('dashboard.continue_learning')}
{/if}
{:else} {:else}
{$t('dashboard.view_courses')} {$t('dashboard.view_courses')}
{/if} {/if}
@@ -337,6 +387,11 @@
<CopyIcon class="size-4" /> <CopyIcon class="size-4" />
</IconButton> </IconButton>
{/if} {/if}
{#if course.type === 'LIVE_CLASS' && course.upcomingSession?.callUrl}
<a href={course.upcomingSession.callUrl} target="_blank" rel="noreferrer" class="shrink-0">
<Button><VideoIcon class="size-4" />{$t('course.navItem.lessons.session.join')}</Button>
</a>
{/if}
<Button variant="outline" class="shrink-0" onclick={() => gotoCourse(course.id)}> <Button variant="outline" class="shrink-0" onclick={() => gotoCourse(course.id)}>
{$t('dashboard.continue')} {$t('dashboard.continue')}
</Button> </Button>
@@ -2,3 +2,4 @@ export { default as LanguagePicker } from './language-picker.svelte';
export { default as AuthGeneral } from './auth-general.svelte'; export { default as AuthGeneral } from './auth-general.svelte';
export { default as AuthSso } from './auth-sso.svelte'; export { default as AuthSso } from './auth-sso.svelte';
export { default as AuthTokenAuth } from './auth-token-auth.svelte'; export { default as AuthTokenAuth } from './auth-token-auth.svelte';
export { default as OrgSettingsInlineTabs } from './org-settings-inline-tabs.svelte';
@@ -0,0 +1,52 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
import { currentOrgPath } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
const orgSettingsTabs = [
{ value: 'general', href: '/settings/org', label: 'settings.auth.tabs.general' },
{
value: 'domains',
href: '/settings/domains',
label: 'settings.organization.organization_profile.custom_domain.heading'
},
{ value: 'teams', href: '/settings/teams', label: 'settings.organization.organization_profile.team.heading' },
{ value: 'customize-lms', href: '/settings/customize-lms', label: 'settings.tabs.customize_lms_tab' }
] as const;
function getCurrentTab(pathname: string) {
if (pathname.endsWith('/settings/org')) return 'general';
if (pathname.endsWith('/settings/customize-lms')) return 'customize-lms';
if (pathname.endsWith('/settings/domains')) return 'domains';
if (pathname.endsWith('/settings/teams')) return 'teams';
return null;
}
let currentTab = $derived(getCurrentTab(page.url.pathname));
function handleTabChange(value: string) {
const nextTab = orgSettingsTabs.find((tab) => tab.value === value);
if (!nextTab || value === currentTab) return;
goto($currentOrgPath + nextTab.href);
}
</script>
{#if currentTab}
<div class="mb-6 px-2">
<UnderlineTabs.Root value={currentTab} onValueChange={(event) => handleTabChange(event)}>
<UnderlineTabs.List class="flex flex-wrap gap-2 border-b">
{#each orgSettingsTabs as tab}
<UnderlineTabs.Trigger value={tab.value}>
{$t(tab.label)}
</UnderlineTabs.Trigger>
{/each}
</UnderlineTabs.List>
</UnderlineTabs.Root>
</div>
{/if}
@@ -1,17 +1,14 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation';
import ZapIcon from '@lucide/svelte/icons/zap';
import PlusIcon from '@lucide/svelte/icons/plus'; import PlusIcon from '@lucide/svelte/icons/plus';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import ColorPicker from 'svelte-awesome-color-picker'; import ColorPicker from 'svelte-awesome-color-picker';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
import { setTheme } from '$lib/utils/functions/theme'; import { setTheme } from '$lib/utils/functions/theme';
import { orgApi } from '$features/org/api/org.svelte'; import { orgApi } from '$features/org/api/org.svelte';
import { Input } from '@cio/ui/base/input'; import { Input } from '@cio/ui/base/input';
import { Button } from '@cio/ui/base/button';
import { UploadImage, UnsavedChanges } from '$features/ui'; import { UploadImage, UnsavedChanges } from '$features/ui';
import * as Field from '@cio/ui/base/field'; import * as Field from '@cio/ui/base/field';
@@ -63,9 +60,6 @@
} }
} }
function gotoSettings(pathname: string) {
goto(`${$currentOrgPath}/settings${pathname}`);
}
let isCustomTheme = $derived($currentOrg?.theme?.includes('#')); let isCustomTheme = $derived($currentOrg?.theme?.includes('#'));
let hex = $derived($currentOrg.theme?.includes('#') ? $currentOrg.theme : undefined); let hex = $derived($currentOrg.theme?.includes('#') ? $currentOrg.theme : undefined);
</script> </script>
@@ -171,51 +165,6 @@
</div> </div>
</Field.Field> </Field.Field>
</Field.Set> </Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.organization.organization_profile.customize_lms.heading')}</Field.Legend>
<Field.Description>{$t('settings.organization.organization_profile.customize_lms.sub_heading')}</Field.Description>
<Field.Description>{$t('settings.organization.organization_profile.customize_lms.body')}</Field.Description>
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={() => gotoSettings('/customize-lms')}>
{$t('settings.organization.organization_profile.customize_lms.button')}
</Button>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.organization.organization_profile.custom_domain.heading')}</Field.Legend>
<Field.Description>{$t('settings.organization.organization_profile.custom_domain.sub_heading')}</Field.Description>
<Field.Description>{$t('settings.organization.organization_profile.custom_domain.body')}</Field.Description>
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={() => gotoSettings('/domains')}>
{#if $isFreePlan}
<ZapIcon size={16} class="filled" />
{/if}
{$t('settings.organization.organization_profile.custom_domain.button')}
</Button>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.organization.organization_profile.team.heading')}</Field.Legend>
<Field.Description>{$t('settings.organization.organization_profile.team.sub_heading')}</Field.Description>
<Field.Description>{$t('settings.organization.organization_profile.team.body')}</Field.Description>
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={() => gotoSettings('/teams')}>
{#if $isFreePlan}
<ZapIcon size={16} class="filled" />
{/if}
{$t('settings.organization.organization_profile.team.button')}
</Button>
</Field.Field>
</Field.Set>
</Field.Group> </Field.Group>
<style> <style>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import * as Select from '@cio/ui/base/select'; import * as Select from '@cio/ui/base/select';
import { Spinner } from '@cio/ui/base/spinner'; import { Spinner } from '@cio/ui/base/spinner';
import { Switch } from '@cio/ui/base/switch'; import CopyIcon from '@lucide/svelte/icons/copy';
import { profile } from '$lib/utils/store/user'; import { profile } from '$lib/utils/store/user';
import { isFreePlan, currentOrg } from '$lib/utils/store/org'; import { isFreePlan, currentOrg } from '$lib/utils/store/org';
@@ -23,32 +23,23 @@
let errorMessage = $state(''); let errorMessage = $state('');
let role = $state(ROLE.TUTOR.toString()); let role = $state(ROLE.TUTOR.toString());
let isRemoving: number | null = $state(null); let isRemoving: number | null = $state(null);
let linkRole = $state(ROLE.TUTOR.toString());
let copiedLink = $state(false);
function buildLinkInviteUrl(token: string): string { function buildLinkInviteUrl(token: string): string {
return `${window.location.origin}/invite/link/${encodeURIComponent(token)}`; return `${window.location.origin}/invite/link/${encodeURIComponent(token)}`;
} }
async function onGenerateLink() { async function onCopyInviteLink() {
if ($isFreePlan) { if ($isFreePlan) {
snackbar.error('upgrade.required'); snackbar.error('upgrade.required');
return; return;
} }
await orgApi.generateLinkInvite(parseInt(linkRole)); await orgApi.generateLinkInvite(parseInt(role));
} const linkInviteToken = orgApi.linkInvite?.token;
async function onCopyLink() { if (!linkInviteToken) return;
if (!orgApi.linkInvite?.token) return;
await navigator.clipboard.writeText(buildLinkInviteUrl(orgApi.linkInvite.token)); await navigator.clipboard.writeText(buildLinkInviteUrl(linkInviteToken));
copiedLink = true;
setTimeout(() => (copiedLink = false), 2000);
}
async function onToggleLinkInvite(active: boolean) {
await orgApi.toggleLinkInvite(!active);
} }
async function onSendInvite() { async function onSendInvite() {
@@ -108,20 +99,18 @@
if (!$currentOrg) return; if (!$currentOrg) return;
orgApi.getOrgTeam(); orgApi.getOrgTeam();
orgApi.getLinkInvite();
}); });
</script> </script>
<UpgradeBanner>{$t('upgrade.team')}</UpgradeBanner> <UpgradeBanner>{$t('upgrade.team')}</UpgradeBanner>
<Field.Group class="w-full max-w-md! px-2"> <Field.Group class="w-full max-w-3xl! px-2">
<Field.Set> <Field.Set>
<Field.Legend>{$t('course.navItem.people.teams.add')}</Field.Legend> <Field.Legend>{$t('course.navItem.people.teams.add')}</Field.Legend>
<Field.Description class="mb-5">{$t('course.navItem.people.teams.add_team')}</Field.Description> <Field.Description class="mb-5">{$t('course.navItem.people.teams.add_team')}</Field.Description>
<Field.Group> <div class="flex flex-col gap-4 lg:grid lg:grid-cols-[minmax(0,1fr)_16rem_auto] lg:items-start">
<Field.Field> <Field.Field class="min-w-0">
<Field.Label>{$t('course.navItem.people.teams.invite')}</Field.Label>
<Input <Input
placeholder={$t('course.navItem.people.teams.placeholder')} placeholder={$t('course.navItem.people.teams.placeholder')}
bind:value={emailsStr} bind:value={emailsStr}
@@ -133,10 +122,9 @@
{/if} {/if}
</Field.Field> </Field.Field>
<Field.Field> <div class="w-full lg:w-64">
<Field.Label>{$t('course.navItem.people.teams.role')}</Field.Label>
<Select.Root type="single" bind:value={role} disabled={$isFreePlan}> <Select.Root type="single" bind:value={role} disabled={$isFreePlan}>
<Select.Trigger id="invite-role" class="w-40"> <Select.Trigger id="invite-role" class="ui:w-full ui:max-w-none">
<p>{role ? $t(ROLE_LABEL[role]) : $t('course.navItem.people.teams.select_role')}</p> <p>{role ? $t(ROLE_LABEL[role]) : $t('course.navItem.people.teams.select_role')}</p>
</Select.Trigger> </Select.Trigger>
<Select.Content> <Select.Content>
@@ -144,8 +132,21 @@
<Select.Item value={ROLE.TUTOR.toString()}>{$t(ROLE_LABEL[ROLE.TUTOR])}</Select.Item> <Select.Item value={ROLE.TUTOR.toString()}>{$t(ROLE_LABEL[ROLE.TUTOR])}</Select.Item>
</Select.Content> </Select.Content>
</Select.Root> </Select.Root>
</Field.Field>
<Button
variant="link"
size="sm"
class="h-auto justify-start"
onclick={onCopyInviteLink}
loading={orgApi.isLoading}
disabled={orgApi.isLoading || $isFreePlan}
>
<CopyIcon size={14} />
{$t('course.navItem.people.teams.link_invite.copy_invite_link')}
</Button>
</div>
<div class="lg:self-start">
<Button <Button
variant="default" variant="default"
onclick={onSendInvite} onclick={onSendInvite}
@@ -154,70 +155,8 @@
> >
{$t('course.navItem.people.teams.send_invite')} {$t('course.navItem.people.teams.send_invite')}
</Button> </Button>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('course.navItem.people.teams.link_invite.heading')}</Field.Legend>
<Field.Description class="mb-4">{$t('course.navItem.people.teams.link_invite.description')}</Field.Description>
{#if !orgApi.linkInvite}
<Field.Group>
<Field.Field>
<Field.Label>{$t('course.navItem.people.teams.role')}</Field.Label>
<Select.Root type="single" bind:value={linkRole} disabled={$isFreePlan}>
<Select.Trigger class="w-40">
<p>{linkRole ? $t(ROLE_LABEL[linkRole]) : $t('course.navItem.people.teams.link_invite.select_role')}</p>
</Select.Trigger>
<Select.Content>
<Select.Item value={ROLE.ADMIN.toString()}>{$t(ROLE_LABEL[ROLE.ADMIN])}</Select.Item>
<Select.Item value={ROLE.TUTOR.toString()}>{$t(ROLE_LABEL[ROLE.TUTOR])}</Select.Item>
</Select.Content>
</Select.Root>
</Field.Field>
<Button
variant="default"
onclick={onGenerateLink}
loading={orgApi.isLoading}
disabled={orgApi.isLoading || $isFreePlan}
>
{$t('course.navItem.people.teams.link_invite.generate')}
</Button>
</Field.Group>
{:else}
<Field.Group>
<Field.Field>
<div class="flex items-center gap-2">
<Input
value={buildLinkInviteUrl(orgApi.linkInvite.token)}
readonly
class="w-full font-mono text-xs {orgApi.linkInvite.isRevoked ? 'opacity-50' : ''}"
/>
<Button variant="outline" size="sm" onclick={onCopyLink} disabled={orgApi.linkInvite.isRevoked}>
{copiedLink
? $t('course.navItem.people.teams.link_invite.copied')
: $t('course.navItem.people.teams.link_invite.copy')}
</Button>
</div> </div>
</Field.Field> </div>
{#if orgApi.linkInvite.isRevoked}
<p class="text-sm text-amber-600">{$t('course.navItem.people.teams.link_invite.disabled_notice')}</p>
{/if}
<Field.Field orientation="horizontal" class="mt-2">
<Switch
checked={!orgApi.linkInvite.isRevoked}
onCheckedChange={onToggleLinkInvite}
disabled={orgApi.isLoading || $isFreePlan}
/>
<Field.Label>{$t('course.navItem.people.teams.link_invite.toggle_label')}</Field.Label>
</Field.Field>
</Field.Group>
{/if}
</Field.Set> </Field.Set>
<Field.Separator /> <Field.Separator />
@@ -14,3 +14,17 @@ export const landingPageSettings: Writable<OrgLandingPageJson> = writable(create
* no section is selected — the editor shows the section list. * no section is selected — the editor shows the section list.
*/ */
export const landingPageEditorSelection: Writable<LandingSectionKey | null> = writable(null); export const landingPageEditorSelection: Writable<LandingSectionKey | null> = writable(null);
type SettingsHeaderAction = {
label: string;
disabled: boolean;
loading: boolean;
onClick: null | (() => void | Promise<void>);
};
export const settingsHeaderAction: Writable<SettingsHeaderAction> = writable({
label: 'Save',
disabled: true,
loading: false,
onClick: null
});
@@ -81,15 +81,25 @@ export function generateBreadcrumbs(
const normalizedPathname = pathname.replace(/\/$/, ''); const normalizedPathname = pathname.replace(/\/$/, '');
if (normalizedPathname !== parentBase) { if (normalizedPathname !== parentBase) {
const activeSub = matchedNavItem.items.find((sub) => { const activeSub = matchedNavItem.items.find((sub) => isActive(pathname, sub.url, sub.matchPattern, true));
const subBase = sub.url.replace(/\/$/, '');
return normalizedPathname === subBase || normalizedPathname.startsWith(`${subBase}/`);
});
if (activeSub) { if (activeSub) {
breadcrumbs.push({ breadcrumbs.push({
label: t(activeSub.title), label: t(activeSub.title),
href: activeSub.url href: activeSub.url
}); });
const activeSubBase = activeSub.url.replace(/\/$/, '');
if (normalizedPathname !== activeSubBase && activeSub.nestedRoutes && pathSegments.length > 1) {
const activeNestedRoute = activeSub.nestedRoutes.find((route) => route.path === pathSegments[1]);
if (activeNestedRoute) {
breadcrumbs.push({
label: t(activeNestedRoute.titleKey),
href: `${currentOrgPath}${matchedNavItem.path}/${activeNestedRoute.path}`
});
}
}
return breadcrumbs; return breadcrumbs;
} }
} }
@@ -201,11 +201,27 @@ export const baseNavConfig: NavItemConfig[] = [
items: [ items: [
{ {
titleKey: 'settings.tabs.profile_tab', titleKey: 'settings.tabs.profile_tab',
path: '/settings' path: '/settings',
matchPattern: '^/org/[^/]+/settings/?$'
}, },
{ {
titleKey: 'settings.tabs.organization_tab', titleKey: 'settings.tabs.organization_tab',
path: '/settings/org' path: '/settings/org',
matchPattern: '^/org/[^/]+/settings/(org|customize-lms|domains|teams)(/.*)?$',
nestedRoutes: [
{
path: 'domains',
titleKey: 'settings.organization.organization_profile.custom_domain.heading'
},
{
path: 'teams',
titleKey: 'settings.organization.organization_profile.team.heading'
},
{
path: 'customize-lms',
titleKey: 'settings.tabs.customize_lms_tab'
}
]
}, },
{ {
titleKey: 'settings.tabs.landing_page_tab', titleKey: 'settings.tabs.landing_page_tab',
@@ -331,7 +347,9 @@ export function getOrgNavigationItems(
isActive: isActive(pagePathname, subUrl, subMatchPattern, true), isActive: isActive(pagePathname, subUrl, subMatchPattern, true),
url: subUrl, url: subUrl,
path: subConfig.path, path: subConfig.path,
isPaid: subConfig.isPaid matchPattern: subMatchPattern,
isPaid: subConfig.isPaid,
nestedRoutes: subConfig.nestedRoutes
}; };
}); });
} }
@@ -410,7 +428,9 @@ export function getOrgNavigationGroups(
isActive: isActive(pathnameOnly, subUrl, subMatchPattern, true), isActive: isActive(pathnameOnly, subUrl, subMatchPattern, true),
url: subUrl, url: subUrl,
path: subConfig.path, path: subConfig.path,
isPaid: subConfig.isPaid matchPattern: subMatchPattern,
isPaid: subConfig.isPaid,
nestedRoutes: subConfig.nestedRoutes
}; };
}); });
} }
@@ -16,6 +16,10 @@
snackbar.error('grade cant be more than max value'); snackbar.error('grade cant be more than max value');
grade = gradeMax; grade = gradeMax;
} }
if (grade && grade < 0) {
snackbar.error('grade cant be less than 0');
grade = 0;
}
}); });
</script> </script>
@@ -24,6 +28,7 @@
placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.points')} placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.points')}
bind:value={grade} bind:value={grade}
max={gradeMax} max={gradeMax}
min={0}
type="number" type="number"
inputClassName="!w-16" inputClassName="!w-16"
isDisabled={disableGrading} isDisabled={disableGrading}
@@ -1,7 +1,42 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime'; import relativeTime from 'dayjs/plugin/relativeTime';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
dayjs.extend(relativeTime); dayjs.extend(relativeTime);
dayjs.extend(utc);
dayjs.extend(timezone);
/** The browser's IANA timezone (e.g. "America/New_York"), with a UTC fallback. */
export function getBrowserTimezone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
} catch {
return 'UTC';
}
}
/**
* Convert a `datetime-local` wall-clock string (e.g. "2026-06-20T14:00") that the
* user intends in `timeZone` into an absolute UTC instant (ISO string). DST-safe.
* Never use `new Date(wallClock).toISOString()` — that assumes the browser zone.
*/
export function zonedWallClockToInstant(wallClock: string, timeZone: string): string {
if (!wallClock) return '';
return dayjs.tz(wallClock, timeZone).toISOString();
}
/**
* Inverse of {@link zonedWallClockToInstant}: render an absolute instant as a
* `datetime-local` wall-clock string in `timeZone` (so editing shows the same
* local time the user set).
*/
export function instantToZonedWallClock(iso: string | null | undefined, timeZone: string): string {
if (!iso) return '';
return dayjs(iso).tz(timeZone).format('YYYY-MM-DDTHH:mm');
}
export const calDateDiff = (date: string | number | Date): string => { export const calDateDiff = (date: string | number | Date): string => {
return dayjs(date).fromNow(true) + ` ago`; return dayjs(date).fromNow(true) + ` ago`;
@@ -5,10 +5,10 @@ import { env } from '$env/dynamic/public';
const isSelfHosted = PUBLIC_IS_SELFHOSTED === 'true'; const isSelfHosted = PUBLIC_IS_SELFHOSTED === 'true';
const DEFAULT_TITLE = 'ClassroomIO | The Most Customizable Platform for Customer, Partner, and Employee Education'; const DEFAULT_TITLE = 'ClassroomIO | One Platform for Customer, Partner, and Employee Training';
const DEFAULT_DESCRIPTION = const DEFAULT_DESCRIPTION =
'One platform for customer academies, partner certification, and employee training. Build courses with AI, publish under your domain, and track completions.'; 'One platform for customer academies, partner certification, and employee training. Build courses with AI, publish under your domain, and track completions.';
const CLOUD_OG_IMAGE = 'https://brand.cdn.clsrio.com/og/classroomio-opengraph.png'; const CLOUD_OG_IMAGE = 'https://brand.cdn.clsrio.com/og/classroomio-opengraph.jpg';
function resolveOgImageUrl(url: URL, orgSiteInfo: OrgSiteInfo): string { function resolveOgImageUrl(url: URL, orgSiteInfo: OrgSiteInfo): string {
const envUrl = env.PUBLIC_OG_IMAGE_URL?.trim(); const envUrl = env.PUBLIC_OG_IMAGE_URL?.trim();
@@ -68,7 +68,15 @@ export function getBaseMetaTags(url: URL, orgSiteInfo: OrgSiteInfo): MetaTagsPro
width: 1920, width: 1920,
height: 1080, height: 1080,
secureUrl: ogImageUrl, secureUrl: ogImageUrl,
type: 'image/png' type: 'image/jpeg'
},
{
url: 'https://brand.cdn.clsrio.com/og/classroomio-opengraph.webp',
alt: `${siteName} platform for customer, partner, and employee education`,
width: 1920,
height: 1080,
secureUrl: 'https://brand.cdn.clsrio.com/og/classroomio-opengraph.webp',
type: 'image/webp'
} }
] ]
}, },
@@ -677,6 +677,15 @@
"user_analytics": { "user_analytics": {
"title": "Elevprofil", "title": "Elevprofil",
"page_subtitle": "Progress, grades, and activity across their courses." "page_subtitle": "Progress, grades, and activity across their courses."
},
"delete": {
"action": "Slet",
"title": "Slet medlem",
"description": "Dette fjerner medlemmet fra din organisation og tilbagekalder deres adgang til alle tildelte kurser og programmer.",
"confirmation": "Er du sikker på, at du vil slette {name} ({email})? Denne handling kan ikke fortrydes.",
"cancel": "Annuller",
"confirm": "Slet",
"snackbar_success": "Medlem slettet"
} }
}, },
"community": { "community": {
@@ -803,6 +812,12 @@
"animation_waves": "Bølger", "animation_waves": "Bølger",
"animation_dotted": "Stiplet", "animation_dotted": "Stiplet",
"animation_none": "Ingen" "animation_none": "Ingen"
},
"course_type_learn_more": "Få flere oplysninger",
"welcome_email": {
"title": "Velkomstmail",
"description": "Tilpas den e-mail, som eleverne modtager efter tilmelding. Tilføj en personlig note, instruktioner eller et link til en livesession.",
"placeholder": "Skriv den velkomstbesked, eleverne får, når de tilmelder sig..."
} }
}, },
"analytics": { "analytics": {
@@ -1054,7 +1069,7 @@
"add": "Tilføj", "add": "Tilføj",
"add_team": "Tilføj dine holdkammerater eller samarbejdspartnere til din organisation. Begynd at arbejde sammen", "add_team": "Tilføj dine holdkammerater eller samarbejdspartnere til din organisation. Begynd at arbejde sammen",
"invite": "Inviter", "invite": "Inviter",
"placeholder": "e-mail kommasepareret", "placeholder": "user1@domain.com, user2@domain.com",
"role": "Rolle", "role": "Rolle",
"send_invite": "Send invitation", "send_invite": "Send invitation",
"members": "Medlemmer", "members": "Medlemmer",
@@ -1066,6 +1081,7 @@
"description": "Alle med dette link kan tilslutte sig din organisation.", "description": "Alle med dette link kan tilslutte sig din organisation.",
"generate": "Generer link", "generate": "Generer link",
"copy": "Kopiér", "copy": "Kopiér",
"copy_invite_link": "Kopiér invitationslink",
"copied": "Kopieret!", "copied": "Kopieret!",
"toggle_label": "Linkinvitation aktiv", "toggle_label": "Linkinvitation aktiv",
"disabled_notice": "Dette link er deaktiveret. Aktiver det for at tillade nye medlemmer at deltage via link.", "disabled_notice": "Dette link er deaktiveret. Aktiver det for at tillade nye medlemmer at deltage via link.",
@@ -1775,7 +1791,13 @@
"settings_completion_policy": "Fuldførelsesregel", "settings_completion_policy": "Fuldførelsesregel",
"settings_completion_submitted": "Indsendt - enhver indsendelse tæller", "settings_completion_submitted": "Indsendt - enhver indsendelse tæller",
"settings_completion_passed": "Bestået - minimumsscore kræves", "settings_completion_passed": "Bestået - minimumsscore kræves",
"settings_pass_threshold": "Beståelsesgrænse (%)" "settings_pass_threshold": "Beståelsesgrænse (%)",
"notify": {
"action": "Giv eleverne besked",
"sending": "Underretter elever...",
"sent": "Meddelte {count} elever",
"failed": "Eleverne kunne ikke underrettes"
}
}, },
"new_exercise_modal": { "new_exercise_modal": {
"heading": "Ny øvelse", "heading": "Ny øvelse",
@@ -2046,6 +2068,41 @@
"sections": "Afsnit", "sections": "Afsnit",
"lessons": "Lektioner", "lessons": "Lektioner",
"exercises": "Øvelser" "exercises": "Øvelser"
},
"session": {
"join": "Deltag i session",
"heading": "Live session",
"intro": "Indstil deltagelseslinket og det planlagte tidspunkt for dette livehold. Eleverne får rykker 24 timer og 1 time før.",
"link_label": "Tilmeld dig link (Zoom, Mød osv.)",
"datetime_label": "Dato og tid",
"timezone_label": "Tidszone",
"timezone_helper": "Påmindelser og kalenderinvitationer bruger denne tidszone til visning.",
"save": "Gem session",
"saved": "Session gemt",
"notified": "Eleverne får besked om den opdaterede session",
"notify_failed": "Kunne ikke give eleverne besked om ændringen",
"confirm_title": "Underrette eleverne om ændringen?",
"confirm_body": "Når du gemmer, sendes en e-mail til alle tilmeldte elever om den opdaterede session med en ny kalenderinvitation, der opdaterer begivenheden i deres kalender.",
"confirm_cancel": "Annuller",
"confirm_save": "Gem og underret",
"live_indicator": "Denne lektion er en live session",
"live_session": "Live session",
"live_now": "Live nu",
"live_event": "Live session",
"started": "Startede",
"min_ago": "min siden",
"join_now": "Deltag nu",
"copy_link": "Kopiér link",
"copy": "Kopiér",
"add_to_calendar": "Tilføj til kalender",
"add_google": "Google Kalender",
"add_outlook": "Outlook.com",
"add_office365": "Office 365",
"add_yahoo": "Yahoo kalender",
"add_apple": "Apple-kalender (.ics)",
"starts_in": "Starter i",
"session_ended": "Session afsluttet",
"link_copied": "Linket er kopieret"
} }
}, },
"news_feed": { "news_feed": {
@@ -2211,7 +2268,10 @@
"title": "Kursuskravene er fuldført!", "title": "Kursuskravene er fuldført!",
"subtitle": "Tillykke – du kan se og downloade dit certifikat når som helst.", "subtitle": "Tillykke – du kan se og downloade dit certifikat når som helst.",
"view_certificate": "Se certifikat", "view_certificate": "Se certifikat",
"later": "Luk" "later": "Luk",
"checking": "Tjekker berettigelse...",
"not_eligible_title": "Næsten der",
"retake_exercise": "Gentag øvelsen"
} }
}, },
"certificates": { "certificates": {
@@ -2236,7 +2296,8 @@
"blocker_final_score": "Din bedste score på \"{exerciseTitle}\" er {bestPercent}%; {requiredPercent} % er påkrævet.", "blocker_final_score": "Din bedste score på \"{exerciseTitle}\" er {bestPercent}%; {requiredPercent} % er påkrævet.",
"blocker_final_misconfigured": "Certificeringsøvelsen \"{exerciseTitle}\" har ingen point konfigureret; bed din instruktør om at ordne det.", "blocker_final_misconfigured": "Certificeringsøvelsen \"{exerciseTitle}\" har ingen point konfigureret; bed din instruktør om at ordne det.",
"email_message_label": "E-mail besked", "email_message_label": "E-mail besked",
"email_message_placeholder": "f.eks. Vi er stolte af dine fremskridt - fortsæt med at lære!" "email_message_placeholder": "f.eks. Vi er stolte af dine fremskridt - fortsæt med at lære!",
"final_exercise_multiple_attempts_note": "Elever får lov til ubegrænsede forsøg på den valgte øvelse."
}, },
"creator": { "creator": {
"heading": "Hvilket kursus vil du lave?", "heading": "Hvilket kursus vil du lave?",
@@ -2440,8 +2501,10 @@
"learner": "Student", "learner": "Student",
"progress": "Fremskridt", "progress": "Fremskridt",
"pick_up_where_you_left_off": "Fortsæt hvor du slap", "pick_up_where_you_left_off": "Fortsæt hvor du slap",
"get_your_certificate": "Hent dit certifikat",
"course_awaits_you": "{title} venter på dig", "course_awaits_you": "{title} venter på dig",
"learning_awaits_you": "Dit næste kursus venter på dig", "learning_awaits_you": "Dit næste kursus venter på dig",
"my_certificate": "Mit certifikat",
"lms_today_progress": "Du er {progress} % gennem din læring - hvert skridt tæller.", "lms_today_progress": "Du er {progress} % gennem din læring - hvert skridt tæller.",
"lms_today_empty": "Din læringsvej er klar, når du er.", "lms_today_empty": "Din læringsvej er klar, når du er.",
"compliance_score": "Overholdelsesscore", "compliance_score": "Overholdelsesscore",
@@ -2459,7 +2522,8 @@
"login_activity_logins": "logins", "login_activity_logins": "logins",
"open_academy": "Åbent Akademi", "open_academy": "Åbent Akademi",
"explore_more_courses": "Udforsk flere kurser", "explore_more_courses": "Udforsk flere kurser",
"view_more": "Se mere" "view_more": "Se mere",
"upcoming_sessions_heading": "Live sessioner"
}, },
"schedule": { "schedule": {
"title": "Dit skema", "title": "Dit skema",
@@ -677,6 +677,15 @@
"user_analytics": { "user_analytics": {
"title": "Studentenprofil", "title": "Studentenprofil",
"page_subtitle": "Progress, grades, and activity across their courses." "page_subtitle": "Progress, grades, and activity across their courses."
},
"delete": {
"action": "Löschen",
"title": "Mitglied löschen",
"description": "Dadurch wird das Mitglied aus Ihrer Organisation entfernt und ihm der Zugriff auf alle zugewiesenen Kurse und Programme entzogen.",
"confirmation": "Sind Sie sicher, dass Sie {name} ({email}) löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.",
"cancel": "Abbrechen",
"confirm": "Löschen",
"snackbar_success": "Mitglied gelöscht"
} }
}, },
"community": { "community": {
@@ -803,6 +812,12 @@
"animation_waves": "Wellen", "animation_waves": "Wellen",
"animation_dotted": "Gepunktet", "animation_dotted": "Gepunktet",
"animation_none": "Keine" "animation_none": "Keine"
},
"course_type_learn_more": "Erfahren Sie mehr",
"welcome_email": {
"title": "Willkommens-E-Mail",
"description": "Passen Sie die E-Mail an, die Studierende nach der Anmeldung erhalten. Fügen Sie eine persönliche Notiz, Anweisungen oder einen Link zur Live-Sitzung hinzu.",
"placeholder": "Schreiben Sie die Willkommensnachricht, die Studierende bei der Einschreibung erhalten …"
} }
}, },
"analytics": { "analytics": {
@@ -1054,7 +1069,7 @@
"add": "Hinzufügen", "add": "Hinzufügen",
"add_team": "Fügen Sie Ihre Teamkollegen oder Mitarbeiter zu Ihrer Organisation hinzu. Beginnen Sie mit der Zusammenarbeit", "add_team": "Fügen Sie Ihre Teamkollegen oder Mitarbeiter zu Ihrer Organisation hinzu. Beginnen Sie mit der Zusammenarbeit",
"invite": "Einladen", "invite": "Einladen",
"placeholder": "E-Mail durch Komma getrennt", "placeholder": "user1@domain.com, user2@domain.com",
"role": "Rolle", "role": "Rolle",
"send_invite": "Einladung senden", "send_invite": "Einladung senden",
"members": "Mitglieder", "members": "Mitglieder",
@@ -1066,6 +1081,7 @@
"description": "Jeder mit diesem Link kann Ihrer Organisation beitreten.", "description": "Jeder mit diesem Link kann Ihrer Organisation beitreten.",
"generate": "Link generieren", "generate": "Link generieren",
"copy": "Kopieren", "copy": "Kopieren",
"copy_invite_link": "Einladungslink kopieren",
"copied": "Kopiert!", "copied": "Kopiert!",
"toggle_label": "Linkeinladung aktiv", "toggle_label": "Linkeinladung aktiv",
"disabled_notice": "Dieser Link ist deaktiviert. Aktivieren Sie es, um neuen Mitgliedern den Beitritt über einen Link zu ermöglichen.", "disabled_notice": "Dieser Link ist deaktiviert. Aktivieren Sie es, um neuen Mitgliedern den Beitritt über einen Link zu ermöglichen.",
@@ -1775,7 +1791,13 @@
"settings_completion_policy": "Abschlussregel", "settings_completion_policy": "Abschlussregel",
"settings_completion_submitted": "Eingereicht – jede Einreichung zählt", "settings_completion_submitted": "Eingereicht – jede Einreichung zählt",
"settings_completion_passed": "Bestanden – Mindestpunktzahl erforderlich", "settings_completion_passed": "Bestanden – Mindestpunktzahl erforderlich",
"settings_pass_threshold": "Bestehensschwelle (%)" "settings_pass_threshold": "Bestehensschwelle (%)",
"notify": {
"action": "Benachrichtigen Sie die Schüler",
"sending": "Studierende benachrichtigen…",
"sent": "{count} Studenten benachrichtigt",
"failed": "Die Schüler konnten nicht benachrichtigt werden"
}
}, },
"new_exercise_modal": { "new_exercise_modal": {
"heading": "Neue Übung", "heading": "Neue Übung",
@@ -2046,6 +2068,41 @@
"sections": "Abschnitte", "sections": "Abschnitte",
"lessons": "Lektionen", "lessons": "Lektionen",
"exercises": "Übungen" "exercises": "Übungen"
},
"session": {
"join": "Nehmen Sie an der Sitzung teil",
"heading": "Live-Sitzung",
"intro": "Legen Sie den Beitrittslink und die geplante Zeit für diesen Live-Kurs fest. Schüler erhalten 24 Stunden und 1 Stunde vorher Erinnerungen.",
"link_label": "Beitrittslink (Zoom, Meet usw.)",
"datetime_label": "Datum und Uhrzeit",
"timezone_label": "Zeitzone",
"timezone_helper": "Erinnerungen und die Kalendereinladung verwenden diese Zeitzone zur Anzeige.",
"save": "Sitzung speichern",
"saved": "Sitzung gespeichert",
"notified": "Studierende werden über die aktualisierte Sitzung informiert",
"notify_failed": "Die Schüler konnten nicht über die Änderung informiert werden",
"confirm_title": "Studierende über die Änderung informieren?",
"confirm_body": "Durch das Speichern werden alle eingeschriebenen Schüler per E-Mail über die aktualisierte Sitzung informiert, zusammen mit einer neuen Kalendereinladung, die das Ereignis in ihrem Kalender aktualisiert.",
"confirm_cancel": "Abbrechen",
"confirm_save": "Speichern und benachrichtigen",
"live_indicator": "Diese Lektion ist eine Live-Sitzung",
"live_session": "Live-Sitzung",
"live_now": "Jetzt live",
"live_event": "Live-Sitzung",
"started": "Begonnen",
"min_ago": "vor einer Minute",
"join_now": "Jetzt teilnehmen",
"copy_link": "Link kopieren",
"copy": "Kopieren",
"add_to_calendar": "Zum Kalender hinzufügen",
"add_google": "Google Kalender",
"add_outlook": "Outlook.com",
"add_office365": "Büro 365",
"add_yahoo": "Yahoo-Kalender",
"add_apple": "Apple Kalender (.ics)",
"starts_in": "Beginnt in",
"session_ended": "Sitzung beendet",
"link_copied": "Link kopiert"
} }
}, },
"news_feed": { "news_feed": {
@@ -2211,7 +2268,10 @@
"title": "Kursvoraussetzungen erfüllt!", "title": "Kursvoraussetzungen erfüllt!",
"subtitle": "Herzlichen Glückwunsch – Sie können Ihr Zertifikat jederzeit einsehen und herunterladen.", "subtitle": "Herzlichen Glückwunsch – Sie können Ihr Zertifikat jederzeit einsehen und herunterladen.",
"view_certificate": "Zertifikat ansehen", "view_certificate": "Zertifikat ansehen",
"later": "Schließen" "later": "Schließen",
"checking": "Berechtigung prüfen…",
"not_eligible_title": "Fast da",
"retake_exercise": "Wiederholen Sie die Übung"
} }
}, },
"certificates": { "certificates": {
@@ -2236,7 +2296,8 @@
"blocker_final_score": "Ihre beste Punktzahl bei „{exerciseTitle}“ beträgt {bestPercent} %; {requiredPercent}% ist erforderlich.", "blocker_final_score": "Ihre beste Punktzahl bei „{exerciseTitle}“ beträgt {bestPercent} %; {requiredPercent}% ist erforderlich.",
"blocker_final_misconfigured": "Für die Zertifizierungsübung „{exerciseTitle}“ sind keine Punkte konfiguriert; Bitten Sie Ihren Lehrer, das Problem zu beheben.", "blocker_final_misconfigured": "Für die Zertifizierungsübung „{exerciseTitle}“ sind keine Punkte konfiguriert; Bitten Sie Ihren Lehrer, das Problem zu beheben.",
"email_message_label": "E-Mail-Nachricht", "email_message_label": "E-Mail-Nachricht",
"email_message_placeholder": "z.B. Wir sind stolz auf Ihre Fortschritte – lernen Sie weiter!" "email_message_placeholder": "z.B. Wir sind stolz auf Ihre Fortschritte – lernen Sie weiter!",
"final_exercise_multiple_attempts_note": "Den Schülern sind unbegrenzte Versuche für die ausgewählte Übung gestattet."
}, },
"creator": { "creator": {
"heading": "Welchen Kurs möchten Sie erstellen?", "heading": "Welchen Kurs möchten Sie erstellen?",
@@ -2440,8 +2501,10 @@
"learner": "Student", "learner": "Student",
"progress": "Fortschritt", "progress": "Fortschritt",
"pick_up_where_you_left_off": "Machen Sie dort weiter, wo Sie aufgehört haben", "pick_up_where_you_left_off": "Machen Sie dort weiter, wo Sie aufgehört haben",
"get_your_certificate": "Hole dir dein Zertifikat",
"course_awaits_you": "{title} erwartet Sie", "course_awaits_you": "{title} erwartet Sie",
"learning_awaits_you": "Ihr nächster Kurs erwartet Sie", "learning_awaits_you": "Ihr nächster Kurs erwartet Sie",
"my_certificate": "Mein Zertifikat",
"lms_today_progress": "Ihr Lernfortschritt beträgt {progress} % – jeder Schritt zählt.", "lms_today_progress": "Ihr Lernfortschritt beträgt {progress} % – jeder Schritt zählt.",
"lms_today_empty": "Ihr Lernpfad ist fertig, wenn Sie es sind.", "lms_today_empty": "Ihr Lernpfad ist fertig, wenn Sie es sind.",
"compliance_score": "Compliance-Score", "compliance_score": "Compliance-Score",
@@ -2459,7 +2522,8 @@
"login_activity_logins": "Anmeldungen", "login_activity_logins": "Anmeldungen",
"open_academy": "Offene Akademie", "open_academy": "Offene Akademie",
"explore_more_courses": "Entdecken Sie weitere Kurse", "explore_more_courses": "Entdecken Sie weitere Kurse",
"view_more": "Mehr anzeigen" "view_more": "Mehr anzeigen",
"upcoming_sessions_heading": "Live-Sitzungen"
}, },
"schedule": { "schedule": {
"title": "Ihr Zeitplan", "title": "Ihr Zeitplan",
@@ -290,7 +290,8 @@
"common": { "common": {
"unsaved_changes": { "unsaved_changes": {
"message": "You have unsaved changes. Are you sure you want to leave?" "message": "You have unsaved changes. Are you sure you want to leave?"
} },
"refresh": "Refresh"
}, },
"compliance": { "compliance": {
"title": "Compliance", "title": "Compliance",
@@ -1665,9 +1666,12 @@
"progress": "Progress", "progress": "Progress",
"continue": "Continue", "continue": "Continue",
"continue_learning": "Continue Learning", "continue_learning": "Continue Learning",
"upcoming_sessions_heading": "Live Sessions",
"pick_up_where_you_left_off": "Pick up where you left off", "pick_up_where_you_left_off": "Pick up where you left off",
"get_your_certificate": "Get your certificate",
"course_awaits_you": "{title} awaits you", "course_awaits_you": "{title} awaits you",
"learning_awaits_you": "Your next course awaits you", "learning_awaits_you": "Your next course awaits you",
"my_certificate": "My Certificate",
"lms_today_progress": "You're {progress}% through your learning - every step counts.", "lms_today_progress": "You're {progress}% through your learning - every step counts.",
"lms_today_empty": "Your learning path is ready when you are.", "lms_today_empty": "Your learning path is ready when you are.",
"compliance_score": "Compliance Score", "compliance_score": "Compliance Score",
@@ -2116,6 +2120,7 @@
"final_exercise_label": "Final exercise (optional)", "final_exercise_label": "Final exercise (optional)",
"final_exercise_none": "none", "final_exercise_none": "none",
"final_exercise_helper": "When set, students must reach at least the minimum score on this exercise (best attempt counts) in addition to the completion percentage above.", "final_exercise_helper": "When set, students must reach at least the minimum score on this exercise (best attempt counts) in addition to the completion percentage above.",
"final_exercise_multiple_attempts_note": "Students will be allowed unlimited attempts on the selected exercise.",
"min_exercise_score_label": "Minimum score on final exercise (%)", "min_exercise_score_label": "Minimum score on final exercise (%)",
"min_exercise_score_helper": "Score is a percentage of the total points possible on that exercise (0–100).", "min_exercise_score_helper": "Score is a percentage of the total points possible on that exercise (0–100).",
"blocker_progress": "Your course progress is {current}%; {required}% is required.", "blocker_progress": "Your course progress is {current}%; {required}% is required.",
@@ -2133,7 +2138,10 @@
"title": "Course requirements complete!", "title": "Course requirements complete!",
"subtitle": "Congratulations — you can view and download your certificate anytime.", "subtitle": "Congratulations — you can view and download your certificate anytime.",
"view_certificate": "View certificate", "view_certificate": "View certificate",
"later": "Close" "later": "Close",
"checking": "Checking eligibility…",
"not_eligible_title": "Almost there",
"retake_exercise": "Retake exercise"
} }
}, },
"header": { "header": {
@@ -2233,6 +2241,41 @@
"seek_blocked": "You cannot skip ahead on this video", "seek_blocked": "You cannot skip ahead on this video",
"complete_via_watch": "This lesson completes when you watch enough of the video" "complete_via_watch": "This lesson completes when you watch enough of the video"
}, },
"session": {
"join": "Join session",
"live_indicator": "This lesson is a live session",
"heading": "Live session",
"intro": "Set the join link and scheduled time for this live class. Students get reminders 24 hours and 1 hour before.",
"link_label": "Join link (Zoom, Meet, etc.)",
"datetime_label": "Date and time",
"timezone_label": "Timezone",
"timezone_helper": "Reminders and the calendar invite use this timezone for display.",
"save": "Save session",
"saved": "Session saved",
"notified": "Students notified of the updated session",
"notify_failed": "Couldn't notify students of the change",
"confirm_title": "Notify students of the change?",
"confirm_body": "Saving will email all enrolled students about the updated session, with a new calendar invite that updates the event on their calendar.",
"confirm_cancel": "Cancel",
"confirm_save": "Save and notify",
"live_session": "Live session",
"live_now": "Live now",
"live_event": "Live Session",
"started": "Started",
"min_ago": "min ago",
"join_now": "Join now",
"copy_link": "Copy link",
"copy": "Copy",
"add_to_calendar": "Add to calendar",
"add_google": "Google Calendar",
"add_outlook": "Outlook.com",
"add_office365": "Office 365",
"add_yahoo": "Yahoo Calendar",
"add_apple": "Apple Calendar (.ics)",
"starts_in": "Starts in",
"session_ended": "Session ended",
"link_copied": "Link copied"
},
"share_your_knowledge": "Share your knowledge with the world by creating engaging lessons. Start by clicking on the Add button.", "share_your_knowledge": "Share your knowledge with the world by creating engaging lessons. Start by clicking on the Add button.",
"section_prompt": { "section_prompt": {
"cta": "Enable Sections", "cta": "Enable Sections",
@@ -2504,6 +2547,12 @@
}, },
"all_exercises": { "all_exercises": {
"heading": "All Exercise", "heading": "All Exercise",
"notify": {
"action": "Notify students",
"sending": "Notifying students…",
"sent": "Notified {count} students",
"failed": "Couldn't notify students"
},
"questions": "Questions", "questions": "Questions",
"settings": "Settings", "settings": "Settings",
"settings_allow_multiple": "Allow multiple attempts", "settings_allow_multiple": "Allow multiple attempts",
@@ -3260,7 +3309,7 @@
"add": "Add", "add": "Add",
"add_team": "Add your team mates or collaborators to your organization. Start working together", "add_team": "Add your team mates or collaborators to your organization. Start working together",
"invite": "Invite", "invite": "Invite",
"placeholder": "email comma separated", "placeholder": "user1@domain.com, user2@domain.com",
"role": "Role", "role": "Role",
"send_invite": "Send Invite", "send_invite": "Send Invite",
"members": "Members", "members": "Members",
@@ -3272,6 +3321,7 @@
"description": "Anyone with this link can join your organization.", "description": "Anyone with this link can join your organization.",
"generate": "Generate Link", "generate": "Generate Link",
"copy": "Copy", "copy": "Copy",
"copy_invite_link": "Copy invite link",
"copied": "Copied!", "copied": "Copied!",
"toggle_label": "Link invite active", "toggle_label": "Link invite active",
"disabled_notice": "This link is disabled. Enable it to allow new members to join via link.", "disabled_notice": "This link is disabled. Enable it to allow new members to join via link.",
@@ -3532,6 +3582,11 @@
"enabled": "Enabled", "enabled": "Enabled",
"order": "Order Lessons Tab", "order": "Order Lessons Tab",
"drag": "Drag and drop the labels to re-order your material tabs", "drag": "Drag and drop the labels to re-order your material tabs",
"welcome_email": {
"title": "Welcome email",
"description": "Customize the email students receive after they enroll. Add a personal note, instructions, or a live session link.",
"placeholder": "Write the welcome message students get when they enroll…"
},
"content_grouping_title": "Content grouping", "content_grouping_title": "Content grouping",
"content_grouping_description": "Group lessons and exercises by section in the content list.", "content_grouping_description": "Group lessons and exercises by section in the content list.",
"progression_mode_title": "Student progression", "progression_mode_title": "Student progression",
@@ -3550,7 +3605,8 @@
"published": "Published", "published": "Published",
"unpublished": "Unpublished", "unpublished": "Unpublished",
"type": "Course type", "type": "Course type",
"course_type_desc": "Update the course type for a more personalized experience", "course_type_desc": "Update the course type for a more personalized experience.",
"course_type_learn_more": "Learn more",
"live_class": "Live class", "live_class": "Live class",
"self_paced": "Self paced", "self_paced": "Self paced",
"compliance": "Compliance", "compliance": "Compliance",
@@ -3658,6 +3714,15 @@
"select_all": "Select all", "select_all": "Select all",
"selected_count": "{count} selected", "selected_count": "{count} selected",
"assign_courses": "Assign to Courses", "assign_courses": "Assign to Courses",
"delete": {
"action": "Delete",
"title": "Delete member",
"description": "This removes the member from your organization and revokes their access to all assigned courses and programs.",
"confirmation": "Are you sure you want to delete {name} ({email})? This action cannot be undone.",
"cancel": "Cancel",
"confirm": "Delete",
"snackbar_success": "Member deleted"
},
"user_analytics": { "user_analytics": {
"title": "Student profile", "title": "Student profile",
"page_subtitle": "Progress, grades, and activity across their courses." "page_subtitle": "Progress, grades, and activity across their courses."
@@ -677,6 +677,15 @@
"user_analytics": { "user_analytics": {
"title": "Perfil de estudiante", "title": "Perfil de estudiante",
"page_subtitle": "Progress, grades, and activity across their courses." "page_subtitle": "Progress, grades, and activity across their courses."
},
"delete": {
"action": "Eliminar",
"title": "Eliminar miembro",
"description": "Esto elimina al miembro de su organización y revoca su acceso a todos los cursos y programas asignados.",
"confirmation": "¿Está seguro de que desea eliminar {name} ({email})? Esta acción no se puede deshacer.",
"cancel": "Cancelar",
"confirm": "Eliminar",
"snackbar_success": "Miembro eliminado"
} }
}, },
"community": { "community": {
@@ -803,7 +812,13 @@
"animation_waves": "Olas", "animation_waves": "Olas",
"animation_dotted": "punteado", "animation_dotted": "punteado",
"animation_none": "Ninguno" "animation_none": "Ninguno"
} },
"welcome_email": {
"title": "Correo electrónico de bienvenida",
"description": "Personalice el correo electrónico que reciben los estudiantes después de inscribirse. Agregue una nota personal, instrucciones o un enlace de sesión en vivo.",
"placeholder": "Escribe el mensaje de bienvenida que reciben los estudiantes cuando se matriculan..."
},
"course_type_learn_more": "Más información"
}, },
"analytics": { "analytics": {
"title": "Analítica" "title": "Analítica"
@@ -1054,7 +1069,7 @@
"add": "Añadir", "add": "Añadir",
"add_team": "Agregue a sus compañeros de equipo o colaboradores a su organización. Empezar a trabajar juntos", "add_team": "Agregue a sus compañeros de equipo o colaboradores a su organización. Empezar a trabajar juntos",
"invite": "invitar", "invite": "invitar",
"placeholder": "correo electrónico separado por comas", "placeholder": "user1@domain.com, user2@domain.com",
"role": "Rol", "role": "Rol",
"send_invite": "Enviar invitación", "send_invite": "Enviar invitación",
"members": "Miembros", "members": "Miembros",
@@ -1066,6 +1081,7 @@
"description": "Cualquiera que tenga este enlace puede unirse a su organización.", "description": "Cualquiera que tenga este enlace puede unirse a su organización.",
"generate": "Generar enlace", "generate": "Generar enlace",
"copy": "Copiar", "copy": "Copiar",
"copy_invite_link": "Copiar enlace de invitación",
"copied": "¡Copiado!", "copied": "¡Copiado!",
"toggle_label": "Invitación de enlace activa", "toggle_label": "Invitación de enlace activa",
"disabled_notice": "Este enlace está deshabilitado. Habilítelo para permitir que nuevos miembros se unan a través de un enlace.", "disabled_notice": "Este enlace está deshabilitado. Habilítelo para permitir que nuevos miembros se unan a través de un enlace.",
@@ -1775,7 +1791,13 @@
"settings_completion_policy": "Regla de finalización", "settings_completion_policy": "Regla de finalización",
"settings_completion_submitted": "Enviado: cualquier envío cuenta", "settings_completion_submitted": "Enviado: cualquier envío cuenta",
"settings_completion_passed": "Aprobado: puntuación mínima requerida", "settings_completion_passed": "Aprobado: puntuación mínima requerida",
"settings_pass_threshold": "Umbral de aprobación (%)" "settings_pass_threshold": "Umbral de aprobación (%)",
"notify": {
"action": "Notificar a los estudiantes",
"sending": "Notificar a los estudiantes...",
"sent": "{count} estudiantes notificados",
"failed": "No se pudo notificar a los estudiantes"
}
}, },
"new_exercise_modal": { "new_exercise_modal": {
"heading": "Nuevo ejercicio", "heading": "Nuevo ejercicio",
@@ -2046,6 +2068,41 @@
"sections": "Secciones", "sections": "Secciones",
"lessons": "Lecciones", "lessons": "Lecciones",
"exercises": "Ejercicios" "exercises": "Ejercicios"
},
"session": {
"join": "Unirse a la sesión",
"heading": "Sesión en vivo",
"intro": "Configure el enlace para unirse y la hora programada para esta clase en vivo. Los estudiantes reciben recordatorios 24 horas y 1 hora antes.",
"link_label": "Enlace para unirse (Zoom, Meet, etc.)",
"datetime_label": "Fecha y hora",
"timezone_label": "Zona horaria",
"timezone_helper": "Los recordatorios y la invitación del calendario utilizan esta zona horaria para mostrarse.",
"save": "Guardar sesión",
"saved": "Sesión guardada",
"notified": "Estudiantes notificados de la sesión actualizada.",
"notify_failed": "No se pudo notificar a los estudiantes sobre el cambio.",
"confirm_title": "¿Notificar a los estudiantes sobre el cambio?",
"confirm_body": "Al guardar, se enviará un correo electrónico a todos los estudiantes inscritos sobre la sesión actualizada, con una nueva invitación de calendario que actualiza el evento en su calendario.",
"confirm_cancel": "Cancelar",
"confirm_save": "Guardar y notificar",
"live_indicator": "Esta lección es una sesión en vivo.",
"live_session": "Sesión en vivo",
"live_now": "En vivo ahora",
"live_event": "Sesión en vivo",
"started": "iniciado",
"min_ago": "Hace minutos",
"join_now": "Únete ahora",
"copy_link": "Copiar enlace",
"copy": "Copiar",
"add_to_calendar": "Añadir al calendario",
"add_google": "Calendario de Google",
"add_outlook": "Outlook.com",
"add_office365": "Oficina 365",
"add_yahoo": "Calendario de Yahoo",
"add_apple": "Calendario de Apple (.ics)",
"starts_in": "Comienza en",
"session_ended": "Sesión finalizada",
"link_copied": "Enlace copiado"
} }
}, },
"news_feed": { "news_feed": {
@@ -2211,7 +2268,10 @@
"title": "¡Requisitos del curso completos!", "title": "¡Requisitos del curso completos!",
"subtitle": "Felicitaciones, puede ver y descargar su certificado en cualquier momento.", "subtitle": "Felicitaciones, puede ver y descargar su certificado en cualquier momento.",
"view_certificate": "Ver certificado", "view_certificate": "Ver certificado",
"later": "Cerrar" "later": "Cerrar",
"checking": "Comprobando la elegibilidad...",
"not_eligible_title": "Casi allí",
"retake_exercise": "Rehacer ejercicio"
} }
}, },
"certificates": { "certificates": {
@@ -2236,7 +2296,8 @@
"blocker_final_score": "Su mejor puntuación en \"{exerciseTitle}\" es {bestPercent}%; {requiredPercent}% es obligatorio.", "blocker_final_score": "Su mejor puntuación en \"{exerciseTitle}\" es {bestPercent}%; {requiredPercent}% es obligatorio.",
"blocker_final_misconfigured": "El ejercicio de certificación \"{exerciseTitle}\" no tiene puntos configurados; Pídele a tu instructor que lo arregle.", "blocker_final_misconfigured": "El ejercicio de certificación \"{exerciseTitle}\" no tiene puntos configurados; Pídele a tu instructor que lo arregle.",
"email_message_label": "Mensaje de correo electrónico", "email_message_label": "Mensaje de correo electrónico",
"email_message_placeholder": "por ej. Estamos orgullosos de tu progreso: ¡sigue aprendiendo!" "email_message_placeholder": "por ej. Estamos orgullosos de tu progreso: ¡sigue aprendiendo!",
"final_exercise_multiple_attempts_note": "A los estudiantes se les permitirán intentos ilimitados en el ejercicio seleccionado."
}, },
"creator": { "creator": {
"heading": "¿Qué curso quieres crear?", "heading": "¿Qué curso quieres crear?",
@@ -2440,8 +2501,10 @@
"learner": "estudiante", "learner": "estudiante",
"progress": "Progreso", "progress": "Progreso",
"pick_up_where_you_left_off": "Continua donde lo dejaste", "pick_up_where_you_left_off": "Continua donde lo dejaste",
"get_your_certificate": "Obtén tu certificado",
"course_awaits_you": "{title} te espera", "course_awaits_you": "{title} te espera",
"learning_awaits_you": "Tu próximo curso te espera", "learning_awaits_you": "Tu próximo curso te espera",
"my_certificate": "Mi certificado",
"lms_today_progress": "Estás {progress}% en tu aprendizaje: cada paso cuenta.", "lms_today_progress": "Estás {progress}% en tu aprendizaje: cada paso cuenta.",
"lms_today_empty": "Tu camino de aprendizaje estará listo cuando tú lo estés.", "lms_today_empty": "Tu camino de aprendizaje estará listo cuando tú lo estés.",
"compliance_score": "Puntuación de cumplimiento", "compliance_score": "Puntuación de cumplimiento",
@@ -2459,7 +2522,8 @@
"login_activity_logins": "inicios de sesión", "login_activity_logins": "inicios de sesión",
"open_academy": "Academia Abierta", "open_academy": "Academia Abierta",
"explore_more_courses": "Explora más cursos", "explore_more_courses": "Explora más cursos",
"view_more": "Ver más" "view_more": "Ver más",
"upcoming_sessions_heading": "Sesiones en vivo"
}, },
"schedule": { "schedule": {
"title": "Tu horario", "title": "Tu horario",
@@ -677,6 +677,15 @@
"user_analytics": { "user_analytics": {
"title": "Profil étudiant", "title": "Profil étudiant",
"page_subtitle": "Progress, grades, and activity across their courses." "page_subtitle": "Progress, grades, and activity across their courses."
},
"delete": {
"action": "Supprimer",
"title": "Supprimer un membre",
"description": "Cela supprime le membre de votre organisation et révoque son accès à tous les cours et programmes attribués.",
"confirmation": "Êtes-vous sûr de vouloir supprimer {name} ({email}) ? Cette action ne peut pas être annulée.",
"cancel": "Annuler",
"confirm": "Supprimer",
"snackbar_success": "Membre supprimé"
} }
}, },
"community": { "community": {
@@ -803,6 +812,12 @@
"animation_waves": "Vagues", "animation_waves": "Vagues",
"animation_dotted": "Pointillé", "animation_dotted": "Pointillé",
"animation_none": "Aucun" "animation_none": "Aucun"
},
"course_type_learn_more": "En savoir plus",
"welcome_email": {
"title": "E-mail de bienvenue",
"description": "Personnalisez l'e-mail que les étudiants reçoivent après leur inscription. Ajoutez une note personnelle, des instructions ou un lien de session en direct.",
"placeholder": "Rédigez le message de bienvenue que les étudiants reçoivent lors de leur inscription…"
} }
}, },
"analytics": { "analytics": {
@@ -1054,7 +1069,7 @@
"add": "Ajouter", "add": "Ajouter",
"add_team": "Ajoutez vos coéquipiers ou collaborateurs à votre organisation. Commencez à travailler ensemble", "add_team": "Ajoutez vos coéquipiers ou collaborateurs à votre organisation. Commencez à travailler ensemble",
"invite": "Inviter", "invite": "Inviter",
"placeholder": "email séparé par des virgules", "placeholder": "user1@domain.com, user2@domain.com",
"role": "Rôle", "role": "Rôle",
"send_invite": "Envoyer une invitation", "send_invite": "Envoyer une invitation",
"members": "Membres", "members": "Membres",
@@ -1066,6 +1081,7 @@
"description": "Toute personne disposant de ce lien peut rejoindre votre organisation.", "description": "Toute personne disposant de ce lien peut rejoindre votre organisation.",
"generate": "Générer un lien", "generate": "Générer un lien",
"copy": "Copier", "copy": "Copier",
"copy_invite_link": "Copier le lien d'invitation",
"copied": "Copié!", "copied": "Copié!",
"toggle_label": "Invitation de lien active", "toggle_label": "Invitation de lien active",
"disabled_notice": "Ce lien est désactivé. Activez-le pour permettre aux nouveaux membres de rejoindre via un lien.", "disabled_notice": "Ce lien est désactivé. Activez-le pour permettre aux nouveaux membres de rejoindre via un lien.",
@@ -1775,7 +1791,13 @@
"settings_completion_policy": "Règle d'achèvement", "settings_completion_policy": "Règle d'achèvement",
"settings_completion_submitted": "Soumis – toute soumission compte", "settings_completion_submitted": "Soumis – toute soumission compte",
"settings_completion_passed": "Réussi – note minimale requise", "settings_completion_passed": "Réussi – note minimale requise",
"settings_pass_threshold": "Seuil de réussite (%)" "settings_pass_threshold": "Seuil de réussite (%)",
"notify": {
"action": "Avertir les étudiants",
"sending": "Informer les étudiants…",
"sent": "{count} étudiants notifiés",
"failed": "Impossible d'avertir les étudiants"
}
}, },
"new_exercise_modal": { "new_exercise_modal": {
"heading": "Nouvel exercice", "heading": "Nouvel exercice",
@@ -2046,6 +2068,41 @@
"sections": "Sections", "sections": "Sections",
"lessons": "Leçons", "lessons": "Leçons",
"exercises": "Exercices" "exercises": "Exercices"
},
"session": {
"join": "Rejoindre la session",
"heading": "Séance en direct",
"intro": "Définissez le lien de participation et l’heure prévue pour ce cours en direct. Les étudiants reçoivent des rappels 24 heures et 1 heure avant.",
"link_label": "Lien de participation (Zoom, Meet, etc.)",
"datetime_label": "Date et heure",
"timezone_label": "Fuseau horaire",
"timezone_helper": "Les rappels et l'invitation du calendrier utilisent ce fuseau horaire pour l'affichage.",
"save": "Enregistrer la session",
"saved": "Séance enregistrée",
"notified": "Étudiants informés de la session mise à jour",
"notify_failed": "Impossible d'informer les étudiants du changement",
"confirm_title": "Informer les étudiants du changement ?",
"confirm_body": "L'enregistrement enverra un e-mail à tous les étudiants inscrits concernant la session mise à jour, avec une nouvelle invitation de calendrier qui met à jour l'événement sur leur calendrier.",
"confirm_cancel": "Annuler",
"confirm_save": "Enregistrer et notifier",
"live_indicator": "Cette leçon est une session en direct",
"live_session": "Séance en direct",
"live_now": "En direct maintenant",
"live_event": "Séance en direct",
"started": "Commencé",
"min_ago": "il y a min",
"join_now": "Rejoindre maintenant",
"copy_link": "Copier le lien",
"copy": "Copier",
"add_to_calendar": "Ajouter au calendrier",
"add_google": "Google Agenda",
"add_outlook": "Outlook.com",
"add_office365": "Bureau 365",
"add_yahoo": "Calendrier Yahoo",
"add_apple": "Calendrier Apple (.ics)",
"starts_in": "Commence dans",
"session_ended": "Séance terminée",
"link_copied": "Lien copié"
} }
}, },
"news_feed": { "news_feed": {
@@ -2211,7 +2268,10 @@
"title": "Exigences du cours terminées !", "title": "Exigences du cours terminées !",
"subtitle": "Félicitations : vous pouvez consulter et télécharger votre certificat à tout moment.", "subtitle": "Félicitations : vous pouvez consulter et télécharger votre certificat à tout moment.",
"view_certificate": "Afficher le certificat", "view_certificate": "Afficher le certificat",
"later": "Fermer" "later": "Fermer",
"checking": "Vérification de l’éligibilité…",
"not_eligible_title": "Presque là",
"retake_exercise": "Reprendre l'exercice"
} }
}, },
"certificates": { "certificates": {
@@ -2236,7 +2296,8 @@
"blocker_final_score": "Votre meilleur score sur \"{exerciseTitle}\" est de {bestPercent} % ; {requiredPercent} % est requis.", "blocker_final_score": "Votre meilleur score sur \"{exerciseTitle}\" est de {bestPercent} % ; {requiredPercent} % est requis.",
"blocker_final_misconfigured": "L'exercice de certification « {exerciseTitle} » n'a aucun point configuré ; demandez à votre instructeur de le réparer.", "blocker_final_misconfigured": "L'exercice de certification « {exerciseTitle} » n'a aucun point configuré ; demandez à votre instructeur de le réparer.",
"email_message_label": "Message électronique", "email_message_label": "Message électronique",
"email_message_placeholder": "par ex. Nous sommes fiers de vos progrès – continuez à apprendre !" "email_message_placeholder": "par ex. Nous sommes fiers de vos progrès – continuez à apprendre !",
"final_exercise_multiple_attempts_note": "Les étudiants auront droit à des tentatives illimitées sur l'exercice sélectionné."
}, },
"creator": { "creator": {
"heading": "Quel cours souhaitez-vous créer ?", "heading": "Quel cours souhaitez-vous créer ?",
@@ -2440,8 +2501,10 @@
"learner": "Étudiant", "learner": "Étudiant",
"progress": "Progrès", "progress": "Progrès",
"pick_up_where_you_left_off": "Reprenez là où vous vous étiez arrêté", "pick_up_where_you_left_off": "Reprenez là où vous vous étiez arrêté",
"get_your_certificate": "Obtenez votre certificat",
"course_awaits_you": "{title} vous attend", "course_awaits_you": "{title} vous attend",
"learning_awaits_you": "Votre prochain cours vous attend", "learning_awaits_you": "Votre prochain cours vous attend",
"my_certificate": "Mon certificat",
"lms_today_progress": "Vous avez progressé de {progress} % dans votre apprentissage : chaque étape compte.", "lms_today_progress": "Vous avez progressé de {progress} % dans votre apprentissage : chaque étape compte.",
"lms_today_empty": "Votre parcours d’apprentissage est prêt quand vous l’êtes.", "lms_today_empty": "Votre parcours d’apprentissage est prêt quand vous l’êtes.",
"compliance_score": "Score de conformité", "compliance_score": "Score de conformité",
@@ -2459,7 +2522,8 @@
"login_activity_logins": "connexions", "login_activity_logins": "connexions",
"open_academy": "Académie ouverte", "open_academy": "Académie ouverte",
"explore_more_courses": "Explorez plus de cours", "explore_more_courses": "Explorez plus de cours",
"view_more": "Voir plus" "view_more": "Voir plus",
"upcoming_sessions_heading": "Séances en direct"
}, },
"schedule": { "schedule": {
"title": "Votre emploi du temps", "title": "Votre emploi du temps",
@@ -677,6 +677,15 @@
"user_analytics": { "user_analytics": {
"title": "छात्र प्रोफ़ाइल", "title": "छात्र प्रोफ़ाइल",
"page_subtitle": "Progress, grades, and activity across their courses." "page_subtitle": "Progress, grades, and activity across their courses."
},
"delete": {
"action": "हटाएँ",
"title": "सदस्य हटाएँ",
"description": "यह सदस्य को आपके संगठन से हटा देता है और सभी निर्दिष्ट पाठ्यक्रमों और कार्यक्रमों तक उनकी पहुंच रद्द कर देता है।",
"confirmation": "क्या आप वाकई {name} ({email}) को हटाना चाहते हैं? इस एक्शन को वापस नहीं किया जा सकता।",
"cancel": "रद्द करें",
"confirm": "हटाएँ",
"snackbar_success": "सदस्य हटा दिया गया"
} }
}, },
"community": { "community": {
@@ -803,6 +812,12 @@
"animation_waves": "लहरें", "animation_waves": "लहरें",
"animation_dotted": "बिंदीदार", "animation_dotted": "बिंदीदार",
"animation_none": "कोई नहीं" "animation_none": "कोई नहीं"
},
"course_type_learn_more": "और जानें",
"welcome_email": {
"title": "आपका स्वागत है ईमेल",
"description": "नामांकन के बाद छात्रों को प्राप्त होने वाले ईमेल को अनुकूलित करें। एक व्यक्तिगत नोट, निर्देश, या एक लाइव सत्र लिंक जोड़ें।",
"placeholder": "नामांकन के समय विद्यार्थियों को मिलने वाला स्वागत संदेश लिखें..."
} }
}, },
"analytics": { "analytics": {
@@ -1054,7 +1069,7 @@
"add": "जोड़ें", "add": "जोड़ें",
"add_team": "अपने टीम के साथियों या सहयोगियों को अपने संगठन में जोड़ें। एक साथ काम करना शुरू करें", "add_team": "अपने टीम के साथियों या सहयोगियों को अपने संगठन में जोड़ें। एक साथ काम करना शुरू करें",
"invite": "आमंत्रित करें", "invite": "आमंत्रित करें",
"placeholder": "ईमेल अल्पविराम से अलग किया गया", "placeholder": "user1@domain.com, user2@domain.com",
"role": "भूमिका", "role": "भूमिका",
"send_invite": "आमंत्रण भेजें", "send_invite": "आमंत्रण भेजें",
"members": "सदस्य", "members": "सदस्य",
@@ -1066,6 +1081,7 @@
"description": "इस लिंक वाला कोई भी व्यक्ति आपके संगठन में शामिल हो सकता है।", "description": "इस लिंक वाला कोई भी व्यक्ति आपके संगठन में शामिल हो सकता है।",
"generate": "लिंक जनरेट करें", "generate": "लिंक जनरेट करें",
"copy": "प्रतिलिपि", "copy": "प्रतिलिपि",
"copy_invite_link": "आमंत्रण लिंक कॉपी करें",
"copied": "नकल की गई!", "copied": "नकल की गई!",
"toggle_label": "लिंक आमंत्रण सक्रिय", "toggle_label": "लिंक आमंत्रण सक्रिय",
"disabled_notice": "यह लिंक अक्षम है. नए सदस्यों को लिंक के माध्यम से जुड़ने की अनुमति देने के लिए इसे सक्षम करें।", "disabled_notice": "यह लिंक अक्षम है. नए सदस्यों को लिंक के माध्यम से जुड़ने की अनुमति देने के लिए इसे सक्षम करें।",
@@ -1775,7 +1791,13 @@
"settings_completion_policy": "समापन नियम", "settings_completion_policy": "समापन नियम",
"settings_completion_submitted": "सबमिट किया गया - कोई भी सबमिशन मायने रखता है", "settings_completion_submitted": "सबमिट किया गया - कोई भी सबमिशन मायने रखता है",
"settings_completion_passed": "उत्तीर्ण - न्यूनतम अंक आवश्यक", "settings_completion_passed": "उत्तीर्ण - न्यूनतम अंक आवश्यक",
"settings_pass_threshold": "पास सीमा (%)" "settings_pass_threshold": "पास सीमा (%)",
"notify": {
"action": "छात्रों को सूचित करें",
"sending": "छात्रों को सूचित किया जा रहा है...",
"sent": "{count} छात्रों को सूचित किया गया",
"failed": "छात्रों को सूचित नहीं किया जा सका"
}
}, },
"new_exercise_modal": { "new_exercise_modal": {
"heading": "नया व्यायाम", "heading": "नया व्यायाम",
@@ -2046,6 +2068,41 @@
"sections": "अनुभाग", "sections": "अनुभाग",
"lessons": "सबक", "lessons": "सबक",
"exercises": "व्यायाम" "exercises": "व्यायाम"
},
"session": {
"join": "सत्र में शामिल हों",
"heading": "लाइव सत्र",
"intro": "इस लाइव क्लास के लिए जॉइन लिंक और निर्धारित समय निर्धारित करें। छात्रों को 24 घंटे और 1 घंटे पहले रिमाइंडर मिलते हैं।",
"link_label": "लिंक से जुड़ें (ज़ूम, मीट, आदि)",
"datetime_label": "दिनांक और समय",
"timezone_label": "समयक्षेत्र",
"timezone_helper": "अनुस्मारक और कैलेंडर आमंत्रण प्रदर्शन के लिए इस समयक्षेत्र का उपयोग करते हैं।",
"save": "सत्र सहेजें",
"saved": "सत्र सहेजा गया",
"notified": "छात्रों को अद्यतन सत्र की सूचना दी गई",
"notify_failed": "छात्रों को परिवर्तन के बारे में सूचित नहीं किया जा सका",
"confirm_title": "परिवर्तन के बारे में छात्रों को सूचित करें?",
"confirm_body": "सेविंग सभी नामांकित छात्रों को अद्यतन सत्र के बारे में एक नए कैलेंडर आमंत्रण के साथ ईमेल करेगी जो उनके कैलेंडर पर ईवेंट को अपडेट करता है।",
"confirm_cancel": "रद्द करें",
"confirm_save": "सहेजें और सूचित करें",
"live_indicator": "यह पाठ एक लाइव सत्र है",
"live_session": "लाइव सत्र",
"live_now": "अभी लाइव",
"live_event": "लाइव सत्र",
"started": "शुरू हुआ",
"min_ago": "मिनट पहले",
"join_now": "अभी शामिल हों",
"copy_link": "लिंक कॉपी करें",
"copy": "प्रतिलिपि",
"add_to_calendar": "कैलेंडर में जोड़ें",
"add_google": "गूगल कैलेंडर",
"add_outlook": "आउटलुक.कॉम",
"add_office365": "कार्यालय 365",
"add_yahoo": "याहू कैलेंडर",
"add_apple": "एप्पल कैलेंडर (.ics)",
"starts_in": "में शुरू होता है",
"session_ended": "सत्र ख़त्म हुआ",
"link_copied": "लिंक कॉपी किया गया"
} }
}, },
"news_feed": { "news_feed": {
@@ -2211,7 +2268,10 @@
"title": "पाठ्यक्रम की आवश्यकताएँ पूर्ण!", "title": "पाठ्यक्रम की आवश्यकताएँ पूर्ण!",
"subtitle": "बधाई हो - आप अपना प्रमाणपत्र कभी भी देख और डाउनलोड कर सकते हैं।", "subtitle": "बधाई हो - आप अपना प्रमाणपत्र कभी भी देख और डाउनलोड कर सकते हैं।",
"view_certificate": "प्रमाणपत्र देखें", "view_certificate": "प्रमाणपत्र देखें",
"later": "बंद करें" "later": "बंद करें",
"checking": "पात्रता की जाँच की जा रही है...",
"not_eligible_title": "लगभग वहाँ",
"retake_exercise": "पुनः व्यायाम करें"
} }
}, },
"certificates": { "certificates": {
@@ -2236,7 +2296,8 @@
"blocker_final_score": "\"{exerciseTitle}\" पर आपका सर्वोत्तम स्कोर {bestPercent}% है; {requiredPercent}% आवश्यक है.", "blocker_final_score": "\"{exerciseTitle}\" पर आपका सर्वोत्तम स्कोर {bestPercent}% है; {requiredPercent}% आवश्यक है.",
"blocker_final_misconfigured": "प्रमाणन अभ्यास \"{exerciseTitle}\" में कोई बिंदु कॉन्फ़िगर नहीं किया गया है; अपने प्रशिक्षक से इसे ठीक करने के लिए कहें।", "blocker_final_misconfigured": "प्रमाणन अभ्यास \"{exerciseTitle}\" में कोई बिंदु कॉन्फ़िगर नहीं किया गया है; अपने प्रशिक्षक से इसे ठीक करने के लिए कहें।",
"email_message_label": "ईमेल संदेश", "email_message_label": "ईमेल संदेश",
"email_message_placeholder": "जैसे हमें आपकी प्रगति पर गर्व है - सीखते रहें!" "email_message_placeholder": "जैसे हमें आपकी प्रगति पर गर्व है - सीखते रहें!",
"final_exercise_multiple_attempts_note": "छात्रों को चयनित अभ्यास पर असीमित प्रयासों की अनुमति दी जाएगी।"
}, },
"creator": { "creator": {
"heading": "आप कौन सा कोर्स बनाना चाहते हैं?", "heading": "आप कौन सा कोर्स बनाना चाहते हैं?",
@@ -2440,8 +2501,10 @@
"learner": "छात्र", "learner": "छात्र",
"progress": "प्रगति", "progress": "प्रगति",
"pick_up_where_you_left_off": "वहीं से शुरू करें जहां आपने छोड़ा था", "pick_up_where_you_left_off": "वहीं से शुरू करें जहां आपने छोड़ा था",
"get_your_certificate": "अपना प्रमाणपत्र प्राप्त करें",
"course_awaits_you": "{title} आपकी प्रतीक्षा कर रहा है", "course_awaits_you": "{title} आपकी प्रतीक्षा कर रहा है",
"learning_awaits_you": "आपका अगला कोर्स आपका इंतजार कर रहा है", "learning_awaits_you": "आपका अगला कोर्स आपका इंतजार कर रहा है",
"my_certificate": "मेरा प्रमाणपत्र",
"lms_today_progress": "आप अपने सीखने के माध्यम से {progress}% कर रहे हैं - हर कदम मायने रखता है।", "lms_today_progress": "आप अपने सीखने के माध्यम से {progress}% कर रहे हैं - हर कदम मायने रखता है।",
"lms_today_empty": "जब आप हों तो आपका सीखने का मार्ग तैयार है।", "lms_today_empty": "जब आप हों तो आपका सीखने का मार्ग तैयार है।",
"compliance_score": "अनुपालन स्कोर", "compliance_score": "अनुपालन स्कोर",
@@ -2459,7 +2522,8 @@
"login_activity_logins": "लॉगिन", "login_activity_logins": "लॉगिन",
"open_academy": "अकादमी खोलें", "open_academy": "अकादमी खोलें",
"explore_more_courses": "अधिक पाठ्यक्रम खोजें", "explore_more_courses": "अधिक पाठ्यक्रम खोजें",
"view_more": "और देखें" "view_more": "और देखें",
"upcoming_sessions_heading": "लाइव सत्र"
}, },
"schedule": { "schedule": {
"title": "आपका शेड्यूल", "title": "आपका शेड्यूल",
@@ -677,6 +677,15 @@
"user_analytics": { "user_analytics": {
"title": "Profil studenta", "title": "Profil studenta",
"page_subtitle": "Progress, grades, and activity across their courses." "page_subtitle": "Progress, grades, and activity across their courses."
},
"delete": {
"action": "Usuń",
"title": "Usuń członka",
"description": "Spowoduje to usunięcie członka z Twojej organizacji i odebranie mu dostępu do wszystkich przypisanych kursów i programów.",
"confirmation": "Czy na pewno chcesz usunąć {name} ({email})? Tej akcji nie można cofnąć.",
"cancel": "Anuluj",
"confirm": "Usuń",
"snackbar_success": "Członek usunięty"
} }
}, },
"community": { "community": {
@@ -803,6 +812,12 @@
"animation_waves": "Fale", "animation_waves": "Fale",
"animation_dotted": "Przerywana", "animation_dotted": "Przerywana",
"animation_none": "Żadne" "animation_none": "Żadne"
},
"course_type_learn_more": "Dowiedz się więcej",
"welcome_email": {
"title": "Witamy e-mail",
"description": "Dostosuj wiadomość e-mail otrzymywaną przez uczniów po zarejestrowaniu się. Dodaj osobistą notatkę, instrukcje lub link do sesji na żywo.",
"placeholder": "Napisz wiadomość powitalną, jaką otrzymają uczniowie po zarejestrowaniu się…"
} }
}, },
"analytics": { "analytics": {
@@ -1054,7 +1069,7 @@
"add": "Dodaj", "add": "Dodaj",
"add_team": "Dodaj członków zespołu lub współpracowników do swojej organizacji. Zacznijcie współpracować", "add_team": "Dodaj członków zespołu lub współpracowników do swojej organizacji. Zacznijcie współpracować",
"invite": "Zaproś", "invite": "Zaproś",
"placeholder": "e-mail oddzielony przecinkami", "placeholder": "user1@domain.com, user2@domain.com",
"role": "Rola", "role": "Rola",
"send_invite": "Wyślij zaproszenie", "send_invite": "Wyślij zaproszenie",
"members": "Członkowie", "members": "Członkowie",
@@ -1066,6 +1081,7 @@
"description": "Każda osoba mająca ten link może dołączyć do Twojej organizacji.", "description": "Każda osoba mająca ten link może dołączyć do Twojej organizacji.",
"generate": "Wygeneruj łącze", "generate": "Wygeneruj łącze",
"copy": "Kopiuj", "copy": "Kopiuj",
"copy_invite_link": "Skopiuj link zaproszenia",
"copied": "Skopiowano!", "copied": "Skopiowano!",
"toggle_label": "Zaproszenie do linku aktywne", "toggle_label": "Zaproszenie do linku aktywne",
"disabled_notice": "To łącze jest wyłączone. Włącz tę opcję, aby umożliwić nowym członkom dołączanie za pośrednictwem łącza.", "disabled_notice": "To łącze jest wyłączone. Włącz tę opcję, aby umożliwić nowym członkom dołączanie za pośrednictwem łącza.",
@@ -1775,7 +1791,13 @@
"settings_completion_policy": "Zasada uzupełnienia", "settings_completion_policy": "Zasada uzupełnienia",
"settings_completion_submitted": "Przesłane — liczy się każde przesłanie", "settings_completion_submitted": "Przesłane — liczy się każde przesłanie",
"settings_completion_passed": "Zaliczony — wymagany minimalny wynik", "settings_completion_passed": "Zaliczony — wymagany minimalny wynik",
"settings_pass_threshold": "Próg zaliczenia (%)" "settings_pass_threshold": "Próg zaliczenia (%)",
"notify": {
"action": "Powiadom uczniów",
"sending": "Powiadamianie uczniów…",
"sent": "Powiadomiono {count} uczniów",
"failed": "Nie udało się powiadomić uczniów"
}
}, },
"new_exercise_modal": { "new_exercise_modal": {
"heading": "Nowe ćwiczenie", "heading": "Nowe ćwiczenie",
@@ -2046,6 +2068,41 @@
"sections": "Sekcje", "sections": "Sekcje",
"lessons": "Lekcje", "lessons": "Lekcje",
"exercises": "Ćwiczenia" "exercises": "Ćwiczenia"
},
"session": {
"join": "Dołącz do sesji",
"heading": "Sesja na żywo",
"intro": "Ustaw link dołączenia i zaplanowany czas dla tych zajęć na żywo. Uczniowie otrzymują przypomnienia 24 godziny i 1 godzinę wcześniej.",
"link_label": "Dołącz link (Zoom, Meet itp.)",
"datetime_label": "Data i godzina",
"timezone_label": "Strefa czasowa",
"timezone_helper": "Przypomnienia i zaproszenia z kalendarza korzystają z tej strefy czasowej do wyświetlania.",
"save": "Zapisz sesję",
"saved": "Sesja zapisana",
"notified": "Studenci zostali powiadomieni o zaktualizowanej sesji",
"notify_failed": "Nie udało się powiadomić uczniów o zmianie",
"confirm_title": "Powiadomić uczniów o zmianie?",
"confirm_body": "Po zapisaniu do wszystkich zapisanych uczniów zostanie wysłana wiadomość e-mail z informacją o zaktualizowanej sesji oraz nowym zaproszeniem do kalendarza, które zaktualizuje wydarzenie w ich kalendarzu.",
"confirm_cancel": "Anuluj",
"confirm_save": "Zapisz i powiadom",
"live_indicator": "Ta lekcja to sesja na żywo",
"live_session": "Sesja na żywo",
"live_now": "Na żywo teraz",
"live_event": "Sesja na żywo",
"started": "Rozpoczęło się",
"min_ago": "minutę temu",
"join_now": "Dołącz teraz",
"copy_link": "Skopiuj link",
"copy": "Kopiuj",
"add_to_calendar": "Dodaj do kalendarza",
"add_google": "Kalendarz Google",
"add_outlook": "Outlook.com",
"add_office365": "Biuro 365",
"add_yahoo": "Kalendarz Yahoo",
"add_apple": "Kalendarz Apple (.ics)",
"starts_in": "Zaczyna się w",
"session_ended": "Sesja zakończona",
"link_copied": "Link skopiowany"
} }
}, },
"news_feed": { "news_feed": {
@@ -2211,7 +2268,10 @@
"title": "Wymagania dotyczące kursu zostały spełnione!", "title": "Wymagania dotyczące kursu zostały spełnione!",
"subtitle": "Gratulacje — możesz wyświetlić i pobrać swój certyfikat w dowolnym momencie.", "subtitle": "Gratulacje — możesz wyświetlić i pobrać swój certyfikat w dowolnym momencie.",
"view_certificate": "Zobacz certyfikat", "view_certificate": "Zobacz certyfikat",
"later": "Zamknij" "later": "Zamknij",
"checking": "Sprawdzam uprawnienia…",
"not_eligible_title": "Prawie tam",
"retake_exercise": "Powtórz ćwiczenie"
} }
}, },
"certificates": { "certificates": {
@@ -2236,7 +2296,8 @@
"blocker_final_score": "Twój najlepszy wynik w „{exerciseTitle}” to {bestPercent}%; Wymagany jest {requiredPercent}%.", "blocker_final_score": "Twój najlepszy wynik w „{exerciseTitle}” to {bestPercent}%; Wymagany jest {requiredPercent}%.",
"blocker_final_misconfigured": "W ćwiczeniu certyfikacyjnym „{exerciseTitle}” nie skonfigurowano żadnych punktów; poproś instruktora, żeby to naprawił.", "blocker_final_misconfigured": "W ćwiczeniu certyfikacyjnym „{exerciseTitle}” nie skonfigurowano żadnych punktów; poproś instruktora, żeby to naprawił.",
"email_message_label": "Wiadomość e-mail", "email_message_label": "Wiadomość e-mail",
"email_message_placeholder": "np. Jesteśmy dumni z Twoich postępów — ucz się dalej!" "email_message_placeholder": "np. Jesteśmy dumni z Twoich postępów — ucz się dalej!",
"final_exercise_multiple_attempts_note": "Studenci będą mieli nieograniczoną liczbę prób wykonania wybranego ćwiczenia."
}, },
"creator": { "creator": {
"heading": "Jaki kurs chcesz stworzyć?", "heading": "Jaki kurs chcesz stworzyć?",
@@ -2440,8 +2501,10 @@
"learner": "Studenta", "learner": "Studenta",
"progress": "Postęp", "progress": "Postęp",
"pick_up_where_you_left_off": "Rozpocznij od miejsca, w którym skończyłeś", "pick_up_where_you_left_off": "Rozpocznij od miejsca, w którym skończyłeś",
"get_your_certificate": "Odbierz swój certyfikat",
"course_awaits_you": "{title} czeka na Ciebie", "course_awaits_you": "{title} czeka na Ciebie",
"learning_awaits_you": "Kolejny kurs już na Ciebie czeka", "learning_awaits_you": "Kolejny kurs już na Ciebie czeka",
"my_certificate": "Mój certyfikat",
"lms_today_progress": "Twój postęp w nauce wynosi {progress}% – każdy krok się liczy.", "lms_today_progress": "Twój postęp w nauce wynosi {progress}% – każdy krok się liczy.",
"lms_today_empty": "Twoja ścieżka edukacyjna jest gotowa, gdy Ty jesteś.", "lms_today_empty": "Twoja ścieżka edukacyjna jest gotowa, gdy Ty jesteś.",
"compliance_score": "Wynik zgodności", "compliance_score": "Wynik zgodności",
@@ -2459,7 +2522,8 @@
"login_activity_logins": "logowania", "login_activity_logins": "logowania",
"open_academy": "Otwarta Akademia", "open_academy": "Otwarta Akademia",
"explore_more_courses": "Przeglądaj więcej kursów", "explore_more_courses": "Przeglądaj więcej kursów",
"view_more": "Zobacz więcej" "view_more": "Zobacz więcej",
"upcoming_sessions_heading": "Sesje na żywo"
}, },
"schedule": { "schedule": {
"title": "Twój harmonogram", "title": "Twój harmonogram",
@@ -677,6 +677,15 @@
"user_analytics": { "user_analytics": {
"title": "Perfil do aluno", "title": "Perfil do aluno",
"page_subtitle": "Progress, grades, and activity across their courses." "page_subtitle": "Progress, grades, and activity across their courses."
},
"delete": {
"action": "Excluir",
"title": "Excluir membro",
"description": "Isso remove o membro da sua organização e revoga seu acesso a todos os cursos e programas atribuídos.",
"confirmation": "Tem certeza de que deseja excluir {name} ({email})? Esta ação não pode ser desfeita.",
"cancel": "Cancelar",
"confirm": "Excluir",
"snackbar_success": "Membro excluído"
} }
}, },
"community": { "community": {
@@ -803,6 +812,12 @@
"animation_waves": "Ondas", "animation_waves": "Ondas",
"animation_dotted": "Pontilhado", "animation_dotted": "Pontilhado",
"animation_none": "Nenhum" "animation_none": "Nenhum"
},
"course_type_learn_more": "Saiba mais",
"welcome_email": {
"title": "E-mail de boas-vindas",
"description": "Personalize o e-mail que os alunos recebem após a inscrição. Adicione uma nota pessoal, instruções ou um link de sessão ao vivo.",
"placeholder": "Escreva a mensagem de boas-vindas que os alunos recebem quando se matriculam…"
} }
}, },
"analytics": { "analytics": {
@@ -1054,7 +1069,7 @@
"add": "Adicionar", "add": "Adicionar",
"add_team": "Adicione seus companheiros de equipe ou colaboradores à sua organização. Comece a trabalhar juntos", "add_team": "Adicione seus companheiros de equipe ou colaboradores à sua organização. Comece a trabalhar juntos",
"invite": "Convidar", "invite": "Convidar",
"placeholder": "e-mail separado por vírgula", "placeholder": "user1@domain.com, user2@domain.com",
"role": "Função", "role": "Função",
"send_invite": "Enviar convite", "send_invite": "Enviar convite",
"members": "Membros", "members": "Membros",
@@ -1066,6 +1081,7 @@
"description": "Qualquer pessoa com este link pode ingressar na sua organização.", "description": "Qualquer pessoa com este link pode ingressar na sua organização.",
"generate": "Gerar link", "generate": "Gerar link",
"copy": "Copiar", "copy": "Copiar",
"copy_invite_link": "Copiar link de convite",
"copied": "Copiado!", "copied": "Copiado!",
"toggle_label": "Link de convite ativo", "toggle_label": "Link de convite ativo",
"disabled_notice": "Este link está desativado. Habilite-o para permitir que novos membros participem via link.", "disabled_notice": "Este link está desativado. Habilite-o para permitir que novos membros participem via link.",
@@ -1775,7 +1791,13 @@
"settings_completion_policy": "Regra de conclusão", "settings_completion_policy": "Regra de conclusão",
"settings_completion_submitted": "Enviado – qualquer envio conta", "settings_completion_submitted": "Enviado – qualquer envio conta",
"settings_completion_passed": "Aprovado – pontuação mínima exigida", "settings_completion_passed": "Aprovado – pontuação mínima exigida",
"settings_pass_threshold": "Limite de aprovação (%)" "settings_pass_threshold": "Limite de aprovação (%)",
"notify": {
"action": "Notificar os alunos",
"sending": "Notificando os alunos…",
"sent": "{count} alunos notificados",
"failed": "Não foi possível notificar os alunos"
}
}, },
"new_exercise_modal": { "new_exercise_modal": {
"heading": "Novo exercício", "heading": "Novo exercício",
@@ -2046,6 +2068,41 @@
"sections": "Seções", "sections": "Seções",
"lessons": "Lições", "lessons": "Lições",
"exercises": "Exercícios" "exercises": "Exercícios"
},
"session": {
"join": "Entrar na sessão",
"heading": "Sessão ao vivo",
"intro": "Defina o link de participação e o horário agendado para esta aula ao vivo. Os alunos recebem lembretes 24 horas e 1 hora antes.",
"link_label": "Link de adesão (Zoom, Meet, etc.)",
"datetime_label": "Data e hora",
"timezone_label": "Fuso horário",
"timezone_helper": "Os lembretes e o convite do calendário usam esse fuso horário para exibição.",
"save": "Salvar sessão",
"saved": "Sessão salva",
"notified": "Alunos notificados sobre a sessão atualizada",
"notify_failed": "Não foi possível notificar os alunos sobre a mudança",
"confirm_title": "Notificar os alunos sobre a mudança?",
"confirm_body": "Salvar enviará um e-mail a todos os alunos inscritos sobre a sessão atualizada, com um novo convite de calendário que atualiza o evento em seus calendários.",
"confirm_cancel": "Cancelar",
"confirm_save": "Salvar e notificar",
"live_indicator": "Esta lição é uma sessão ao vivo",
"live_session": "Sessão ao vivo",
"live_now": "Ao vivo agora",
"live_event": "Sessão ao vivo",
"started": "Iniciado",
"min_ago": "minutos atrás",
"join_now": "Entrar agora",
"copy_link": "Copiar link",
"copy": "Copiar",
"add_to_calendar": "Adicionar ao calendário",
"add_google": "Google Agenda",
"add_outlook": "Outlook.com",
"add_office365": "Escritório 365",
"add_yahoo": "Calendário do Yahoo",
"add_apple": "Calendário Apple (.ics)",
"starts_in": "Começa em",
"session_ended": "Sessão encerrada",
"link_copied": "Link copiado"
} }
}, },
"news_feed": { "news_feed": {
@@ -2211,7 +2268,10 @@
"title": "Requisitos do curso concluídos!", "title": "Requisitos do curso concluídos!",
"subtitle": "Parabéns – você pode visualizar e baixar seu certificado a qualquer momento.", "subtitle": "Parabéns – você pode visualizar e baixar seu certificado a qualquer momento.",
"view_certificate": "Ver certificado", "view_certificate": "Ver certificado",
"later": "Fechar" "later": "Fechar",
"checking": "Verificando elegibilidade…",
"not_eligible_title": "Quase lá",
"retake_exercise": "Refazer o exercício"
} }
}, },
"certificates": { "certificates": {
@@ -2236,7 +2296,8 @@
"blocker_final_score": "Sua melhor pontuação em \"{exerciseTitle}\" é {bestPercent}%; {requiredPercent}% é obrigatório.", "blocker_final_score": "Sua melhor pontuação em \"{exerciseTitle}\" é {bestPercent}%; {requiredPercent}% é obrigatório.",
"blocker_final_misconfigured": "O exercício de certificação \"{exerciseTitle}\" não possui pontos configurados; peça ao seu instrutor para consertar.", "blocker_final_misconfigured": "O exercício de certificação \"{exerciseTitle}\" não possui pontos configurados; peça ao seu instrutor para consertar.",
"email_message_label": "Mensagem de e-mail", "email_message_label": "Mensagem de e-mail",
"email_message_placeholder": "por exemplo Estamos orgulhosos do seu progresso – continue aprendendo!" "email_message_placeholder": "por exemplo Estamos orgulhosos do seu progresso – continue aprendendo!",
"final_exercise_multiple_attempts_note": "Os alunos terão permissão para tentativas ilimitadas no exercício selecionado."
}, },
"creator": { "creator": {
"heading": "Qual curso você deseja criar?", "heading": "Qual curso você deseja criar?",
@@ -2440,8 +2501,10 @@
"learner": "Estudante", "learner": "Estudante",
"progress": "Progresso", "progress": "Progresso",
"pick_up_where_you_left_off": "Continue de onde parou", "pick_up_where_you_left_off": "Continue de onde parou",
"get_your_certificate": "Obtenha seu certificado",
"course_awaits_you": "{title} espera por você", "course_awaits_you": "{title} espera por você",
"learning_awaits_you": "Seu próximo curso espera por você", "learning_awaits_you": "Seu próximo curso espera por você",
"my_certificate": "Meu certificado",
"lms_today_progress": "Você está {progress}% em seu aprendizado - cada etapa conta.", "lms_today_progress": "Você está {progress}% em seu aprendizado - cada etapa conta.",
"lms_today_empty": "Seu caminho de aprendizagem estará pronto quando você estiver.", "lms_today_empty": "Seu caminho de aprendizagem estará pronto quando você estiver.",
"compliance_score": "Pontuação de Conformidade", "compliance_score": "Pontuação de Conformidade",
@@ -2459,7 +2522,8 @@
"login_activity_logins": "logins", "login_activity_logins": "logins",
"open_academy": "Academia Aberta", "open_academy": "Academia Aberta",
"explore_more_courses": "Explorar mais cursos", "explore_more_courses": "Explorar mais cursos",
"view_more": "Ver mais" "view_more": "Ver mais",
"upcoming_sessions_heading": "Sessões ao vivo"
}, },
"schedule": { "schedule": {
"title": "Sua programação", "title": "Sua programação",
@@ -677,6 +677,15 @@
"user_analytics": { "user_analytics": {
"title": "Профиль студента", "title": "Профиль студента",
"page_subtitle": "Progress, grades, and activity across their courses." "page_subtitle": "Progress, grades, and activity across their courses."
},
"delete": {
"action": "Удалить",
"title": "Удалить участника",
"description": "Это приведет к удалению участника из вашей организации и аннулированию его доступа ко всем назначенным курсам и программам.",
"confirmation": "Вы уверены, что хотите удалить {name} ({email})? Это действие невозможно отменить.",
"cancel": "Отмена",
"confirm": "Удалить",
"snackbar_success": "Участник удален"
} }
}, },
"community": { "community": {
@@ -803,6 +812,12 @@
"animation_waves": "Волны", "animation_waves": "Волны",
"animation_dotted": "Пунктирный", "animation_dotted": "Пунктирный",
"animation_none": "Нет" "animation_none": "Нет"
},
"course_type_learn_more": "Узнать больше",
"welcome_email": {
"title": "Приветственное письмо",
"description": "Настройте электронное письмо, которое студенты получат после регистрации. Добавьте личную заметку, инструкции или ссылку на сеанс в реальном времени.",
"placeholder": "Напишите приветственное сообщение, которое студенты получат при зачислении…"
} }
}, },
"analytics": { "analytics": {
@@ -1054,7 +1069,7 @@
"add": "Добавить", "add": "Добавить",
"add_team": "Добавьте своих товарищей по команде или сотрудников в свою организацию. Начните работать вместе", "add_team": "Добавьте своих товарищей по команде или сотрудников в свою организацию. Начните работать вместе",
"invite": "Пригласить", "invite": "Пригласить",
"placeholder": "электронная почта, разделенная запятой", "placeholder": "user1@domain.com, user2@domain.com",
"role": "Роль", "role": "Роль",
"send_invite": "Отправить приглашение", "send_invite": "Отправить приглашение",
"members": "Члены", "members": "Члены",
@@ -1066,6 +1081,7 @@
"description": "Любой, у кого есть эта ссылка, может присоединиться к вашей организации.", "description": "Любой, у кого есть эта ссылка, может присоединиться к вашей организации.",
"generate": "Создать ссылку", "generate": "Создать ссылку",
"copy": "Копировать", "copy": "Копировать",
"copy_invite_link": "Скопировать ссылку приглашения",
"copied": "Скопировано!", "copied": "Скопировано!",
"toggle_label": "Приглашение по ссылке активно", "toggle_label": "Приглашение по ссылке активно",
"disabled_notice": "Эта ссылка отключена. Включите его, чтобы позволить новым участникам присоединяться по ссылке.", "disabled_notice": "Эта ссылка отключена. Включите его, чтобы позволить новым участникам присоединяться по ссылке.",
@@ -1775,7 +1791,13 @@
"settings_completion_policy": "Правило завершения", "settings_completion_policy": "Правило завершения",
"settings_completion_submitted": "Отправлено — любая отправка засчитывается", "settings_completion_submitted": "Отправлено — любая отправка засчитывается",
"settings_completion_passed": "Пройдено — требуется минимальный балл", "settings_completion_passed": "Пройдено — требуется минимальный балл",
"settings_pass_threshold": "Порог прохождения (%)" "settings_pass_threshold": "Порог прохождения (%)",
"notify": {
"action": "Уведомить студентов",
"sending": "Уведомление учащихся…",
"sent": "Уведомлено учащихся: {count}",
"failed": "Не удалось уведомить студентов"
}
}, },
"new_exercise_modal": { "new_exercise_modal": {
"heading": "Новое упражнение", "heading": "Новое упражнение",
@@ -2046,6 +2068,41 @@
"sections": "Разделы", "sections": "Разделы",
"lessons": "Уроки", "lessons": "Уроки",
"exercises": "Упражнения" "exercises": "Упражнения"
},
"session": {
"join": "Присоединиться к сеансу",
"heading": "Живая сессия",
"intro": "Установите ссылку для присоединения и запланированное время для этого живого урока. Студенты получают напоминания за 24 часа и за 1 час.",
"link_label": "Ссылка для присоединения (Zoom, Meet и т. д.)",
"datetime_label": "Дата и время",
"timezone_label": "Часовой пояс",
"timezone_helper": "Напоминания и приглашения в календаре используют этот часовой пояс для отображения.",
"save": "Сохранить сеанс",
"saved": "Сеанс сохранен.",
"notified": "Студенты уведомлены об обновленной сессии",
"notify_failed": "Не удалось уведомить учащихся об изменении.",
"confirm_title": "Уведомить студентов об изменении?",
"confirm_body": "При сохранении всем зачисленным учащимся будет отправлено электронное письмо об обновленном сеансе с новым приглашением в календаре, которое обновит событие в их календаре.",
"confirm_cancel": "Отмена",
"confirm_save": "Сохранить и уведомить",
"live_indicator": "Этот урок представляет собой живое занятие",
"live_session": "Живая сессия",
"live_now": "В прямом эфире",
"live_event": "Живая сессия",
"started": "Началось",
"min_ago": "минуту назад",
"join_now": "Присоединяйтесь сейчас",
"copy_link": "Скопировать ссылку",
"copy": "Копировать",
"add_to_calendar": "Добавить в календарь",
"add_google": "Google Календарь",
"add_outlook": "Outlook.com",
"add_office365": "Офис 365",
"add_yahoo": "Yahoo-календарь",
"add_apple": "Календарь Apple (.ics)",
"starts_in": "Начинается через",
"session_ended": "Сессия завершена",
"link_copied": "Ссылка скопирована"
} }
}, },
"news_feed": { "news_feed": {
@@ -2211,7 +2268,10 @@
"title": "Требования курса выполнены!", "title": "Требования курса выполнены!",
"subtitle": "Поздравляем — вы можете просмотреть и скачать свой сертификат в любое время.", "subtitle": "Поздравляем — вы можете просмотреть и скачать свой сертификат в любое время.",
"view_certificate": "Посмотреть сертификат", "view_certificate": "Посмотреть сертификат",
"later": "Закрыть" "later": "Закрыть",
"checking": "Проверка пригодности…",
"not_eligible_title": "Почти там",
"retake_exercise": "Пересдать упражнение"
} }
}, },
"certificates": { "certificates": {
@@ -2236,7 +2296,8 @@
"blocker_final_score": "Ваш лучший результат в «{exerciseTitle}» — {bestPercent}%; Требуется {requiredPercent}%.", "blocker_final_score": "Ваш лучший результат в «{exerciseTitle}» — {bestPercent}%; Требуется {requiredPercent}%.",
"blocker_final_misconfigured": "Для сертификационного упражнения «{exerciseTitle}» не настроены баллы; попросите инструктора это исправить.", "blocker_final_misconfigured": "Для сертификационного упражнения «{exerciseTitle}» не настроены баллы; попросите инструктора это исправить.",
"email_message_label": "Сообщение электронной почты", "email_message_label": "Сообщение электронной почты",
"email_message_placeholder": "например Мы гордимся вашим прогрессом — продолжайте учиться!" "email_message_placeholder": "например Мы гордимся вашим прогрессом — продолжайте учиться!",
"final_exercise_multiple_attempts_note": "Студентам будет разрешено неограниченное количество попыток выполнения выбранного упражнения."
}, },
"creator": { "creator": {
"heading": "Какой курс вы хотите создать?", "heading": "Какой курс вы хотите создать?",
@@ -2440,8 +2501,10 @@
"learner": "Студент", "learner": "Студент",
"progress": "Прогресс", "progress": "Прогресс",
"pick_up_where_you_left_off": "Продолжайте с того места, где вы остановились", "pick_up_where_you_left_off": "Продолжайте с того места, где вы остановились",
"get_your_certificate": "Получите свой сертификат",
"course_awaits_you": "{title} ждет вас", "course_awaits_you": "{title} ждет вас",
"learning_awaits_you": "Следующий курс ждет вас", "learning_awaits_you": "Следующий курс ждет вас",
"my_certificate": "Мой сертификат",
"lms_today_progress": "Ваш прогресс в обучении – {progress} %. Каждый шаг имеет значение.", "lms_today_progress": "Ваш прогресс в обучении – {progress} %. Каждый шаг имеет значение.",
"lms_today_empty": "Ваш путь обучения готов, когда вы будете готовы.", "lms_today_empty": "Ваш путь обучения готов, когда вы будете готовы.",
"compliance_score": "Оценка соответствия", "compliance_score": "Оценка соответствия",
@@ -2459,7 +2522,8 @@
"login_activity_logins": "логины", "login_activity_logins": "логины",
"open_academy": "Открытая Академия", "open_academy": "Открытая Академия",
"explore_more_courses": "Ознакомьтесь с другими курсами", "explore_more_courses": "Ознакомьтесь с другими курсами",
"view_more": "Посмотреть больше" "view_more": "Посмотреть больше",
"upcoming_sessions_heading": "Живые сессии"
}, },
"schedule": { "schedule": {
"title": "Ваше расписание", "title": "Ваше расписание",
@@ -677,6 +677,15 @@
"user_analytics": { "user_analytics": {
"title": "Hồ sơ sinh viên", "title": "Hồ sơ sinh viên",
"page_subtitle": "Progress, grades, and activity across their courses." "page_subtitle": "Progress, grades, and activity across their courses."
},
"delete": {
"action": "Xóa",
"title": "Xóa thành viên",
"description": "Thao tác này sẽ xóa thành viên khỏi tổ chức của bạn và thu hồi quyền truy cập của họ vào tất cả các khóa học và chương trình được chỉ định.",
"confirmation": "Bạn có chắc chắn muốn xóa {name} ({email}) không? Không thể hoàn tác hành động này.",
"cancel": "Hủy bỏ",
"confirm": "Xóa",
"snackbar_success": "Thành viên đã bị xóa"
} }
}, },
"community": { "community": {
@@ -803,6 +812,12 @@
"animation_waves": "Sóng", "animation_waves": "Sóng",
"animation_dotted": "Chấm", "animation_dotted": "Chấm",
"animation_none": "không có" "animation_none": "không có"
},
"course_type_learn_more": "Tìm hiểu thêm",
"welcome_email": {
"title": "Email chào mừng",
"description": "Tùy chỉnh email sinh viên nhận được sau khi đăng ký. Thêm ghi chú cá nhân, hướng dẫn hoặc liên kết phiên trực tiếp.",
"placeholder": "Viết lời chào mừng mà học viên nhận được khi đăng ký…"
} }
}, },
"analytics": { "analytics": {
@@ -1054,7 +1069,7 @@
"add": "Thêm", "add": "Thêm",
"add_team": "Thêm đồng đội hoặc cộng tác viên vào tổ chức của bạn. Bắt đầu làm việc cùng nhau", "add_team": "Thêm đồng đội hoặc cộng tác viên vào tổ chức của bạn. Bắt đầu làm việc cùng nhau",
"invite": "Mời", "invite": "Mời",
"placeholder": "email được phân tách bằng dấu phẩy", "placeholder": "user1@domain.com, user2@domain.com",
"role": "Vai trò", "role": "Vai trò",
"send_invite": "Gửi lời mời", "send_invite": "Gửi lời mời",
"members": "Thành viên", "members": "Thành viên",
@@ -1066,6 +1081,7 @@
"description": "Bất kỳ ai có liên kết này đều có thể tham gia tổ chức của bạn.", "description": "Bất kỳ ai có liên kết này đều có thể tham gia tổ chức của bạn.",
"generate": "Tạo liên kết", "generate": "Tạo liên kết",
"copy": "Sao chép", "copy": "Sao chép",
"copy_invite_link": "Sao chép liên kết mời",
"copied": "Đã sao chép!", "copied": "Đã sao chép!",
"toggle_label": "Liên kết lời mời đang hoạt động", "toggle_label": "Liên kết lời mời đang hoạt động",
"disabled_notice": "Liên kết này bị vô hiệu hóa. Kích hoạt nó để cho phép thành viên mới tham gia thông qua liên kết.", "disabled_notice": "Liên kết này bị vô hiệu hóa. Kích hoạt nó để cho phép thành viên mới tham gia thông qua liên kết.",
@@ -1775,7 +1791,13 @@
"settings_completion_policy": "Quy tắc hoàn thành", "settings_completion_policy": "Quy tắc hoàn thành",
"settings_completion_submitted": "Đã gửi - mọi lần gửi đều được tính", "settings_completion_submitted": "Đã gửi - mọi lần gửi đều được tính",
"settings_completion_passed": "Đạt - yêu cầu điểm tối thiểu", "settings_completion_passed": "Đạt - yêu cầu điểm tối thiểu",
"settings_pass_threshold": "Ngưỡng vượt qua (%)" "settings_pass_threshold": "Ngưỡng vượt qua (%)",
"notify": {
"action": "Thông báo cho sinh viên",
"sending": "Thông báo cho học sinh…",
"sent": "Đã thông báo cho {count} sinh viên",
"failed": "Không thể thông báo cho sinh viên"
}
}, },
"new_exercise_modal": { "new_exercise_modal": {
"heading": "Bài tập mới", "heading": "Bài tập mới",
@@ -2046,6 +2068,41 @@
"sections": "Phần", "sections": "Phần",
"lessons": "Bài học", "lessons": "Bài học",
"exercises": "Bài tập" "exercises": "Bài tập"
},
"session": {
"join": "Tham gia phiên",
"heading": "Phiên trực tiếp",
"intro": "Đặt liên kết tham gia và thời gian dự kiến cho lớp học trực tiếp này. Học sinh nhận được lời nhắc 24 giờ và 1 giờ trước.",
"link_label": "Liên kết tham gia (Zoom, Meet, v.v.)",
"datetime_label": "Ngày và giờ",
"timezone_label": "Múi giờ",
"timezone_helper": "Lời nhắc và lời mời lịch sử dụng múi giờ này để hiển thị.",
"save": "Lưu phiên",
"saved": "Đã lưu phiên",
"notified": "Sinh viên được thông báo về phiên cập nhật",
"notify_failed": "Không thể thông báo cho sinh viên về sự thay đổi",
"confirm_title": "Thông báo cho sinh viên về sự thay đổi?",
"confirm_body": "Việc lưu sẽ gửi email cho tất cả sinh viên đã đăng ký về phiên cập nhật, kèm theo lời mời lịch mới cập nhật sự kiện trên lịch của họ.",
"confirm_cancel": "Hủy bỏ",
"confirm_save": "Lưu và thông báo",
"live_indicator": "Bài học này là một buổi học trực tiếp",
"live_session": "Phiên trực tiếp",
"live_now": "Đang trực tiếp",
"live_event": "Phiên trực tiếp",
"started": "Đã bắt đầu",
"min_ago": "phút trước",
"join_now": "Tham gia ngay bây giờ",
"copy_link": "Sao chép liên kết",
"copy": "Sao chép",
"add_to_calendar": "Thêm vào lịch",
"add_google": "Lịch Google",
"add_outlook": "Outlook.com",
"add_office365": "Văn phòng 365",
"add_yahoo": "Lịch Yahoo",
"add_apple": "Lịch Apple (.ics)",
"starts_in": "Bắt đầu vào",
"session_ended": "Phiên kết thúc",
"link_copied": "Đã sao chép liên kết"
} }
}, },
"news_feed": { "news_feed": {
@@ -2211,7 +2268,10 @@
"title": "Yêu cầu khóa học đã hoàn tất!", "title": "Yêu cầu khóa học đã hoàn tất!",
"subtitle": "Xin chúc mừng — bạn có thể xem và tải xuống chứng chỉ của mình bất kỳ lúc nào.", "subtitle": "Xin chúc mừng — bạn có thể xem và tải xuống chứng chỉ của mình bất kỳ lúc nào.",
"view_certificate": "Xem chứng chỉ", "view_certificate": "Xem chứng chỉ",
"later": "Đóng" "later": "Đóng",
"checking": "Đang kiểm tra tính đủ điều kiện…",
"not_eligible_title": "Gần như ở đó",
"retake_exercise": "Làm lại bài tập"
} }
}, },
"certificates": { "certificates": {
@@ -2236,7 +2296,8 @@
"blocker_final_score": "Điểm cao nhất của bạn trong \"{exerciseTitle}\" là {bestPercent}%; {requiredPercent}% là bắt buộc.", "blocker_final_score": "Điểm cao nhất của bạn trong \"{exerciseTitle}\" là {bestPercent}%; {requiredPercent}% là bắt buộc.",
"blocker_final_misconfigured": "Bài tập chứng nhận \"{exerciseTitle}\" không có cấu hình điểm nào; nhờ người hướng dẫn khắc phục.", "blocker_final_misconfigured": "Bài tập chứng nhận \"{exerciseTitle}\" không có cấu hình điểm nào; nhờ người hướng dẫn khắc phục.",
"email_message_label": "Tin nhắn email", "email_message_label": "Tin nhắn email",
"email_message_placeholder": "ví dụ: Chúng tôi tự hào về sự tiến bộ của bạn - hãy tiếp tục học hỏi!" "email_message_placeholder": "ví dụ: Chúng tôi tự hào về sự tiến bộ của bạn - hãy tiếp tục học hỏi!",
"final_exercise_multiple_attempts_note": "Học sinh sẽ được phép thử không giới hạn bài tập đã chọn."
}, },
"creator": { "creator": {
"heading": "Bạn muốn tạo khóa học nào?", "heading": "Bạn muốn tạo khóa học nào?",
@@ -2440,8 +2501,10 @@
"learner": "sinh viên", "learner": "sinh viên",
"progress": "Tiến độ", "progress": "Tiến độ",
"pick_up_where_you_left_off": "Tiếp tục nơi bạn đã dừng lại", "pick_up_where_you_left_off": "Tiếp tục nơi bạn đã dừng lại",
"get_your_certificate": "Nhận chứng chỉ của bạn",
"course_awaits_you": "{title} đang chờ bạn", "course_awaits_you": "{title} đang chờ bạn",
"learning_awaits_you": "Khóa học tiếp theo đang chờ bạn", "learning_awaits_you": "Khóa học tiếp theo đang chờ bạn",
"my_certificate": "Chứng chỉ của tôi",
"lms_today_progress": "Bạn đã đạt được {progress}% trong quá trình học tập - mỗi bước đều có giá trị.", "lms_today_progress": "Bạn đã đạt được {progress}% trong quá trình học tập - mỗi bước đều có giá trị.",
"lms_today_empty": "Con đường học tập của bạn đã sẵn sàng khi bạn sẵn sàng.", "lms_today_empty": "Con đường học tập của bạn đã sẵn sàng khi bạn sẵn sàng.",
"compliance_score": "Điểm tuân thủ", "compliance_score": "Điểm tuân thủ",
@@ -2459,7 +2522,8 @@
"login_activity_logins": "thông tin đăng nhập", "login_activity_logins": "thông tin đăng nhập",
"open_academy": "Học viện mở", "open_academy": "Học viện mở",
"explore_more_courses": "Khám phá thêm các khóa học", "explore_more_courses": "Khám phá thêm các khóa học",
"view_more": "Xem thêm" "view_more": "Xem thêm",
"upcoming_sessions_heading": "Phiên trực tiếp"
}, },
"schedule": { "schedule": {
"title": "Lịch trình của bạn", "title": "Lịch trình của bạn",
@@ -1,12 +1,46 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state';
import { Button } from '@cio/ui/base/button';
interface Props { interface Props {
children?: import('svelte').Snippet; children?: import('svelte').Snippet;
} }
let { children }: Props = $props();
import * as Page from '@cio/ui/base/page'; import * as Page from '@cio/ui/base/page';
import { OrgSettingsInlineTabs } from '$features/settings/components';
import { settingsHeaderAction } from '$features/settings/utils/store';
import { t } from '$lib/utils/functions/translations';
let { children }: Props = $props();
const showOrgSettingsTabs = $derived(
page.url.pathname.endsWith('/settings/org') ||
page.url.pathname.endsWith('/settings/customize-lms') ||
page.url.pathname.endsWith('/settings/domains') ||
page.url.pathname.endsWith('/settings/teams')
);
</script> </script>
<Page.Root class="w-full pb-10 md:max-w-3xl lg:mx-auto"> <Page.Root class="w-full pb-10 md:max-w-3xl lg:mx-auto">
{#if showOrgSettingsTabs}
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('settings.organization.organization_profile.heading')}</Page.Title>
<Page.Subtitle>{$t('settings.organization.page_subtitle')}</Page.Subtitle>
</Page.HeaderContent>
<Page.Action>
<Button
variant="secondary"
loading={$settingsHeaderAction.loading}
disabled={$settingsHeaderAction.disabled || !$settingsHeaderAction.onClick}
onclick={() => $settingsHeaderAction.onClick?.()}
>
{$settingsHeaderAction.label}
</Button>
</Page.Action>
</Page.Header>
<OrgSettingsInlineTabs />
{/if}
{@render children?.()} {@render children?.()}
</Page.Root> </Page.Root>
@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte';
import { CustomizeLmsPage } from '$features/settings/pages'; import { CustomizeLmsPage } from '$features/settings/pages';
import { t } from '$lib/utils/functions/translations'; import { settingsHeaderAction } from '$features/settings/utils/store';
import { Button } from '@cio/ui/base/button';
import * as Page from '@cio/ui/base/page';
let customizeLmsComponent: CustomizeLmsPage | null = $state(null); let customizeLmsComponent: CustomizeLmsPage | null = $state(null);
let isSaving = $state(false); let isSaving = $state(false);
@@ -15,27 +14,28 @@
isSaving = false; isSaving = false;
} }
} }
$effect(() => {
settingsHeaderAction.set({
label: 'Save',
disabled: isSaving,
loading: isSaving,
onClick: handleSave
});
});
onDestroy(() => {
settingsHeaderAction.set({
label: 'Save',
disabled: true,
loading: false,
onClick: null
});
});
</script> </script>
<svelte:head> <svelte:head>
<title>Customize LMS - ClassroomIO</title> <title>Customize LMS - ClassroomIO</title>
</svelte:head> </svelte:head>
<Page.Root class="w-full md:max-w-4xl lg:mx-auto"> <CustomizeLmsPage bind:this={customizeLmsComponent} />
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('components.settings.customize_lms.title')}</Page.Title>
<Page.Subtitle>{$t('components.settings.customize_lms.page_subtitle')}</Page.Subtitle>
</Page.HeaderContent>
<Page.Action>
<Button variant="default" loading={isSaving} disabled={isSaving} onclick={handleSave}>
{$t('components.settings.customize_lms.save')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<CustomizeLmsPage bind:this={customizeLmsComponent} />
{/snippet}
</Page.Body>
</Page.Root>
@@ -1,21 +1,29 @@
<script> <script>
import { onDestroy } from 'svelte';
import { DomainsPage } from '$features/settings/pages'; import { DomainsPage } from '$features/settings/pages';
import { t } from '$lib/utils/functions/translations'; import { settingsHeaderAction } from '$features/settings/utils/store';
import * as Page from '@cio/ui/base/page';
$effect(() => {
settingsHeaderAction.set({
label: 'Save',
disabled: true,
loading: false,
onClick: null
});
});
onDestroy(() => {
settingsHeaderAction.set({
label: 'Save',
disabled: true,
loading: false,
onClick: null
});
});
</script> </script>
<svelte:head> <svelte:head>
<title>Custom domain - ClassroomIO</title> <title>Custom domain - ClassroomIO</title>
</svelte:head> </svelte:head>
<Page.Header> <DomainsPage />
<Page.HeaderContent>
<Page.Title>{$t('components.settings.domains.title')}</Page.Title>
<Page.Subtitle>{$t('components.settings.domains.page_subtitle')}</Page.Subtitle>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<DomainsPage />
{/snippet}
</Page.Body>
@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte';
import { OrgPage } from '$features/settings/pages'; import { OrgPage } from '$features/settings/pages';
import { t } from '$lib/utils/functions/translations'; import { settingsHeaderAction } from '$features/settings/utils/store';
import { Button } from '@cio/ui/base/button';
import * as Page from '@cio/ui/base/page';
let orgComponent: OrgPage | null = $state(null); let orgComponent: OrgPage | null = $state(null);
let loading = $state(false); let loading = $state(false);
@@ -15,25 +14,28 @@
loading = false; loading = false;
} }
} }
$effect(() => {
settingsHeaderAction.set({
label: 'Save',
disabled: loading,
loading,
onClick: handleUpdate
});
});
onDestroy(() => {
settingsHeaderAction.set({
label: 'Save',
disabled: true,
loading: false,
onClick: null
});
});
</script> </script>
<svelte:head> <svelte:head>
<title>Organization Settings - ClassroomIO</title> <title>Organization Settings - ClassroomIO</title>
</svelte:head> </svelte:head>
<Page.Header isSticky> <OrgPage bind:this={orgComponent} />
<Page.HeaderContent>
<Page.Title>{$t('settings.organization.organization_profile.heading')}</Page.Title>
<Page.Subtitle>{$t('settings.organization.page_subtitle')}</Page.Subtitle>
</Page.HeaderContent>
<Page.Action>
<Button variant="default" {loading} disabled={loading} onclick={handleUpdate}>
{$t('settings.organization.organization_profile.update_organization')}
</Button>
</Page.Action>
</Page.Header>
<Page.Body>
{#snippet child()}
<OrgPage bind:this={orgComponent} />
{/snippet}
</Page.Body>
@@ -1,21 +1,29 @@
<script> <script>
import { onDestroy } from 'svelte';
import { TeamsPage } from '$features/settings/pages'; import { TeamsPage } from '$features/settings/pages';
import { t } from '$lib/utils/functions/translations'; import { settingsHeaderAction } from '$features/settings/utils/store';
import * as Page from '@cio/ui/base/page';
$effect(() => {
settingsHeaderAction.set({
label: 'Save',
disabled: true,
loading: false,
onClick: null
});
});
onDestroy(() => {
settingsHeaderAction.set({
label: 'Save',
disabled: true,
loading: false,
onClick: null
});
});
</script> </script>
<svelte:head> <svelte:head>
<title>Teams - ClassroomIO</title> <title>Teams - ClassroomIO</title>
</svelte:head> </svelte:head>
<Page.Header> <TeamsPage />
<Page.HeaderContent>
<Page.Title>{$t('upgrade.team_members')}</Page.Title>
<Page.Subtitle>{$t('settings.teams.page_subtitle')}</Page.Subtitle>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<TeamsPage />
{/snippet}
</Page.Body>
+1 -1
View File
@@ -20,7 +20,7 @@
const pageTitle = $derived( const pageTitle = $derived(
data.isOrgSite && data.org data.isOrgSite && data.org
? data.org.name ? data.org.name
: 'ClassroomIO - The Most Customizable Platform for Customer, Partner, and Employee Education' : 'ClassroomIO - One Platform for Customer, Partner, and Employee Training'
); );
const authAction = $derived( const authAction = $derived(
+27 -2
View File
@@ -19,7 +19,7 @@ const DEFAULT_META_TITLE =
'ClassroomIO Docs | Customer, Partner, and Employee Education'; 'ClassroomIO Docs | Customer, Partner, and Employee Education';
const DEFAULT_META_DESCRIPTION = const DEFAULT_META_DESCRIPTION =
'Learn how to create customer academies, partner certification programs, and employee training workflows in ClassroomIO.'; 'Learn how to create customer academies, partner certification programs, and employee training workflows in ClassroomIO.';
const DEFAULT_OG_IMAGE_URL = 'https://brand.cdn.clsrio.com/og/classroomio-opengraph.png'; const DEFAULT_OG_IMAGE_URL = 'https://brand.cdn.clsrio.com/og/classroomio-opengraph.jpg';
const DEFAULT_OG_IMAGE_ALT = 'ClassroomIO documentation for customer, partner, and employee education'; const DEFAULT_OG_IMAGE_ALT = 'ClassroomIO documentation for customer, partner, and employee education';
export const links: LinksFunction = () => [ export const links: LinksFunction = () => [
@@ -60,7 +60,7 @@ export const meta: MetaFunction = () => [
}, },
{ {
property: 'og:image:type', property: 'og:image:type',
content: 'image/png' content: 'image/jpeg'
}, },
{ {
property: 'og:image:width', property: 'og:image:width',
@@ -74,6 +74,31 @@ export const meta: MetaFunction = () => [
property: 'og:image:secure_url', property: 'og:image:secure_url',
content: DEFAULT_OG_IMAGE_URL content: DEFAULT_OG_IMAGE_URL
}, },
// WebP fallback for better compression on supported platforms
{
property: 'og:image',
content: 'https://brand.cdn.clsrio.com/og/classroomio-opengraph.webp'
},
{
property: 'og:image:alt',
content: DEFAULT_OG_IMAGE_ALT
},
{
property: 'og:image:type',
content: 'image/webp'
},
{
property: 'og:image:width',
content: '1920'
},
{
property: 'og:image:height',
content: '1080'
},
{
property: 'og:image:secure_url',
content: 'https://brand.cdn.clsrio.com/og/classroomio-opengraph.webp'
},
{ {
name: 'twitter:card', name: 'twitter:card',
content: 'summary_large_image' content: 'summary_large_image'
+8 -1
View File
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import type * as PageTree from 'fumadocs-core/page-tree'; import type * as PageTree from 'fumadocs-core/page-tree';
import browserCollections from 'fumadocs-mdx:collections/browser'; import browserCollections from 'fumadocs-mdx:collections/browser';
import { isRouteErrorResponse, useLoaderData, useRouteError } from 'react-router'; import { isRouteErrorResponse, redirect, useLoaderData, useRouteError } from 'react-router';
import { DocsLayout } from 'fumadocs-ui/layouts/docs'; import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page'; import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page';
import { HomeLayout } from 'fumadocs-ui/layouts/home'; import { HomeLayout } from 'fumadocs-ui/layouts/home';
@@ -26,6 +26,13 @@ const clientLoader = browserCollections.docs.createClientLoader({
export async function loader({ params }: { params: Record<string, string | undefined> }) { export async function loader({ params }: { params: Record<string, string | undefined> }) {
const slug = params['*']; const slug = params['*'];
// How-to guides were flattened to the docs root; permanently redirect the old
// `.../how-to-guides/<page>` URLs to `.../<page>`.
if (slug && slug.includes('how-to-guides/')) {
throw redirect('/' + slug.replace('how-to-guides/', ''), 301);
}
const slugs = slug ? slug.split('/') : ['home']; const slugs = slug ? slug.split('/') : ['home'];
const { getDocsRouteData } = await import('@/lib/source.server'); const { getDocsRouteData } = await import('@/lib/source.server');
const routeData = await getDocsRouteData(slugs); const routeData = await getDocsRouteData(slugs);
+7 -7
View File
@@ -7,6 +7,10 @@ import { Callout } from 'fumadocs-ui/components/callout';
**Audience** is your org-wide student directory. It shows every person who belongs to your organization — whether they signed up themselves, accepted a course invite, or were added directly. From here you can search, sort, bulk-assign to courses, and manage pending invites. **Audience** is your org-wide student directory. It shows every person who belongs to your organization — whether they signed up themselves, accepted a course invite, or were added directly. From here you can search, sort, bulk-assign to courses, and manage pending invites.
<Callout type="info" title="Plan limits">
Your plan sets a maximum audience size. When you reach the limit, a banner appears and you cannot invite more students until you upgrade or remove existing members.
</Callout>
## What Audience shows ## What Audience shows
Audience is separate from course-level **People**. Course People shows who is enrolled in a specific course. Audience shows everyone in your organization regardless of which courses they have joined. Audience is separate from course-level **People**. Course People shows who is enrolled in a specific course. Audience shows everyone in your organization regardless of which courses they have joined.
@@ -42,12 +46,8 @@ For students with **Pending** status, you can:
Use **Import Users** to bulk-add students from a CSV file. Use **Export** to download the current audience list. Use **Import Users** to bulk-add students from a CSV file. Use **Export** to download the current audience list.
<Callout type="info" title="Plan limits">
Your plan sets a maximum audience size. When you reach the limit, a banner appears and you cannot invite more students until you upgrade or remove existing members.
</Callout>
## Related guides ## Related guides
- [How Course Enrollment Works](/docs/how-to-guides/course-enrollment) — how students join courses - [How Course Enrollment Works](/docs/course-enrollment) — how students join courses
- [How to Invite Students](/docs/how-to-guides/invite-students) — send course-specific invites - [How to Invite Students](/docs/invite-students) — send course-specific invites
- [Roles and Permissions](/docs/how-to-guides/roles-and-permissions) — difference between students and team members - [Roles and Permissions](/docs/roles-and-permissions) — difference between students and team members
+100
View File
@@ -0,0 +1,100 @@
---
title: How to Create Certificates
description: Configure certificate rules for a course, set completion requirements, and let students download their certificates.
---
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
Certificates recognize students for completing a course. You configure the rules on a per-course basis — how much of the course must be completed, whether a final exercise must be passed, and when certificates expire. Students can then download their certificate from the **Certificates** tab in the LMS.
<Callout type="warn" title="Paid plan required">
Certificate features require a paid plan. The controls are visible on free plans but disabled.
</Callout>
## Configure certificate rules
Open your course in the admin dashboard and go to the **Certificates** tab.
![Certificate rules panel with the download toggle and completion threshold](/certificates-rules.webp)
<Steps>
<Step>
### Enable downloadable certificates
Turn on **Allow students download certificate**. When this is off, no certificate is generated even if students meet the requirements.
</Step>
<Step>
### Set a completion threshold
Enter a percentage in **Minimum completion (%)**. A student must complete this share of the course to qualify. Defaults to 100%.
</Step>
<Step>
### Require a final exercise (optional)
Select an exercise from **Final exercise (optional)**. When set, the student must pass that specific exercise (best attempt counts) in addition to meeting the overall completion percentage. Set **Minimum score on final exercise (%)** to the minimum passing score for that exercise.
</Step>
<Step>
### Set a deadline (optional)
Enter a date and time in **Certification deadline**. Students who complete the course after this date do not receive a certificate.
</Step>
<Step>
### Add a custom email message (optional)
Enter text in the email message field. This message is included in the notification email sent to students when they earn their certificate.
</Step>
</Steps>
## Customize the design and add signatures
Set up how the certificate looks before the course goes live, so the first student to qualify gets the finished
version. On the **Certificates** tab, click **Customize design** to open the certificate editor.
![Certificate design editor showing the template picker and live preview](/certificates-design-editor.webp)
<Steps>
<Step>
### Pick a template and accent color
Choose a **Template** and an accent color to match your brand. A live preview shows the result as you change it.
</Step>
<Step>
### Add signatories and signatures
Under **Signatories**, turn on **Show signatory 1** and fill in **Signatory 1 name** and **Signatory 1 role**, then
use **Signature image** to upload a signature graphic. You can enable a second signatory the same way.
![Signatories section with name, role, and signature image upload](/certificates-signatories.webp)
</Step>
<Step>
### Set the certificate ID format (optional)
Set a **Certificate ID format** so every issued certificate carries a unique, verifiable reference.
</Step>
</Steps>
<Callout type="info">
Finalize the design and signatures before you publish the course. Certificates are generated from the current design
when a student qualifies, so getting it right up front means no one receives an unfinished certificate.
</Callout>
## What students see
Once a student meets all the configured requirements, their certificate appears in the **Certificates** tab when they open the course in the LMS. They can view and download it from there.
![Student certificate view with download buttons](/certificates-student-download.webp)
## Related guides
- [Creating Your First Course](/docs/create-first-course) — course setup overview
- [How to Create an Exercise](/docs/create-exercise) — create the exercise you reference as a final exercise
- [How to Grade an Exercise](/docs/grade-exercise) — grade submissions before certificates are issued
- [Control Course Progression](/docs/course-progression): require completion before the certificate unlocks
- [Student Dashboard](/docs/student-dashboard) — where students access their certificates
+1 -1
View File
@@ -29,4 +29,4 @@ Admins can delete any question or comment. Authors can edit or delete their own
## Related guides ## Related guides
- [Course](/docs/course) — course-level features including Newsfeed - [Course](/docs/course) — course-level features including Newsfeed
- [Roles and Permissions](/docs/how-to-guides/roles-and-permissions) — who can post and moderate - [Roles and Permissions](/docs/roles-and-permissions) — who can post and moderate
@@ -54,7 +54,7 @@ From **People → Add people**, you enter one or more email addresses and Classr
When a student opens the link, they sign in (or sign up) and click **Join course**. ClassroomIO validates the token, expiry, and usage limit before enrolling them. When a student opens the link, they sign in (or sign up) and click **Join course**. ClassroomIO validates the token, expiry, and usage limit before enrolling them.
For step-by-step instructions, see [How to Invite Students](/docs/how-to-guides/invite-students). For step-by-step instructions, see [How to Invite Students](/docs/invite-students).
<Callout type="info" title="Email invites work on closed or unpublished courses"> <Callout type="info" title="Email invites work on closed or unpublished courses">
**Allow New Students** can be off and email invites still work. The toggle only blocks unsolicited self-enrollment from the landing page; it does not block people you explicitly invited. Invites also work on **unpublished** courses, so you can pre-enroll students before a course goes live. **Allow New Students** can be off and email invites still work. The toggle only blocks unsolicited self-enrollment from the landing page; it does not block people you explicitly invited. Invites also work on **unpublished** courses, so you can pre-enroll students before a course goes live.
@@ -83,7 +83,7 @@ Paid courses are useful when you want to prevent casual sign-ups on a free landi
| Goal | Suggested setup | | Goal | Suggested setup |
| --- | --- | | --- | --- |
| Anyone can join | Free course, published, **Allow New Students** on | | Anyone can join | Free course, published, **Allow New Students** on |
| Only people you email invites to | **Allow New Students** off (optional), send [email invites](/docs/how-to-guides/invite-students) | | Only people you email invites to | **Allow New Students** off (optional), send [email invites](/docs/invite-students) |
| Only people you add manually | **Allow New Students** off, use **People → Add people** to directly enroll existing students | | Only people you add manually | **Allow New Students** off, use **People → Add people** to directly enroll existing students |
| Paywall + optional invites | Set a price, send invites to buyers who should skip or supplement checkout | | Paywall + optional invites | Set a price, send invites to buyers who should skip or supplement checkout |
| Visible in catalog, enrollment closed | Published course, **Allow New Students** off, use invites or **People** to add members | | Visible in catalog, enrollment closed | Published course, **Allow New Students** off, use invites or **People** to add members |
@@ -181,9 +181,10 @@ Enable **Disable Signup** and **Internal Enrollment Only** in **Settings → Aut
## Related guides ## Related guides
- [How to Invite Students](/docs/how-to-guides/invite-students) — create, send, and revoke course invite links - [How to Invite Students](/docs/invite-students) — create, send, and revoke course invite links
- [Roles and Permissions](/docs/how-to-guides/roles-and-permissions) — who can create courses and access the admin dashboard - [Customize the Welcome Email](/docs/welcome-email): the message students get when they enroll
- [Roles and Permissions](/docs/roles-and-permissions) — who can create courses and access the admin dashboard
- [Student Dashboard](/docs/student-dashboard) — Home, My Learning, Explore, and Programs - [Student Dashboard](/docs/student-dashboard) — Home, My Learning, Explore, and Programs
- [Set Up a Custom Domain](/docs/how-to-guides/custom-domain) — org URLs used in enrollment links - [Set Up a Custom Domain](/docs/custom-domain) — org URLs used in enrollment links
- [Create a Course Landing Page](/docs/how-to-guides/course-landingpage) — pricing, publish state, and what prospects see before enrolling - [Create a Course Landing Page](/docs/course-landingpage) — pricing, publish state, and what prospects see before enrolling
- [Creating Your First Course](/docs/how-to-guides/create-first-course) — create and publish a course - [Creating Your First Course](/docs/create-first-course) — create and publish a course
@@ -6,7 +6,7 @@ description: Build and edit a course landing page, including pricing, hero butto
import { Callout } from 'fumadocs-ui/components/callout'; import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps'; import { Step, Steps } from 'fumadocs-ui/components/steps';
A **course landing page** is the public page for one course. Prospects read the description, see pricing, and click **Enroll** if enrollment is open. This is separate from your [academy landing page](/docs/how-to-guides/org-landing-page), which is the academy homepage. A **course landing page** is the public page for one course. Prospects read the description, see pricing, and click **Enroll** if enrollment is open. This is separate from your [academy landing page](/docs/org-landing-page), which is the academy homepage.
## Open the editor ## Open the editor
@@ -47,7 +47,7 @@ Use the preview control next to **Save** to see the page. Confirm behavior on th
| **Instructors** | Instructor cards | | **Instructors** | Instructor cards |
| **Footer** | Supporting links and copy | | **Footer** | Supporting links and copy |
Hero and pricing interact with [course enrollment](/docs/how-to-guides/course-enrollment): a free course with **Allow New Students** on shows an open **Enroll** button; paid or closed courses route visitors through payment or invites. Hero and pricing interact with [course enrollment](/docs/course-enrollment): a free course with **Allow New Students** on shows an open **Enroll** button; paid or closed courses route visitors through payment or invites.
## Pricing and currency ## Pricing and currency
@@ -72,11 +72,11 @@ Landing page content can be ready before you open enrollment:
| **Allow New Students** | Public **Enroll** works (for free courses) | | **Allow New Students** | Public **Enroll** works (for free courses) |
| **Price > 0** | Enrollment requires payment or a valid invite | | **Price > 0** | Enrollment requires payment or a valid invite |
See [How Course Enrollment Works](/docs/how-to-guides/course-enrollment) for closed cohorts and invite-only access. See [How Course Enrollment Works](/docs/course-enrollment) for closed cohorts and invite-only access.
## Preview vs live site ## Preview vs live site
The in-app preview shows layout and copy. Test **Enroll**, external links, and payment on your live academy URL (subdomain or [custom domain](/docs/how-to-guides/custom-domain)) before sharing publicly. The in-app preview shows layout and copy. Test **Enroll**, external links, and payment on your live academy URL (subdomain or [custom domain](/docs/custom-domain)) before sharing publicly.
<Callout type="warn" title="Org landing page preview"> <Callout type="warn" title="Org landing page preview">
The org homepage preview in **Settings → Landing Page** disables link clicks. Course landing previews should still be checked on the live site for enrollment flows. The org homepage preview in **Settings → Landing Page** disables link clicks. Course landing previews should still be checked on the live site for enrollment flows.
@@ -88,8 +88,8 @@ After a successful enroll, students land in the [Student Dashboard](/docs/studen
## Related guides ## Related guides
- [How Course Enrollment Works](/docs/how-to-guides/course-enrollment) — who can join and how Explore relates to publish settings - [How Course Enrollment Works](/docs/course-enrollment) — who can join and how Explore relates to publish settings
- [How to Invite Students](/docs/how-to-guides/invite-students) — invite links when public enroll is off - [How to Invite Students](/docs/invite-students) — invite links when public enroll is off
- [Course Types](/docs/how-to-guides/course-types) — how type affects public vs LMS access - [Course Types](/docs/course-types) — how type affects public vs LMS access
- [Set Up a Custom Domain](/docs/how-to-guides/custom-domain) — host for your landing page URL - [Set Up a Custom Domain](/docs/custom-domain) — host for your landing page URL
- [Creating Your First Course](/docs/how-to-guides/create-first-course) - [Creating Your First Course](/docs/create-first-course)
@@ -0,0 +1,103 @@
---
title: Control Course Progression
description: Require students to move through a course in order, with lessons and exercises that only complete when your rules are met.
---
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
By default students can open any unlocked item in any order. With **sequential** progression they must finish each item
before the next one unlocks, and you decide what "finished" means for every lesson and exercise. This is the backbone of
a structured training flow: watch the video, pass the quiz, earn the certificate.
## Choose a progression mode
In the course's **Settings** tab, find the **Student progression** section and pick one:
| Mode | Behavior |
| --- | --- |
| **Free** | Students open any unlocked item in any order and mark lessons complete themselves. |
| **Sequential** | The next item stays locked until the current (or previous) item is complete. |
![Student progression setting with Free and Sequential options](/progression-mode.webp)
<Callout type="info">
If a course is just a video and a quiz, turn off **Content grouping** in the same settings so the content list is a
flat list instead of grouped sections.
</Callout>
## Set how a lesson completes
Open a lesson, go to its **Settings** tab, and choose a **Completion rule**:
| Rule | Completes when |
| --- | --- |
| **Manual** | The student clicks to mark the lesson complete. |
| **Video watch** | The student watches the selected videos. Set the **Watch threshold (%)** (for example 95%). |
| **None** | The lesson never blocks progression. |
![Lesson Completion rule options with Manual, Video watch, and None](/progression-completion-rule.webp)
<Callout type="warn">
**Video watch** only enforces uploaded videos. While the rule is active the player blocks skipping ahead and pauses when
the student switches tabs, so the watch percentage reflects real viewing. The lesson completes once the threshold is reached.
</Callout>
## Set how an exercise completes
Open an exercise, go to its **Settings** tab, and choose a **Completion rule**:
- **Submitted**: the exercise completes as soon as the student submits any answer.
- **Passed**: the exercise completes only when the student's score meets the **Pass threshold (%)** you set.
Use **Passed** when an exercise must be cleared before the course can continue or a certificate can be issued.
## Gate the certificate
In the course's **Certificates** settings, turn on certificate download and set **Minimum completion (%)** (for example
100%). The certificate unlocks only after the student completes that share of the course, which, under sequential
progression, means they have watched the videos and finished the exercises in order.
See [How to Create Certificates](/docs/certificates) for templates, signatures, and the certificate ID.
## What students see
<Steps>
<Step>
### Locked items
Under **Sequential** progression, the next item stays locked until the student completes the one before it. For example, a
quiz that follows a lesson stays locked until that lesson is complete. In the course content list a locked item is greyed
out with a lock icon and cannot be opened; it unlocks automatically once the previous required item is done. If a student
opens a not-yet-available item directly, they see a **Complete previous content first** notice.
![Locked exercise in the content list with a lock icon](/progression-locked.webp)
</Step>
<Step>
### A video they cannot skip
On a **Video watch** lesson, the progress percentage climbs as they watch. Trying to jump ahead shows a "you cannot skip
ahead" notice, and the video pauses if they leave the tab.
![Video player showing the "You cannot skip ahead on this video" notice](/progression-cant-skip.webp)
</Step>
<Step>
### Completion and the certificate
Reaching the watch threshold marks the lesson complete and unlocks the next item. After they finish the final
requirement, the course is complete and they can view and download their certificate.
![Course requirements complete modal showing congratulations and View certificate button](/progression-student-course-complete.webp)
</Step>
</Steps>
## Related guides
- [Course Types](/docs/course-types): Self-paced, Live class, Compliance, and Public
- [How to Create an Exercise](/docs/create-exercise): build the quiz that gates progression
- [How to Create Certificates](/docs/certificates): what students earn at the end
- [Run a Live Class](/docs/live-class): schedule live sessions alongside on-demand content
@@ -26,7 +26,8 @@ Use self-paced for standard training, onboarding content, and evergreen material
**Live class** adds instructor grading workflows. Admins and tutors see a **Marks** section in the course sidebar to review and grade student work. **Live class** adds instructor grading workflows. Admins and tutors see a **Marks** section in the course sidebar to review and grade student work.
Use live class when an instructor scores assignments and you need a dedicated grading view. Use live class when an instructor scores assignments and you need a dedicated grading view. To schedule sessions with a
join link, automatic reminders, and a calendar invite, see [Run a Live Class](/docs/live-class).
## Compliance ## Compliance
@@ -68,6 +69,8 @@ Open the course sidebar after changing type to confirm the right tools are avail
## Related guides ## Related guides
- [Course](/docs/course) — course overview and admin areas - [Course](/docs/course) — course overview and admin areas
- [How Course Enrollment Works](/docs/how-to-guides/course-enrollment) — enrollment (not used for Public in the same way) - [Run a Live Class](/docs/live-class): schedule sessions, reminders, and calendar invites
- [Control Course Progression](/docs/course-progression): sequential learning and completion rules
- [How Course Enrollment Works](/docs/course-enrollment) — enrollment (not used for Public in the same way)
- [Student Dashboard](/docs/student-dashboard) — student LMS views - [Student Dashboard](/docs/student-dashboard) — student LMS views
- [Creating Your First Course](/docs/how-to-guides/create-first-course) - [Creating Your First Course](/docs/create-first-course)
+9 -9
View File
@@ -5,7 +5,7 @@ description: Overview of courses in ClassroomIO — lifecycle, settings, admin a
import { Callout } from 'fumadocs-ui/components/callout'; import { Callout } from 'fumadocs-ui/components/callout';
A **course** in ClassroomIO is a container for lessons, exercises, people, and settings. Students enroll into a course to access its content in the LMS. This page summarizes how courses work for admins. For step-by-step setup, start with [Creating Your First Course](/docs/how-to-guides/create-first-course). A **course** in ClassroomIO is a container for lessons, exercises, people, and settings. Students enroll into a course to access its content in the LMS. This page summarizes how courses work for admins. For step-by-step setup, start with [Creating Your First Course](/docs/create-first-course).
## Course lifecycle ## Course lifecycle
@@ -15,7 +15,7 @@ A **course** in ClassroomIO is a container for lessons, exercises, people, and s
| **Published** | Live on your academy; visible in Explore and on the public landing page | | **Published** | Live on your academy; visible in Explore and on the public landing page |
| **Deleted** | Soft-deleted; no longer appears anywhere in the app | | **Deleted** | Soft-deleted; no longer appears anywhere in the app |
Publishing makes a course discoverable. Combine publish state with **Allow New Students**, price, and invites to control who can actually join. See [How Course Enrollment Works](/docs/how-to-guides/course-enrollment). Publishing makes a course discoverable. Combine publish state with **Allow New Students**, price, and invites to control who can actually join. See [How Course Enrollment Works](/docs/course-enrollment).
## Key settings ## Key settings
@@ -27,7 +27,7 @@ Publishing makes a course discoverable. Combine publish state with **Allow New S
| **Course type** | Course **Settings** | Changes nav, compliance, and public access behavior | | **Course type** | Course **Settings** | Changes nav, compliance, and public access behavior |
<Callout type="info" title="Course type changes more than a label"> <Callout type="info" title="Course type changes more than a label">
Self-paced, Live class, Compliance, and Public types change what admins and students see in the app. See [Course Types](/docs/how-to-guides/course-types) for when to use each. Self-paced, Live class, Compliance, and Public types change what admins and students see in the app. See [Course Types](/docs/course-types) for when to use each.
</Callout> </Callout>
## Admin areas inside a course ## Admin areas inside a course
@@ -47,7 +47,7 @@ When you open a course from the **Courses** list, the sidebar gives you:
The **People** tab lists everyone in the course group. Members appear when they enroll or when you add them from **Add people**. Email-only invites show as **Pending** until the student signs in. The **People** tab lists everyone in the course group. Members appear when they enroll or when you add them from **Add people**. Email-only invites show as **Pending** until the student signs in.
Enrollment paths (public, invite link, admin assignment, paid) are documented in [How Course Enrollment Works](/docs/how-to-guides/course-enrollment). For sending invites, see [How to Invite Students](/docs/how-to-guides/invite-students). Enrollment paths (public, invite link, admin assignment, paid) are documented in [How Course Enrollment Works](/docs/course-enrollment). For sending invites, see [How to Invite Students](/docs/invite-students).
## What students see ## What students see
@@ -55,10 +55,10 @@ After enrolling, students open the course from **Home** or **My Learning** in th
## Related guides ## Related guides
- [Creating Your First Course](/docs/how-to-guides/create-first-course) - [Creating Your First Course](/docs/create-first-course)
- [Course Types](/docs/how-to-guides/course-types) - [Course Types](/docs/course-types)
- [How Course Enrollment Works](/docs/how-to-guides/course-enrollment) - [How Course Enrollment Works](/docs/course-enrollment)
- [How to Invite Students](/docs/how-to-guides/invite-students) - [How to Invite Students](/docs/invite-students)
- [Create a Course Landing Page](/docs/how-to-guides/course-landingpage) - [Create a Course Landing Page](/docs/course-landingpage)
- [Student Dashboard](/docs/student-dashboard) - [Student Dashboard](/docs/student-dashboard)
@@ -3,9 +3,10 @@ title: How to Create an Exercise
description: Learn how to create exercises in ClassroomIO. description: Learn how to create exercises in ClassroomIO.
--- ---
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps'; import { Step, Steps } from 'fumadocs-ui/components/steps';
In ClassroomIO, exercises are a great way to assess students' understanding through practice activities. Once you've [set up your organization](/docs/how-to-guides/customize-organization), [created a course](/docs/how-to-guides/create-first-course), and [added a lesson](/docs/how-to-guides/create-first-course#adding-lessons), you can start creating exercises. Follow the process below to create your first exercise. In ClassroomIO, exercises are a great way to assess students' understanding through practice activities. Once you've [set up your organization](/docs/customize-organization), [created a course](/docs/create-first-course), and [added a lesson](/docs/create-first-course#adding-lessons), you can start creating exercises. Follow the process below to create your first exercise.
<Steps> <Steps>
<Step> <Step>
@@ -105,3 +106,24 @@ You've successfully created your first exercise and it is now accessible to stud
</Step> </Step>
</Steps> </Steps>
## Notify students about a quiz
When a quiz is ready, you can email every enrolled student a one-click link to take it. On the exercise page, open the
**⋯** menu at the top and choose **Notify students**.
![Exercise menu showing the Notify students option](/notify-students-menu.webp)
The send runs in the background. A progress indicator appears while emails go out, then confirms when it is done. You can
run it again at any time to nudge students who have not taken the quiz yet.
<Callout type="info">
Notifying students is handy after a live session: schedule the quiz, then send the notification so attendees know to
complete it. See [Run a Live Class](/docs/live-class).
</Callout>
## Related guides
- [How to Grade an Exercise](/docs/grade-exercise): review and score submissions
- [Control Course Progression](/docs/course-progression): require a passing score before the next item unlocks
- [How to Create Certificates](/docs/certificates): issue certificates on completion
@@ -61,7 +61,7 @@ Open your custom domain in a browser. Sign in, open a course landing page, and c
| Org site landing page and course catalog | Yes | | Org site landing page and course catalog | Yes |
| Organization team invite (some flows) | May still use platform URL until fully aligned | | Organization team invite (some flows) | May still use platform URL until fully aligned |
Course invites are documented in [How to Invite Students](/docs/how-to-guides/invite-students). Those links should point at your academy, not `app.classroomio.com`, when your domain is verified. Course invites are documented in [How to Invite Students](/docs/invite-students). Those links should point at your academy, not `app.classroomio.com`, when your domain is verified.
## Email sender address ## Email sender address
@@ -73,8 +73,8 @@ Students should sign in on your academy so sessions and redirects stay on your a
## Related guides ## Related guides
- [Customize Your Organization](/docs/how-to-guides/customize-organization) — org profile and default subdomain - [Customize Your Organization](/docs/customize-organization) — org profile and default subdomain
- [Customize Your Academy Landing Page](/docs/how-to-guides/org-landing-page) — public homepage on your domain - [Customize Your Academy Landing Page](/docs/org-landing-page) — public homepage on your domain
- [How to Invite Students](/docs/how-to-guides/invite-students) — enrollment links on your academy - [How to Invite Students](/docs/invite-students) — enrollment links on your academy
- [How Course Enrollment Works](/docs/how-to-guides/course-enrollment) — enrollment paths and org signup - [How Course Enrollment Works](/docs/course-enrollment) — enrollment paths and org signup
- [Organization](/docs/organization) — org settings overview - [Organization](/docs/organization) — org settings overview
@@ -91,9 +91,9 @@ Click on Update Domain to save the changes.
</Step> </Step>
</Steps> </Steps>
For full custom domain setup (DNS, verification, which links use your domain), see [Set Up a Custom Domain](/docs/how-to-guides/custom-domain). For full custom domain setup (DNS, verification, which links use your domain), see [Set Up a Custom Domain](/docs/custom-domain).
For the public homepage (hero, nav, footer), see [Customize Your Academy Landing Page](/docs/how-to-guides/org-landing-page). For the public homepage (hero, nav, footer), see [Customize Your Academy Landing Page](/docs/org-landing-page).
## Set Brand Color ## Set Brand Color
@@ -102,6 +102,6 @@ We understand that every organization has a preferred color that suits their bra
## Related guides ## Related guides
- [Organization](/docs/organization) — org settings overview - [Organization](/docs/organization) — org settings overview
- [Customize Your Academy Landing Page](/docs/how-to-guides/org-landing-page) — public homepage - [Customize Your Academy Landing Page](/docs/org-landing-page) — public homepage
- [Set Up a Custom Domain](/docs/how-to-guides/custom-domain) — your own hostname - [Set Up a Custom Domain](/docs/custom-domain) — your own hostname
- [Roles and Permissions](/docs/how-to-guides/roles-and-permissions) — team access - [Roles and Permissions](/docs/roles-and-permissions) — team access
@@ -66,6 +66,6 @@ Click **Grade with AI** inside the grading modal to get an AI-suggested score an
## Related guides ## Related guides
- [How to Create an Exercise](/docs/how-to-guides/create-exercise) — create and configure exercises - [How to Create an Exercise](/docs/create-exercise) — create and configure exercises
- [How to Create Certificates](/docs/how-to-guides/certificates) — certificates that depend on exercise scores - [How to Create Certificates](/docs/certificates) — certificates that depend on exercise scores
- [Course](/docs/course) — course overview including the Submissions tab - [Course](/docs/course) — course overview including the Submissions tab
+1 -1
View File
@@ -5,7 +5,7 @@ description: Learn how to create customer academies, partner certification progr
<img <img
alt="ClassroomIO documentation for customer, partner, and employee education" alt="ClassroomIO documentation for customer, partner, and employee education"
src="https://brand.cdn.clsrio.com/og/classroomio-opengraph.png" src="https://brand.cdn.clsrio.com/og/classroomio-opengraph.jpg"
/> />
Welcome to the ClassroomIO documentation—your comprehensive guide to streamlining training for everyone. Our all-in-one platform empowers bootcamps, educators, and businesses to manage training programs easily. Run multiple classes and cohorts from a single UI with our mobile-first application, ensuring students can access all published content from any device. Welcome to the ClassroomIO documentation—your comprehensive guide to streamlining training for everyone. Our all-in-one platform empowers bootcamps, educators, and businesses to manage training programs easily. Run multiple classes and cohorts from a single UI with our mobile-first application, ensuring students can access all published content from any device.
@@ -1,64 +0,0 @@
---
title: How to Create Certificates
description: Configure certificate rules for a course, set completion requirements, and let students download their certificates.
---
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
Certificates recognize students for completing a course. You configure the rules on a per-course basis — what score is required, whether a final exercise must be passed, and when certificates expire. Students can then download their certificate from the **Certificates** tab in the LMS.
## Configure certificate rules
Open your course in the admin dashboard and go to the **Certificates** tab.
<Steps>
<Step>
### Enable downloadable certificates
Turn on **Allow students to download their certificate**. When this is off, no certificate is generated even if students meet the requirements.
</Step>
<Step>
### Set a completion threshold
Enter a percentage in **Minimum score to receive certificate**. A student must reach this overall course score to qualify. Defaults to 100%.
</Step>
<Step>
### Require a final exercise (optional)
Select an exercise from **Final exercise**. When set, the student must pass that specific exercise in addition to meeting the overall threshold. Set **Minimum exercise score** to the minimum passing score for that exercise.
</Step>
<Step>
### Set a deadline (optional)
Enter a date and time in **Certificate deadline**. Students who complete the course after this date do not receive a certificate.
</Step>
<Step>
### Add a custom email message (optional)
Enter text in the email message field. This message is included in the notification email sent to students when they earn their certificate.
</Step>
</Steps>
## What students see
Once a student meets all the configured requirements, their certificate appears in the **Certificates** tab when they open the course in the LMS. They can view and download it from there.
<Callout type="info" title="Issuing certificates manually">
Admins can also issue a certificate to a specific student manually using the **Issue Certificate** button at the top of the Certificates tab, regardless of whether the student met the automatic requirements.
</Callout>
<Callout type="warn" title="Paid plan required">
Certificate features require a paid plan. The controls are visible on free plans but disabled.
</Callout>
## Related guides
- [Creating Your First Course](/docs/how-to-guides/create-first-course) — course setup overview
- [How to Create an Exercise](/docs/how-to-guides/create-exercise) — create the exercise you reference as a final exercise
- [How to Grade an Exercise](/docs/how-to-guides/grade-exercise) — grade submissions before certificates are issued
- [Student Dashboard](/docs/student-dashboard) — where students access their certificates
@@ -1,17 +0,0 @@
{
"create-first-course": "Creating Your First Course",
"create-exercise": "How to Create an Exercise",
"customize-organization": "Customize Your Organization",
"course-landingpage": "Create a Course Landing Page",
"course-enrollment": "How Course Enrollment Works",
"course-types": "Course Types",
"certificates": "How to Create Certificates",
"grade-exercise": "How to Grade an Exercise",
"invite-students": "How to Invite Students",
"invite-team-member": "Inviting a Team Member",
"take-attendance": "How to Take Attendance",
"use-math-in-editor": "Use Math in the Editor",
"roles-and-permissions": "Roles and Permissions",
"org-landing-page": "Customize Your Academy Landing Page",
"custom-domain": "Set Up a Custom Domain"
}
@@ -7,7 +7,7 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
Course invitations in ClassroomIO let you add students to a course by sending them a secure enrollment link by email. Each invite is unique and can have its own expiry and usage limits. Course invitations in ClassroomIO let you add students to a course by sending them a secure enrollment link by email. Each invite is unique and can have its own expiry and usage limits.
For a full overview of enrollment paths (public enroll, invites, admin assignment, and paid access), see [How Course Enrollment Works](/docs/how-to-guides/course-enrollment). For a full overview of enrollment paths (public enroll, invites, admin assignment, and paid access), see [How Course Enrollment Works](/docs/course-enrollment).
## How course invitations work ## How course invitations work
@@ -80,7 +80,7 @@ Revoking does not remove students who have already joined; it only invalidates t
## Related guides ## Related guides
- [How Course Enrollment Works](/docs/how-to-guides/course-enrollment) — enrollment paths, Allow New Students, and Explore visibility - [How Course Enrollment Works](/docs/course-enrollment) — enrollment paths, Allow New Students, and Explore visibility
- [Roles and Permissions](/docs/how-to-guides/roles-and-permissions) — who can manage courses vs learn in the LMS - [Roles and Permissions](/docs/roles-and-permissions) — who can manage courses vs learn in the LMS
- [Set Up a Custom Domain](/docs/how-to-guides/custom-domain) — which host appears in invite links - [Set Up a Custom Domain](/docs/custom-domain) — which host appears in invite links
- [Student Dashboard](/docs/student-dashboard) — where students land after joining - [Student Dashboard](/docs/student-dashboard) — where students land after joining
@@ -8,6 +8,10 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
Team members are people who help you run your organization — **Admins** have full access, **Tutors** can manage the courses they are assigned to. Both are different from students, who use the LMS and cannot access the admin dashboard. Team members are people who help you run your organization — **Admins** have full access, **Tutors** can manage the courses they are assigned to. Both are different from students, who use the LMS and cannot access the admin dashboard.
<Callout type="warn" title="Paid plan required">
Email invites and link invites for team members require a paid plan.
</Callout>
To invite a team member, go to **Settings → Organization**, then click **Manage Team**. To invite a team member, go to **Settings → Organization**, then click **Manage Team**.
## Invite by email ## Invite by email
@@ -64,12 +68,8 @@ Toggle the link off to stop accepting new members through it. You can re-enable
</Step> </Step>
</Steps> </Steps>
<Callout type="warn" title="Paid plan required">
Email invites and link invites for team members require a paid plan.
</Callout>
## Related guides ## Related guides
- [Roles and Permissions](/docs/how-to-guides/roles-and-permissions) — what admins and tutors can do - [Roles and Permissions](/docs/roles-and-permissions) — what admins and tutors can do
- [How to Invite Students](/docs/how-to-guides/invite-students) — invite students to a specific course (separate from team invites) - [How to Invite Students](/docs/invite-students) — invite students to a specific course (separate from team invites)
- [Organization](/docs/organization) — org settings overview - [Organization](/docs/organization) — org settings overview
+112
View File
@@ -0,0 +1,112 @@
---
title: Run a Live Class
description: Schedule a live session on a Live class course, set the join link, time, and timezone, and let ClassroomIO remind students and send a calendar invite.
---
import { Callout } from 'fumadocs-ui/components/callout';
import { Step, Steps } from 'fumadocs-ui/components/steps';
A **Live class** course runs around scheduled sessions you host on your own video tool (Zoom, Google Meet, Teams).
ClassroomIO handles everything around the session: the join link, the schedule, automatic reminders, a calendar invite,
and a one-click way for students to join.
## Set the course type
Set the course type to **Live class** in the course's **Settings** tab. See [Course Types](/docs/course-types)
for what each type does.
## Schedule the session
Each session lives on a lesson. Create the lesson for what you'll run live, then open its **Settings** tab and fill in
the **Live session** section.
![Lesson Settings tab showing the Live session fields for join link, date and time, and timezone](/live-class-session-settings.webp)
<Steps>
<Step>
### Create a lesson to run live
Add a lesson to the course for the session you'll hold live (for example, the webinar topic). See
[Creating Your First Course](/docs/create-first-course#adding-lessons) for adding lessons.
</Step>
<Step>
### Add the join link
In **Join link (Zoom, Meet, etc.)**, paste the meeting URL students will use to join.
</Step>
<Step>
### Set the date and time
Pick the **Date and time** of the session.
</Step>
<Step>
### Choose the timezone
Set the **Timezone** for the session. Reminders and the calendar invite display the time in this timezone, so students
always see when the session starts in the zone you intend, regardless of where you or they are located.
</Step>
<Step>
### Save the session
Click **Save session**.
</Step>
</Steps>
## What students get
Once a session is scheduled, enrolled students automatically receive:
- **A calendar invite when they enroll.** The welcome email for a Live class course attaches a calendar invite (`.ics`)
for the next session, so students can add it to their calendar right away.
- **Reminder emails.** One about 24 hours before and one about 1 hour before the session, each with the join link. The
24-hour reminder also includes the calendar invite. Once added, the calendar reminds the student on its own.
- **A Join session button.** On their **LMS Home**, students see a **Join session** button on the course while a session
is upcoming, which opens the join link directly.
![LMS Home Live Sessions card with a Join session button](/live-class-lms-card.webp)
In the course content sidebar, a live-session lesson shows a red live (broadcast) icon, so you and your students can spot
it at a glance.
![Student live session card showing the countdown and Add to calendar button](/live-class-student-card.webp)
<Callout type="info">
Reminders are sent to enrolled students only. The times shown in emails and the calendar invite use the **Timezone** you
set on the session. The welcome email, reminders, and updates all reference the same calendar event, so students never
get a duplicate on their calendar.
</Callout>
## Change the session time
If you change the **Date and time**, **Join link**, or **Timezone** of a session that students are already enrolled in,
saving asks you to confirm with **Notify students of the change?**. Choosing **Save and notify** emails every enrolled
student about the update with a fresh calendar invite that updates the existing event on their calendar instead of
creating a duplicate.
![Confirmation dialog asking to notify students of the session change](/live-class-change-time.webp)
<Callout type="warn">
**Save and notify** emails all enrolled students. Use it when the time or link actually changed. For a first-time
schedule there is nothing to notify, so it saves without sending.
</Callout>
## After the session
Pair the session with a post-session quiz and a certificate to round out the workflow:
- Add a graded quiz with [How to Create an Exercise](/docs/create-exercise), then use **Notify students**
to send everyone a link to take it.
- Issue completion certificates with [How to Create Certificates](/docs/certificates).
## Related guides
- [Course Types](/docs/course-types): what Live class and the other types do
- [How to Create an Exercise](/docs/create-exercise): add a post-session quiz
- [How to Create Certificates](/docs/certificates): recognize completion
- [How Course Enrollment Works](/docs/course-enrollment): how students join the course
+31 -2
View File
@@ -18,8 +18,37 @@
"student-dashboard", "student-dashboard",
"enterprise-sso", "enterprise-sso",
"---Guides---", "---Get started---",
"how-to-guides", "create-first-course",
"---Build a course---",
"course-types",
"create-exercise",
"grade-exercise",
"course-progression",
"use-math-in-editor",
"certificates",
"---Live classes---",
"live-class",
"take-attendance",
"---Enrollment & students---",
"course-enrollment",
"welcome-email",
"invite-students",
"---Publish your academy---",
"course-landingpage",
"org-landing-page",
"custom-domain",
"---Organization & team---",
"customize-organization",
"invite-team-member",
"roles-and-permissions",
"---Contributor guides---",
"contributor-guides" "contributor-guides"
] ]
} }
@@ -8,7 +8,11 @@ import { Step, Steps } from 'fumadocs-ui/components/steps';
Your **academy landing page** is the public homepage for your academy (for example `https://ciodevs.myclassroomio.com` or your custom domain). Students and prospects see it before they sign in. This guide covers the editor in **Settings → Landing Page**. Your **academy landing page** is the public homepage for your academy (for example `https://ciodevs.myclassroomio.com` or your custom domain). Students and prospects see it before they sign in. This guide covers the editor in **Settings → Landing Page**.
For org profile and subdomain setup, see [Customize Your Organization](/docs/how-to-guides/customize-organization) and [Set Up a Custom Domain](/docs/how-to-guides/custom-domain). For org profile and subdomain setup, see [Customize Your Organization](/docs/customize-organization) and [Set Up a Custom Domain](/docs/custom-domain).
<Callout type="warn" title="Paid plan required">
Some landing themes require a paid plan. If the customize option is locked, upgrade or pick a theme included in your plan.
</Callout>
## Open the landing page settings ## Open the landing page settings
@@ -46,7 +50,7 @@ The hero section supports a primary and optional secondary action.
| **Primary** (e.g. Get Started, Start Learning) | Often points to `/login` | Label and destination URL | | **Primary** (e.g. Get Started, Start Learning) | Often points to `/login` | Label and destination URL |
| **Secondary** (e.g. View Programs) | Optional second CTA | Label and destination URL | | **Secondary** (e.g. View Programs) | Optional second CTA | Label and destination URL |
Use your academy paths (for example `/login` or `/courses`) so visitors stay on your academy domain. After you verify a [custom domain](/docs/how-to-guides/custom-domain), test buttons on the live site, not only in preview. Use your academy paths (for example `/login` or `/courses`) so visitors stay on your academy domain. After you verify a [custom domain](/docs/custom-domain), test buttons on the live site, not only in preview.
<Callout type="warn" title="Preview disables link clicks"> <Callout type="warn" title="Preview disables link clicks">
The thumbnail preview on the Landing Page settings screen renders with links disabled. Use **Open Academy** or open your live academy URL to test buttons like **Get Started** and **View Programs**. The thumbnail preview on the Landing Page settings screen renders with links disabled. Use **Open Academy** or open your live academy URL to test buttons like **Get Started** and **View Programs**.
@@ -58,7 +62,7 @@ The footer editor lets you set brand text, link columns, social links, and botto
## Themes ## Themes
ClassroomIO ships multiple landing themes. Some themes require a paid plan. If customize is locked, upgrade or pick a theme included in your plan. ClassroomIO ships multiple landing themes. Pick the one that fits your brand from the editor.
## Preview vs live site ## Preview vs live site
@@ -71,7 +75,7 @@ Always validate hero and footer links on the live site before sharing the URL ex
## Related guides ## Related guides
- [Customize Your Organization](/docs/how-to-guides/customize-organization) — org name, logo, brand color - [Customize Your Organization](/docs/customize-organization) — org name, logo, brand color
- [Set Up a Custom Domain](/docs/how-to-guides/custom-domain) — use your own domain on the landing page - [Set Up a Custom Domain](/docs/custom-domain) — use your own domain on the landing page
- [Create a Course Landing Page](/docs/how-to-guides/course-landingpage) — per-course public pages (separate from the org homepage) - [Create a Course Landing Page](/docs/course-landingpage) — per-course public pages (separate from the org homepage)
- [Student Dashboard](/docs/student-dashboard) — where students go after signing in - [Student Dashboard](/docs/student-dashboard) — where students go after signing in
+11 -11
View File
@@ -11,15 +11,15 @@ An **organization** in ClassroomIO holds courses, people, branding, and your aca
| Area | Where | Guide | | Area | Where | Guide |
| --- | --- | --- | | --- | --- | --- |
| Name, logo, brand color | **Settings → Organization** | [Customize Your Organization](/docs/how-to-guides/customize-organization) | | Name, logo, brand color | **Settings → Organization** | [Customize Your Organization](/docs/customize-organization) |
| Public homepage | **Settings → Landing Page** | [Customize Your Academy Landing Page](/docs/how-to-guides/org-landing-page) | | Public homepage | **Settings → Landing Page** | [Customize Your Academy Landing Page](/docs/org-landing-page) |
| Custom domain | **Settings → Organization** | [Set Up a Custom Domain](/docs/how-to-guides/custom-domain) | | Custom domain | **Settings → Organization** | [Set Up a Custom Domain](/docs/custom-domain) |
| Team roles | **Settings → Organization** | [Roles and Permissions](/docs/how-to-guides/roles-and-permissions), [Inviting a Team Member](/docs/how-to-guides/invite-team-member) | | Team roles | **Settings → Organization** | [Roles and Permissions](/docs/roles-and-permissions), [Inviting a Team Member](/docs/invite-team-member) |
| Signup rules, Internal Enrollment Only | **Settings → Authentication → General** | [How Course Enrollment Works](/docs/how-to-guides/course-enrollment) (org access section) | | Signup rules, Internal Enrollment Only | **Settings → Authentication → General** | [How Course Enrollment Works](/docs/course-enrollment) (org access section) |
## Default URL ## Default URL
On creation, each org gets `https://<siteName>.myclassroomio.com` as its academy URL. You can connect a [custom domain](/docs/how-to-guides/custom-domain) so students use your own hostname. On creation, each org gets `https://<siteName>.myclassroomio.com` as its academy URL. You can connect a [custom domain](/docs/custom-domain) so students use your own hostname.
<Callout type="info" title="Academy vs admin app"> <Callout type="info" title="Academy vs admin app">
Share your academy URL with students. The generic `app.classroomio.com` host is for platform access when no org subdomain applies. Course and enroll links should use your academy when possible. Share your academy URL with students. The generic `app.classroomio.com` host is for platform access when no org subdomain applies. Course and enroll links should use your academy when possible.
@@ -33,10 +33,10 @@ On creation, each org gets `https://<siteName>.myclassroomio.com` as its academy
## Related guides ## Related guides
- [Customize Your Organization](/docs/how-to-guides/customize-organization) - [Customize Your Organization](/docs/customize-organization)
- [Customize Your Academy Landing Page](/docs/how-to-guides/org-landing-page) - [Customize Your Academy Landing Page](/docs/org-landing-page)
- [Set Up a Custom Domain](/docs/how-to-guides/custom-domain) - [Set Up a Custom Domain](/docs/custom-domain)
- [Roles and Permissions](/docs/how-to-guides/roles-and-permissions) - [Roles and Permissions](/docs/roles-and-permissions)
- [How Course Enrollment Works](/docs/how-to-guides/course-enrollment) - [How Course Enrollment Works](/docs/course-enrollment)
- [Course](/docs/course) - [Course](/docs/course)
- [Programs](/docs/programs) - [Programs](/docs/programs)
+2 -2
View File
@@ -62,6 +62,6 @@ Students who belong to a program open **Programs** in the LMS to see their track
## Related guides ## Related guides
- [Student Dashboard](/docs/student-dashboard) — Programs vs My Learning vs Explore - [Student Dashboard](/docs/student-dashboard) — Programs vs My Learning vs Explore
- [How Course Enrollment Works](/docs/how-to-guides/course-enrollment) — how students join courses inside a program - [How Course Enrollment Works](/docs/course-enrollment) — how students join courses inside a program
- [Course](/docs/course) — single-course overview - [Course](/docs/course) — single-course overview
- [Roles and Permissions](/docs/how-to-guides/roles-and-permissions) — who can manage programs and courses - [Roles and Permissions](/docs/roles-and-permissions) — who can manage programs and courses
+1 -1
View File
@@ -53,7 +53,7 @@ You are successfully signed up and ready to use ClassroomIO when you see the das
<Step> <Step>
### What Next? ### What Next?
After signing up successfully, you can now set up and [customize your Organization](/docs/how-to-guides/customize-organization). After signing up successfully, you can now set up and [customize your Organization](/docs/customize-organization).
</Step> </Step>
</Steps> </Steps>
@@ -18,7 +18,7 @@ ClassroomIO uses organization roles to control what each person can do. This gui
Admins have full control over organization settings, billing, domains, and all courses. Tutors can manage content for courses they are assigned to but cannot create new courses or change org-wide settings. Students use the [Student Dashboard](/docs/student-dashboard) to learn. Admins have full control over organization settings, billing, domains, and all courses. Tutors can manage content for courses they are assigned to but cannot create new courses or change org-wide settings. Students use the [Student Dashboard](/docs/student-dashboard) to learn.
<Callout type="info" title="Per-person enroll or create toggles"> <Callout type="info" title="Per-person enroll or create toggles">
ClassroomIO does not support per-person flags like "this user can create courses but that user cannot." Access is set by role. To limit who joins a **course**, use course settings, invites, and **People** instead. See [How Course Enrollment Works](/docs/how-to-guides/course-enrollment). ClassroomIO does not support per-person flags like "this user can create courses but that user cannot." Access is set by role. To limit who joins a **course**, use course settings, invites, and **People** instead. See [How Course Enrollment Works](/docs/course-enrollment).
</Callout> </Callout>
## Who can create courses ## Who can create courses
@@ -38,7 +38,7 @@ Students sign in on your academy and land in the LMS. Admins and tutors can swit
## Inviting team members ## Inviting team members
To add Admins or Tutors, use [Inviting a Team Member](/docs/how-to-guides/invite-team-member). Team invites are separate from [student course invites](/docs/how-to-guides/invite-students). To add Admins or Tutors, use [Inviting a Team Member](/docs/invite-team-member). Team invites are separate from [student course invites](/docs/invite-students).
## Organization signup vs course enrollment ## Organization signup vs course enrollment
@@ -55,8 +55,8 @@ Configure org signup under **Settings → Organization**. Configure per-course a
## Related guides ## Related guides
- [How Course Enrollment Works](/docs/how-to-guides/course-enrollment) — restrict who can join each course - [How Course Enrollment Works](/docs/course-enrollment) — restrict who can join each course
- [How to Invite Students](/docs/how-to-guides/invite-students) — course enrollment links - [How to Invite Students](/docs/invite-students) — course enrollment links
- [Inviting a Team Member](/docs/how-to-guides/invite-team-member) — add admins and tutors - [Inviting a Team Member](/docs/invite-team-member) — add admins and tutors
- [Student Dashboard](/docs/student-dashboard) — what students see in the LMS - [Student Dashboard](/docs/student-dashboard) — what students see in the LMS
- [Organization](/docs/organization) — org settings overview - [Organization](/docs/organization) — org settings overview
+4 -4
View File
@@ -43,7 +43,7 @@ Both show enrolled courses. My Learning adds clearer filtering for students mana
**Explore** lists **published, active courses** in your organization that the student has **not** enrolled in yet. Each card links to the course landing page so they can read details and enroll (if enrollment is open). **Explore** lists **published, active courses** in your organization that the student has **not** enrolled in yet. Each card links to the course landing page so they can read details and enroll (if enrollment is open).
<Callout type="info" title="Explore is a catalog, not an enrollment gate"> <Callout type="info" title="Explore is a catalog, not an enrollment gate">
A course can appear in Explore even when public self-enrollment is turned off. **Allow New Students** controls whether someone can join from the landing page, not whether the course is visible in Explore. To restrict who can join, see [How Course Enrollment Works](/docs/how-to-guides/course-enrollment). A course can appear in Explore even when public self-enrollment is turned off. **Allow New Students** controls whether someone can join from the landing page, not whether the course is visible in Explore. To restrict who can join, see [How Course Enrollment Works](/docs/course-enrollment).
</Callout> </Callout>
Explore is useful when you want students to discover optional or elective courses without assigning them manually. Explore is useful when you want students to discover optional or elective courses without assigning them manually.
@@ -82,7 +82,7 @@ If you added them from **People** but they have not signed in yet, they may show
## Related guides ## Related guides
- [How Course Enrollment Works](/docs/how-to-guides/course-enrollment) — how students join courses and what settings control access - [How Course Enrollment Works](/docs/course-enrollment) — how students join courses and what settings control access
- [How to Invite Students](/docs/how-to-guides/invite-students) — send enrollment links by email - [How to Invite Students](/docs/invite-students) — send enrollment links by email
- [Programs](/docs/programs) — multi-course tracks and goals for admins - [Programs](/docs/programs) — multi-course tracks and goals for admins
- [Create a Course Landing Page](/docs/how-to-guides/course-landingpage) — what prospects see before enrolling - [Create a Course Landing Page](/docs/course-landingpage) — what prospects see before enrolling
@@ -28,6 +28,6 @@ The table shows 10 students per page. Use the page controls at the bottom to mov
## Related guides ## Related guides
- [Creating Your First Course](/docs/how-to-guides/create-first-course) — adding lessons to a course - [Creating Your First Course](/docs/create-first-course) — adding lessons to a course
- [How Course Enrollment Works](/docs/how-to-guides/course-enrollment) — how students join a course - [How Course Enrollment Works](/docs/course-enrollment) — how students join a course
- [Course](/docs/course) — course overview and available tabs - [Course](/docs/course) — course overview and available tabs

Some files were not shown because too many files have changed in this diff Show More