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:
@@ -61,3 +61,10 @@ company/
|
||||
# Claude Code local artifacts
|
||||
.claude/skills
|
||||
.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
|
||||
@@ -1,6 +0,0 @@
|
||||
services:
|
||||
- domain: cio-fe
|
||||
port: 5173
|
||||
- domain: cio-be
|
||||
port: 3002
|
||||
log_mode: minimal
|
||||
@@ -6,6 +6,8 @@ Use this file to keep a single prioritized view of all PRDs.
|
||||
|
||||
| Rank | List # | PRD | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| - | - | `prd/super-admin` | draft |
|
||||
| - | - | `prd/embed-link-platforms` | draft |
|
||||
| 1 | - | `prd/notification-system` | todo |
|
||||
| 2 | 14 | `prd/course-templates` | todo |
|
||||
| 3 | 5 | `prd/course-import` | todo |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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>
|
||||
<p align="center">
|
||||
The Open Source Learning Management System for Companies
|
||||
|
||||
@@ -35,6 +35,7 @@ import { courseAiTutorRouter } from '@api/routes/course/ai-tutor';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
||||
import { cloneCourse } from '@api/services/course/clone';
|
||||
import { sanitizeHtml } from '@cio/core/utils/sanitize-html';
|
||||
import { complianceRouter } from '@api/routes/course/compliance';
|
||||
import { contentRouter } from '@api/routes/course/content';
|
||||
import { courseMemberMiddleware } from '@api/middlewares/course-member';
|
||||
@@ -329,6 +330,13 @@ export const courseRouter = new Hono()
|
||||
const validatedData = c.req.valid('json');
|
||||
const { tagIds, ...courseData } = validatedData;
|
||||
|
||||
if (courseData.metadata?.welcomeEmailMessage) {
|
||||
courseData.metadata = {
|
||||
...courseData.metadata,
|
||||
welcomeEmailMessage: sanitizeHtml(courseData.metadata.welcomeEmailMessage)
|
||||
};
|
||||
}
|
||||
|
||||
const result = await updateCourse(courseId, courseData);
|
||||
|
||||
if (tagIds !== undefined) {
|
||||
|
||||
@@ -29,8 +29,14 @@ import {
|
||||
initVideoRecordingUpload
|
||||
} from '@api/services/exercise/video-recording';
|
||||
|
||||
import {
|
||||
getNotifyCourseExerciseStatusService,
|
||||
notifyCourseExerciseService
|
||||
} from '@api/services/course/notify-exercise';
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
import { courseTeamMemberMiddleware } from '@api/middlewares/course-team-member';
|
||||
import { authOrAutomationKeyMiddleware } from '@api/middlewares/auth-or-automation-key';
|
||||
import { courseMemberMiddleware } from '@api/middlewares/course-member';
|
||||
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');
|
||||
}
|
||||
})
|
||||
.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(
|
||||
'/:exerciseId',
|
||||
authOrAutomationKeyMiddleware,
|
||||
|
||||
@@ -38,6 +38,8 @@ import { Hono } from '@api/utils/hono';
|
||||
import { ZLessonDownloadContent } from '@cio/utils/validation/course';
|
||||
import { authMiddleware } from '@api/middlewares/auth';
|
||||
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 { getGroupMemberIdByCourseAndProfile } from '@cio/db/queries/group';
|
||||
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) => {
|
||||
try {
|
||||
const { lessonId } = c.req.valid('param');
|
||||
|
||||
@@ -3,9 +3,19 @@ import { runProgramGoalEvaluationSweep, runProgramGoalReminderScan } from '@api/
|
||||
|
||||
import { Hono } from '@api/utils/hono';
|
||||
import { apiKeyMiddleware } from '@api/middlewares/api-key';
|
||||
import { enqueueSessionReminderScan } from '@cio/jobs';
|
||||
import { handleError } from '@api/utils/errors';
|
||||
|
||||
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) => {
|
||||
try {
|
||||
const data = await runComplianceExpiryCheck();
|
||||
|
||||
@@ -29,11 +29,12 @@ import crypto from 'node:crypto';
|
||||
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
||||
import { getCourseTeachers } from '@cio/db/queries/course/people';
|
||||
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 { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth';
|
||||
import { generateSlug } from '@cio/utils/functions';
|
||||
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
|
||||
import { getWelcomeSessionIcs } from './session-invite';
|
||||
import { db } from '@cio/db/drizzle';
|
||||
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
|
||||
|
||||
@@ -358,20 +359,26 @@ async function sendStudentJoinEmails(input: {
|
||||
courseName: string;
|
||||
orgName: string;
|
||||
org: OrgUrlInfo;
|
||||
branding: EmailBranding;
|
||||
studentId: string;
|
||||
studentEmail: string;
|
||||
welcomeEmailMessage?: string | null;
|
||||
}) {
|
||||
try {
|
||||
const loginUrl = getDashboardBaseUrl(input.org);
|
||||
const ics = await getWelcomeSessionIcs(input.courseId);
|
||||
await enqueueTransactionalEmail('studentCourseWelcome', {
|
||||
to: input.studentEmail,
|
||||
fields: {
|
||||
orgName: input.orgName,
|
||||
courseName: input.courseName,
|
||||
loginUrl
|
||||
loginUrl,
|
||||
customMessage: input.welcomeEmailMessage ?? undefined,
|
||||
branding: input.branding
|
||||
},
|
||||
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`),
|
||||
idempotencyKey: `course-welcome:${input.courseId}:${input.studentId}`
|
||||
idempotencyKey: `course-welcome:${input.courseId}:${input.studentId}`,
|
||||
ics
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to enqueue student welcome email:', error);
|
||||
@@ -395,7 +402,8 @@ async function sendStudentJoinEmails(input: {
|
||||
fields: {
|
||||
courseName: input.courseName,
|
||||
studentName,
|
||||
studentEmail: input.studentEmail
|
||||
studentEmail: input.studentEmail,
|
||||
branding: input.branding
|
||||
},
|
||||
from: buildEmailFromName('ClassroomIO'),
|
||||
idempotencyKey: `teacher-student-joined:${input.courseId}:${input.studentId}`
|
||||
@@ -414,6 +422,7 @@ async function createEmailInviteAndSend(input: {
|
||||
courseName: string;
|
||||
courseSlug: string;
|
||||
org: OrgUrlInfo;
|
||||
branding: EmailBranding;
|
||||
metadata?: Record<string, unknown>;
|
||||
shouldSend: boolean;
|
||||
}) {
|
||||
@@ -450,7 +459,8 @@ async function createEmailInviteAndSend(input: {
|
||||
orgName: input.orgName,
|
||||
courseName: input.courseName,
|
||||
inviteLink: createdInvite.inviteLink,
|
||||
expiresAt: getExpiryLabel(createdInvite.expiresAt)
|
||||
expiresAt: getExpiryLabel(createdInvite.expiresAt),
|
||||
branding: input.branding
|
||||
},
|
||||
from: buildEmailFromName(`${input.orgName} (via ClassroomIO.com)`),
|
||||
idempotencyKey: `course-invite-email:${createdInvite.id}`
|
||||
@@ -549,6 +559,11 @@ export async function createStudentInvite(courseId: string, createdByProfileId:
|
||||
|
||||
const orgName = courseOrgData.orgName || 'ClassroomIO';
|
||||
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(
|
||||
recipients.valid.map((recipientEmail) =>
|
||||
@@ -561,6 +576,7 @@ export async function createStudentInvite(courseId: string, createdByProfileId:
|
||||
courseName,
|
||||
courseSlug,
|
||||
org: orgUrlInfo,
|
||||
branding: orgBranding,
|
||||
metadata: data.metadata,
|
||||
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);
|
||||
}
|
||||
|
||||
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 orgMemberId = await getOrganizationMemberIdByOrgAndProfile(org.id, user.id);
|
||||
@@ -677,8 +694,10 @@ export async function enrollInCourse(
|
||||
courseName: title,
|
||||
orgName: org.name,
|
||||
org: { siteName: org.siteName, customDomain: org.customDomain, isCustomDomainVerified: org.isCustomDomainVerified },
|
||||
branding: buildEmailBranding({ name: org.name, avatarUrl: org.avatarUrl, theme: org.theme }),
|
||||
studentId: user.id,
|
||||
studentEmail: normalizedEmail
|
||||
studentEmail: normalizedEmail,
|
||||
welcomeEmailMessage: courseMetadata?.welcomeEmailMessage
|
||||
});
|
||||
|
||||
trackServerEvent({
|
||||
@@ -944,7 +963,9 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
||||
orgName: organization.name,
|
||||
orgSiteName: organization.siteName,
|
||||
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,
|
||||
orgSiteName: organization.siteName,
|
||||
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,
|
||||
isCustomDomainVerified: result.orgIsCustomDomainVerified
|
||||
},
|
||||
branding: buildEmailBranding({
|
||||
name: result.orgName,
|
||||
avatarUrl: result.orgAvatarUrl,
|
||||
theme: result.orgTheme
|
||||
}),
|
||||
studentId: user.id,
|
||||
studentEmail: normalizedEmail
|
||||
studentEmail: normalizedEmail,
|
||||
welcomeEmailMessage: result.welcomeEmailMessage
|
||||
});
|
||||
|
||||
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 { getCourseWithOrgData } from '@cio/db/queries/course';
|
||||
import { buildEmailFromName } from '@cio/email';
|
||||
import { buildEmailFromName, buildEmailBranding } from '@cio/email';
|
||||
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
|
||||
|
||||
@@ -27,6 +27,11 @@ export async function createPaymentRequest(data: PaymentRequestData) {
|
||||
|
||||
const courseName = course.courseTitle || '';
|
||||
const orgName = course.orgName || 'ClassroomIO';
|
||||
const branding = buildEmailBranding({
|
||||
name: course.orgName,
|
||||
avatarUrl: course.orgAvatarUrl,
|
||||
theme: course.orgTheme
|
||||
});
|
||||
const groupId = course.groupId;
|
||||
|
||||
if (!groupId) {
|
||||
@@ -48,7 +53,8 @@ export async function createPaymentRequest(data: PaymentRequestData) {
|
||||
fields: {
|
||||
courseName,
|
||||
studentEmail: data.studentEmail,
|
||||
studentFullname: data.studentFullname
|
||||
studentFullname: data.studentFullname,
|
||||
branding
|
||||
},
|
||||
from: buildEmailFromName('ClassroomIO'),
|
||||
idempotencyKey: `payment-request:teacher:${data.courseId}:${data.studentEmail}`
|
||||
@@ -64,7 +70,8 @@ export async function createPaymentRequest(data: PaymentRequestData) {
|
||||
courseName,
|
||||
teacherEmail,
|
||||
studentFullname: data.studentFullname,
|
||||
orgName
|
||||
orgName,
|
||||
branding
|
||||
},
|
||||
from: buildEmailFromName(`${orgName} - ClassroomIO`),
|
||||
replyTo: teacherEmail,
|
||||
|
||||
@@ -14,9 +14,10 @@ import type { TGroupmember } from '@cio/db/types';
|
||||
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
||||
import { getCourseWithOrgData } from '@cio/db/queries/course';
|
||||
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 { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
|
||||
import { getWelcomeSessionIcs } from './session-invite';
|
||||
|
||||
/**
|
||||
* Gets all course members (people) for a course
|
||||
@@ -68,6 +69,11 @@ export async function addMember(
|
||||
if (courseOrgData) {
|
||||
const courseName = courseOrgData.courseTitle || '';
|
||||
const orgName = courseOrgData.orgName || 'ClassroomIO';
|
||||
const branding = buildEmailBranding({
|
||||
name: courseOrgData.orgName,
|
||||
avatarUrl: courseOrgData.orgAvatarUrl,
|
||||
theme: courseOrgData.orgTheme
|
||||
});
|
||||
const loginUrl = getDashboardBaseUrl({
|
||||
siteName: courseOrgData.orgSiteName,
|
||||
customDomain: courseOrgData.orgCustomDomain,
|
||||
@@ -97,10 +103,13 @@ export async function addMember(
|
||||
fields: {
|
||||
orgName,
|
||||
courseName,
|
||||
loginUrl
|
||||
loginUrl,
|
||||
customMessage: courseOrgData.welcomeEmailMessage ?? undefined,
|
||||
branding
|
||||
},
|
||||
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) {
|
||||
console.error('Failed to enqueue student welcome email:', emailError);
|
||||
@@ -119,7 +128,8 @@ export async function addMember(
|
||||
fields: {
|
||||
courseName,
|
||||
studentName,
|
||||
studentEmail
|
||||
studentEmail,
|
||||
branding
|
||||
},
|
||||
from: buildEmailFromName('ClassroomIO'),
|
||||
idempotencyKey: `course-people-teacher-joined:${courseId}:${studentEmail}`
|
||||
@@ -182,6 +192,11 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
|
||||
const courseName = courseOrgData.courseTitle || '';
|
||||
const orgName = courseOrgData.orgName || 'ClassroomIO';
|
||||
const orgSiteName = courseOrgData.orgSiteName || '';
|
||||
const branding = buildEmailBranding({
|
||||
name: courseOrgData.orgName,
|
||||
avatarUrl: courseOrgData.orgAvatarUrl,
|
||||
theme: courseOrgData.orgTheme
|
||||
});
|
||||
|
||||
// Add all members
|
||||
const addedMembers = await Promise.all(members.map((member) => addCourseMember(courseId, member)));
|
||||
@@ -217,7 +232,8 @@ export async function addMembers(courseId: string, members: TAddCourseMembers) {
|
||||
name,
|
||||
orgName,
|
||||
courseName,
|
||||
inviteLink
|
||||
inviteLink,
|
||||
branding
|
||||
},
|
||||
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ export interface EnqueueTemplateEmailInput<TId extends EmailId> extends CommonOp
|
||||
fields: z.infer<EmailSchemaFor<TId>>;
|
||||
from?: string;
|
||||
replyTo?: string;
|
||||
/** Optional iCalendar (.ics) body attached as a text/calendar part. */
|
||||
ics?: string;
|
||||
}
|
||||
|
||||
export interface EnqueueRawEmailInput extends CommonOptions {
|
||||
@@ -81,7 +83,8 @@ export async function enqueueTransactionalEmail<TId extends EmailId>(
|
||||
to: recipient,
|
||||
fields: validatedFields,
|
||||
from: input.from,
|
||||
replyTo: input.replyTo
|
||||
replyTo: input.replyTo,
|
||||
ics: input.ics
|
||||
},
|
||||
{ idempotencyKey: recipientKey(input.idempotencyKey, recipient, recipients.length) }
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from '@cio/db/queries/newsfeed';
|
||||
|
||||
import { env } from '@cio/core/config/env';
|
||||
import { buildEmailFromName } from '@cio/email';
|
||||
import { buildEmailFromName, buildEmailBranding } from '@cio/email';
|
||||
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||
|
||||
/**
|
||||
@@ -359,6 +359,11 @@ async function sendNewsfeedPostEmail(feedId: string, authorId: string) {
|
||||
|
||||
const orgName = feedData.organization?.name || 'ClassroomIO';
|
||||
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 recipients = feedData.courseMembers
|
||||
@@ -374,7 +379,8 @@ async function sendNewsfeedPostEmail(feedId: string, authorId: string) {
|
||||
teacherName: feedData.author?.fullname || 'A teacher',
|
||||
content: feedData.content || '',
|
||||
postLink,
|
||||
orgName
|
||||
orgName,
|
||||
branding
|
||||
},
|
||||
from: buildEmailFromName(`${orgName} - ClassroomIO`),
|
||||
replyTo: feedData.author?.email || '[email protected]',
|
||||
@@ -403,6 +409,11 @@ async function sendNewsfeedCommentEmail(feedId: string, commentContent: string)
|
||||
|
||||
const orgName = feedData.organization?.name || 'ClassroomIO';
|
||||
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}`;
|
||||
|
||||
await enqueueTransactionalEmail('newsfeedComment', {
|
||||
@@ -411,7 +422,8 @@ async function sendNewsfeedCommentEmail(feedId: string, commentContent: string)
|
||||
courseTitle: feedData.courseTitle,
|
||||
comment: commentContent,
|
||||
postLink,
|
||||
orgName
|
||||
orgName,
|
||||
branding
|
||||
},
|
||||
from: buildEmailFromName(`${orgName} - ClassroomIO`),
|
||||
replyTo: '[email protected]'
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
TImportAudienceMembers
|
||||
} from '@cio/utils/validation/organization';
|
||||
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 { addProgramMember, getExistingProgramMembers, getProgramsByOrg } from '@cio/db/queries/program';
|
||||
import {
|
||||
@@ -30,6 +30,7 @@ import crypto from 'node:crypto';
|
||||
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
||||
import { getProfilesByEmails } from '@cio/db/queries/auth';
|
||||
import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance';
|
||||
import { getWelcomeSessionIcs } from '../course/session-invite';
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
const ORG_INVITE_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
@@ -62,8 +63,11 @@ function generateToken(): string {
|
||||
return crypto.randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
function buildInviteLink(token: string): string {
|
||||
return `${getDashboardBaseUrl()}/invite/${encodeURIComponent(token)}`;
|
||||
function buildInviteLink(
|
||||
token: string,
|
||||
org?: { siteName?: string | null; customDomain?: string | null; isCustomDomainVerified?: boolean | null }
|
||||
): string {
|
||||
return `${getDashboardBaseUrl(org)}/invite/${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
function getExpiryLabel(expiresAtIso: string): string {
|
||||
@@ -170,6 +174,7 @@ async function enrollAudienceStudentProfilesInCourses(
|
||||
|
||||
const validGroupIds = courseGroups.map((cg) => cg.groupId).filter(Boolean) as string[];
|
||||
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 pairs = validProfiles.flatMap((profileId) => validGroupIds.map((groupId) => ({ groupId, profileId })));
|
||||
@@ -198,6 +203,10 @@ async function enrollAudienceStudentProfilesInCourses(
|
||||
const loginUrl = getDashboardBaseUrl(organization);
|
||||
|
||||
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
|
||||
.filter((p) => profileEmailMap.get(p.profileId))
|
||||
.map(async (p) => {
|
||||
@@ -208,10 +217,13 @@ async function enrollAudienceStudentProfilesInCourses(
|
||||
fields: {
|
||||
orgName: organization.name,
|
||||
courseName: courseTitleByGroupId.get(p.groupId) || 'Course',
|
||||
loginUrl
|
||||
loginUrl,
|
||||
customMessage: welcomeMessageByGroupId.get(p.groupId) ?? undefined,
|
||||
branding: buildEmailBranding(organization)
|
||||
},
|
||||
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++;
|
||||
} catch (emailError) {
|
||||
@@ -290,7 +302,8 @@ async function enrollAudienceStudentProfilesInPrograms(
|
||||
fields: {
|
||||
orgName: organization.name,
|
||||
programName: programNameById.get(pair.programId) || 'Program',
|
||||
loginUrl
|
||||
loginUrl,
|
||||
branding: buildEmailBranding(organization)
|
||||
},
|
||||
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
|
||||
idempotencyKey: `audience-program-welcome:${pair.programId}:${pair.profileId}`
|
||||
@@ -387,7 +400,7 @@ async function createStudentOrgInvitesAndSendEmails(input: {
|
||||
const invite = inviteByEmail.get(email)!;
|
||||
const token = tokenByEmail.get(email)!;
|
||||
try {
|
||||
const inviteLink = buildInviteLink(token);
|
||||
const inviteLink = buildInviteLink(token, organization);
|
||||
await enqueueTransactionalEmail('studentOrgInvite', {
|
||||
to: email,
|
||||
fields: {
|
||||
@@ -395,7 +408,8 @@ async function createStudentOrgInvitesAndSendEmails(input: {
|
||||
orgName: organization.name,
|
||||
inviteLink,
|
||||
expiresAt: getExpiryLabel(expiresAt),
|
||||
courseNames: accessNamesLabel
|
||||
courseNames: accessNamesLabel,
|
||||
branding: buildEmailBranding(organization)
|
||||
},
|
||||
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
|
||||
idempotencyKey: `student-org-invite:${invite.id}`
|
||||
@@ -677,7 +691,7 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
|
||||
|
||||
let emailSent = false;
|
||||
try {
|
||||
const inviteLink = buildInviteLink(token);
|
||||
const inviteLink = buildInviteLink(token, organization);
|
||||
await enqueueTransactionalEmail('studentOrgInvite', {
|
||||
to: emailToUse,
|
||||
fields: {
|
||||
@@ -685,7 +699,8 @@ export async function resendAudienceInvite(orgId: string, data: TAudienceInviteB
|
||||
orgName: organization.name,
|
||||
inviteLink,
|
||||
expiresAt: getExpiryLabel(expiresAt),
|
||||
courseNames: accessNamesLabel
|
||||
courseNames: accessNamesLabel,
|
||||
branding: buildEmailBranding(organization)
|
||||
},
|
||||
from: buildEmailFromName(`${organization.name} (via ClassroomIO.com)`),
|
||||
idempotencyKey: `student-org-invite:${invite.id}`
|
||||
|
||||
@@ -32,6 +32,7 @@ import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
||||
import { parseCourseIdsFromInviteMetadata, parseProgramIdsFromInviteMetadata } from '@api/utils/org';
|
||||
import { markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
|
||||
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||
import { buildEmailBranding } from '@cio/email';
|
||||
import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance';
|
||||
|
||||
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))];
|
||||
}
|
||||
|
||||
function buildInviteLink(token: string): string {
|
||||
return `${getDashboardBaseUrl()}/invite/${encodeURIComponent(token)}`;
|
||||
function buildInviteLink(
|
||||
token: string,
|
||||
org?: { siteName?: string | null; customDomain?: string | null; isCustomDomainVerified?: boolean | null }
|
||||
): string {
|
||||
return `${getDashboardBaseUrl(org)}/invite/${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
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 {
|
||||
await enqueueTransactionalEmail('inviteTeacher', {
|
||||
@@ -256,7 +260,8 @@ export async function inviteTeamMembers(orgId: string, emails: string[], roleId:
|
||||
orgSiteName: organization.siteName,
|
||||
roleName,
|
||||
expiresAt: getExpiryLabel(expiresAt),
|
||||
inviteLink
|
||||
inviteLink,
|
||||
branding: buildEmailBranding(organization)
|
||||
},
|
||||
idempotencyKey: `org-invite-teacher:${invite.id}`
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
||||
import { buildEmailBranding } from '@cio/email';
|
||||
import {
|
||||
countAssignmentsByStatus,
|
||||
createProgramGoal,
|
||||
@@ -425,7 +426,12 @@ export async function runProgramGoalReminderScan(): Promise<{
|
||||
daysUntilDue,
|
||||
completedCount: row.assignment.completedCount ?? 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}`
|
||||
});
|
||||
|
||||
@@ -12,8 +12,8 @@ import type {
|
||||
TSubmissionGradesUpdate,
|
||||
TSubmissionUpdate
|
||||
} from '@cio/utils/validation/submission';
|
||||
import { buildEmailFromName } from '@cio/email';
|
||||
import { enqueueRawEmail } from '@api/services/jobs';
|
||||
import { buildEmailBranding, buildEmailFromName } from '@cio/email';
|
||||
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||
import {
|
||||
createSubmission,
|
||||
deleteSubmission,
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
getQuestionTypeByTypename,
|
||||
requiresManualGrading
|
||||
} 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 { getExerciseById, getExerciseWithRelationsOptimized } from '@cio/db/queries/exercise';
|
||||
import { getGroupMemberIdByCourseAndProfile, isCourseTeamMemberOrOrgAdmin } from '@cio/db/queries/group';
|
||||
@@ -578,7 +578,8 @@ export async function createSubmissionService(
|
||||
.map((question) => question.questionType?.typename ?? '')
|
||||
.filter((typename) => typename.length > 0)
|
||||
);
|
||||
const gradingState: SubmissionGradingState = 'queued';
|
||||
const gradingState: SubmissionGradingState =
|
||||
overallStatus === 'manual_required' || overallStatus === 'hybrid' ? 'awaiting_manual' : 'queued';
|
||||
|
||||
const submissionData: TNewSubmission = {
|
||||
courseId,
|
||||
@@ -869,7 +870,6 @@ export async function updateSubmissionGradesBatch(
|
||||
const currentGradingState = resolveSubmissionGradingState(submission);
|
||||
const targetGradingState: SubmissionGradingState = 'completed';
|
||||
const targetStatusId = projectLegacyStatusId(targetGradingState);
|
||||
|
||||
if (!isAllowedGradingTransition(currentGradingState, targetGradingState)) {
|
||||
throw new AppError('Invalid submission workflow transition', ErrorCodes.VALIDATION_ERROR, 400);
|
||||
}
|
||||
@@ -965,13 +965,17 @@ async function sendSubmissionUpdateEmail(submissionId: string, newStatusId: numb
|
||||
return;
|
||||
}
|
||||
|
||||
// Get organization name
|
||||
const orgResult = await getOrganizationByCourseId(fullSubmission.courseId || '');
|
||||
// Get organization name and URL info
|
||||
const orgResult = await getCourseWithOrgData(fullSubmission.courseId || '');
|
||||
|
||||
const orgName = orgResult?.orgName || 'ClassroomIO';
|
||||
|
||||
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}`;
|
||||
|
||||
// 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 maxMark = (fullSubmission.exercise.questions || []).reduce((sum: number, q: any) => sum + (q.points || 0), 0);
|
||||
|
||||
let content = `
|
||||
<p>Hello ${fullSubmission.groupmember.profile.fullname || 'Student'},</p>
|
||||
<p>The status of your submitted exercise on <strong>${fullSubmission.exercise.title}</strong> has been updated to ${statusText}</p>
|
||||
`;
|
||||
const isGraded = newStatusId === 3;
|
||||
const score = isGraded ? `${totalMark}/${maxMark}` : undefined;
|
||||
|
||||
if (newStatusId === 3) {
|
||||
content += `
|
||||
<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>`;
|
||||
try {
|
||||
await enqueueTransactionalEmail('submissionGraded', {
|
||||
to: fullSubmission.groupmember.profile.email,
|
||||
fields: {
|
||||
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}`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to enqueue submission graded email:', error);
|
||||
}
|
||||
|
||||
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,
|
||||
subject: 'Submission Update',
|
||||
content,
|
||||
idempotencyKey: `submission-update:${submissionId}:${newStatusId}`
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1042,32 +1042,43 @@ async function sendExerciseSubmissionUpdateEmail(courseId: string, exerciseId: s
|
||||
return;
|
||||
}
|
||||
|
||||
// Get organization name
|
||||
const orgResult = await getOrganizationByCourseId(courseId);
|
||||
// Get organization name and URL info
|
||||
const orgResult = await getCourseWithOrgData(courseId);
|
||||
|
||||
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 submissionLink = `${baseUrl}/courses/${courseId}/submissions`;
|
||||
|
||||
const content = `
|
||||
<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 studentName = student.fullname || student.username || 'A student';
|
||||
|
||||
const tutorEmails = tutorsResult.map((tutor) => tutor.email).filter((email): email is string => Boolean(email));
|
||||
if (tutorEmails.length === 0) return;
|
||||
|
||||
await enqueueRawEmail({
|
||||
from: buildEmailFromName(`${orgName} (via ClassroomIO.com)`),
|
||||
to: tutorEmails,
|
||||
subject: `[Submitted]: ${exercise.title}`,
|
||||
content,
|
||||
idempotencyKey: `exercise-submitted:${courseId}:${exerciseId}:${submittedBy}`
|
||||
});
|
||||
try {
|
||||
await enqueueTransactionalEmail('submissionReceived', {
|
||||
to: tutorEmails,
|
||||
fields: {
|
||||
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}`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to enqueue submission received email:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from '@cio/db/queries/course/course';
|
||||
import { setMemberCertificateEarned, setMemberCertificationEmailSent } from '@cio/db/queries/course/people';
|
||||
import { getProfileById } from '@cio/db/queries/auth';
|
||||
import { buildEmailFromName } from '@cio/email';
|
||||
import { buildEmailFromName, buildEmailBranding } from '@cio/email';
|
||||
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||
|
||||
export function calcCourseProgressPercent(params: {
|
||||
@@ -81,7 +81,12 @@ export function scheduleCertificationCompletionWork(params: {
|
||||
courseName: courseRow.title,
|
||||
studentName: profile?.fullname || studentEmail,
|
||||
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)`),
|
||||
idempotencyKey: `course-completion:${groupMemberId}`
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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>
|
||||
<p align="center">
|
||||
The Open Source Teaching Platform for Bootcamps
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
|
||||
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
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
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
+1
@@ -140,6 +140,7 @@
|
||||
{/each}
|
||||
</select>
|
||||
<Field.Description>{$t('course.certification.final_exercise_helper')}</Field.Description>
|
||||
<Field.Description>{$t('course.certification.final_exercise_multiple_attempts_note')}</Field.Description>
|
||||
</Field.Field>
|
||||
|
||||
{#if courseApi.course?.certificate?.requiredExerciseId}
|
||||
|
||||
+61
-10
@@ -4,21 +4,34 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Dialog from '@cio/ui/base/dialog';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { Confetti } from '$features/ui';
|
||||
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 { getContentRoute } from '$features/course/utils/content';
|
||||
import { formatBlockerMessage } from '$features/course/utils/certificate-utils';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
let open = $state(false);
|
||||
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(() => {
|
||||
return courseCompletionModal.subscribe((v) => {
|
||||
if (v?.open && v.courseId) {
|
||||
activeCourseId = v.courseId;
|
||||
activeStep = v.step;
|
||||
activeEvaluation = v.evaluation as Record<string, unknown> | null;
|
||||
activeExerciseId = v.exerciseId;
|
||||
open = true;
|
||||
setConfetti(true);
|
||||
setTimeout(() => setConfetti(false), 5500);
|
||||
|
||||
if (v.step === 'eligible' && !v.evaluation?.certificateEarnedAt) {
|
||||
setConfetti(true);
|
||||
setTimeout(() => setConfetti(false), 5500);
|
||||
}
|
||||
} else {
|
||||
open = false;
|
||||
}
|
||||
@@ -37,6 +50,24 @@
|
||||
onOpenChange(false);
|
||||
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>
|
||||
|
||||
<div class="pointer-events-none fixed inset-0 z-100">
|
||||
@@ -45,13 +76,33 @@
|
||||
|
||||
<Dialog.Root {open} {onOpenChange}>
|
||||
<Dialog.Content class="ui:max-w-md">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{$t('course.completion.modal.title')}</Dialog.Title>
|
||||
<Dialog.Description>{$t('course.completion.modal.subtitle')}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer class="ui:gap-2">
|
||||
<Button variant="outline" onclick={() => onOpenChange(false)}>{$t('course.completion.modal.later')}</Button>
|
||||
<Button onclick={goToCertificate}>{$t('course.completion.modal.view_certificate')}</Button>
|
||||
</Dialog.Footer>
|
||||
{#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.Title>{$t('course.completion.modal.title')}</Dialog.Title>
|
||||
<Dialog.Description>{$t('course.completion.modal.subtitle')}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<Dialog.Footer class="ui:gap-2">
|
||||
<Button variant="outline" onclick={() => onOpenChange(false)}>{$t('course.completion.modal.later')}</Button>
|
||||
<Button onclick={goToCertificate}>{$t('course.completion.modal.view_certificate')}</Button>
|
||||
</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.Root>
|
||||
|
||||
+1
-36
@@ -11,7 +11,7 @@
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
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';
|
||||
|
||||
let isLoading = $state(false);
|
||||
@@ -21,41 +21,6 @@
|
||||
|
||||
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() {
|
||||
return {
|
||||
studentName: $profile.fullname || 'Recipient',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import EyeIcon from '@lucide/svelte/icons/eye';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Waves } from '@cio/ui/custom/animation';
|
||||
import { page } from '$app/state';
|
||||
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
@@ -58,9 +59,22 @@
|
||||
|
||||
<span class="grow"></span>
|
||||
|
||||
<Button variant="outline" size="sm" onclick={toggleAiAssistant}>
|
||||
<SparklesIcon size={14} />
|
||||
{$t('course.navItems.nav_ai_assistant')}
|
||||
<Button
|
||||
size="sm"
|
||||
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>
|
||||
|
||||
{#if !$isStudentExperience}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
shouldDeleteExercise?: boolean;
|
||||
exerciseId: string;
|
||||
goBack?: () => void;
|
||||
/** Self-paced + all auto-gradable question types: enforce points > 0 */
|
||||
/** All auto-gradable question types (any course): enforce points > 0 for scoring */
|
||||
requiresPositivePointsForAutoGrade?: boolean;
|
||||
selfPacedCourse?: boolean;
|
||||
/** Public course restricts picker to auto-gradable question types only. */
|
||||
|
||||
@@ -40,7 +40,11 @@
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { getOrderedNavigableContent } from '$features/course/utils/content';
|
||||
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 { toExerciseQuestionModel } from './question-type-utils';
|
||||
import { getExerciseQuestionLabels } from './question-labels';
|
||||
@@ -152,7 +156,19 @@
|
||||
$isOrgStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete);
|
||||
|
||||
if (allComplete && courseApi.course?.id) {
|
||||
const requiredExerciseId = courseApi.course?.certificate?.requiredExerciseId ?? undefined;
|
||||
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.unlock')}
|
||||
{@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 isLockPending = Boolean(lockPendingItemKeys[getItemLockKey(item)])}
|
||||
{@const actionDisabled = Boolean($contentEditingStore && !isEditingItem)}
|
||||
|
||||
+17
-1
@@ -14,7 +14,11 @@
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
@@ -135,7 +139,19 @@
|
||||
navigableContentItems.every((item) => item.isComplete);
|
||||
|
||||
if (allComplete) {
|
||||
const requiredExerciseId = courseApi.course?.certificate?.requiredExerciseId ?? undefined;
|
||||
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 {
|
||||
snackbar.error('snackbar.lessons.error.try_later');
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import LockIcon from '@lucide/svelte/icons/lock';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { RadioIcon } from '@cio/ui/custom/moving-icons';
|
||||
import { CircleCheckIcon } from '$features/ui/icons';
|
||||
import type { Snippet } from 'svelte';
|
||||
import CourseContentIcon from '$features/course/components/course-content-icon.svelte';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
@@ -62,6 +65,16 @@
|
||||
<CourseContentIcon type={item.type} size={iconSize} />
|
||||
{/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
|
||||
href={resolve(href, {})}
|
||||
class={cn(
|
||||
|
||||
+1
-1
@@ -517,7 +517,7 @@
|
||||
? $t('course.navItem.lessons.add_lesson.lock')
|
||||
: $t('course.navItem.lessons.add_lesson.unlock')}
|
||||
{@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 isLockPending = Boolean(lockPendingItemKeys[getItemLockKey(item)])}
|
||||
{@const actionDisabled = Boolean($contentEditingStore && !isEditingItem)}
|
||||
|
||||
+210
-28
@@ -1,14 +1,123 @@
|
||||
<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 { 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 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 { getBrowserTimezone, instantToZonedWallClock, zonedWallClockToInstant } from '$lib/utils/functions/date';
|
||||
import { getVideoTitle, isHlsUploadVideo, type LessonVideo } from './video/video-card-utils';
|
||||
|
||||
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 videoWatchThreshold = $derived(lessonApi.lesson?.videoWatchThreshold ?? 95);
|
||||
const videos = $derived(lessonApi.lesson?.videos ?? []);
|
||||
@@ -63,6 +172,59 @@
|
||||
|
||||
<div class="p-4">
|
||||
<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.Legend>{$t('course.navItem.lessons.settings.progression.heading')}</Field.Legend>
|
||||
<Field.Description>{$t('course.navItem.lessons.settings.progression.intro')}</Field.Description>
|
||||
@@ -72,9 +234,36 @@
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label for="lesson-completion-policy">
|
||||
{$t('course.navItem.lessons.completion_policy.label')}
|
||||
</Field.Label>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Field.Label for="lesson-completion-policy">
|
||||
{$t('course.navItem.lessons.completion_policy.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.Trigger id="lesson-completion-policy" class="w-full max-w-xs">
|
||||
{policyTriggerLabel(completionPolicy)}
|
||||
@@ -108,30 +297,6 @@
|
||||
</Select.Root>
|
||||
</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'}
|
||||
<Field.Field>
|
||||
<InputField
|
||||
@@ -181,3 +346,20 @@
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
</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} />
|
||||
+19
-4
@@ -12,6 +12,7 @@
|
||||
import { CircleCheckIcon } from '$features/ui/icons';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
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';
|
||||
|
||||
interface Props {
|
||||
@@ -29,6 +30,16 @@
|
||||
const canOpenContentModal = $derived(Boolean(onOpenContentModal) && !isStudent);
|
||||
</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}>
|
||||
{#if contentData.grouped}
|
||||
{#each contentData.sections as section (section.id)}
|
||||
@@ -74,7 +85,7 @@
|
||||
<Sidebar.MenuSubButton isActive={(path || page.url.pathname).includes(contentItem.id)}>
|
||||
{#snippet child({ props })}
|
||||
{@const isContentLocked = (contentItem.isUnlocked ?? true) === false}
|
||||
{@const isLockedForStudent = isStudent && isContentLocked}
|
||||
{@const isLockedForStudent = isStudent && (isContentLocked || contentItem.accessible === false)}
|
||||
<a
|
||||
href={resolve(getContentRoute(id, contentItem), {})}
|
||||
aria-disabled={isLockedForStudent}
|
||||
@@ -97,7 +108,9 @@
|
||||
<CircleCheckIcon size={16} filled />
|
||||
</span>
|
||||
{/if}
|
||||
{#if isContentLocked}
|
||||
{#if contentItem.type === ContentType.Lesson && contentItem.callUrl}
|
||||
{@render liveSessionDot()}
|
||||
{:else if isContentLocked || isLockedForStudent}
|
||||
<span
|
||||
class="shrink-0"
|
||||
title={$t('course.navItem.lessons.add_lesson.lock')}
|
||||
@@ -124,7 +137,7 @@
|
||||
<Sidebar.MenuSubButton isActive={(path || page.url.pathname).includes(contentItem.id)}>
|
||||
{#snippet child({ props })}
|
||||
{@const isContentLocked = (contentItem.isUnlocked ?? true) === false}
|
||||
{@const isLockedForStudent = isStudent && isContentLocked}
|
||||
{@const isLockedForStudent = isStudent && (isContentLocked || contentItem.accessible === false)}
|
||||
<a
|
||||
href={resolve(getContentRoute(id, contentItem), {})}
|
||||
aria-disabled={isLockedForStudent}
|
||||
@@ -145,7 +158,9 @@
|
||||
<CircleCheckIcon size={16} filled />
|
||||
</span>
|
||||
{/if}
|
||||
{#if isContentLocked}
|
||||
{#if contentItem.type === ContentType.Lesson && contentItem.callUrl}
|
||||
{@render liveSessionDot()}
|
||||
{:else if isContentLocked || isLockedForStudent}
|
||||
<span
|
||||
class="shrink-0"
|
||||
title={$t('course.navItem.lessons.add_lesson.lock')}
|
||||
|
||||
@@ -350,13 +350,14 @@
|
||||
return typeIds.length > 0 && typeIds.every((id) => isAutoGradableQuestionType(getQuestionTypeKeyFromId(id)));
|
||||
}
|
||||
|
||||
/** When true, every question must have points > 0 before save */
|
||||
const requiresPositivePointsForAutoGrade = $derived(
|
||||
isSelfPacedCourse && isExerciseFullyAutoGradable(activeQuestionTypeIds)
|
||||
);
|
||||
const isFullyAutoGradable = $derived(isExerciseFullyAutoGradable(activeQuestionTypeIds));
|
||||
|
||||
/** When true, every question must have points > 0 before save (for meaningful auto-grading) */
|
||||
const requiresPositivePointsForAutoGrade = $derived(isFullyAutoGradable);
|
||||
|
||||
const teacherAutoGradeBadge = $derived.by(() => {
|
||||
if ($isOrgStudent || !isSelfPacedCourse || activeQuestionTypeIds.length === 0) return null;
|
||||
return isExerciseFullyAutoGradable(activeQuestionTypeIds) ? ('auto' as const) : ('manual' as const);
|
||||
if ($isOrgStudent || isPublicCourse || activeQuestionTypeIds.length === 0) return null;
|
||||
return isFullyAutoGradable ? ('auto' as const) : ('manual' as const);
|
||||
});
|
||||
|
||||
async function scrollToExerciseElement(elementId: string) {
|
||||
@@ -498,6 +499,16 @@
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.reorder')}
|
||||
</DropdownMenu.Item>
|
||||
{/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.Item
|
||||
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 { getOrderedNavigableContent } from '$features/course/utils/content';
|
||||
import StudentContentLockedNotice from '$features/course/components/student-content-locked-notice.svelte';
|
||||
import LiveSessionCard from '$features/course/components/lesson/live-session-card.svelte';
|
||||
|
||||
interface Props {
|
||||
courseId: string;
|
||||
@@ -94,6 +95,7 @@
|
||||
const isLessonUnlocked = $derived(!isTeacherLocked && !isProgressionLocked);
|
||||
const lessonSlug = $derived(lessonApi.lesson?.slug ?? '');
|
||||
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]) {
|
||||
const params = new SvelteURLSearchParams($page.url.searchParams);
|
||||
@@ -445,9 +447,18 @@
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<div
|
||||
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'}`}
|
||||
>
|
||||
<div class={`overflow-x-hidden pb-6 ${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}
|
||||
<StudentContentLockedNotice
|
||||
reason={isProgressionLocked ? 'progression_locked' : 'teacher_locked'}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
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 ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
@@ -15,7 +16,7 @@
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { InputField } from '@cio/ui/custom/input-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 { settings } from '$features/course/utils/settings-store';
|
||||
@@ -159,7 +160,8 @@
|
||||
lessonDownload: $settings.lessonDownload,
|
||||
allowNewStudent: $settings.allowNewStudents ?? false,
|
||||
isContentGroupingEnabled: $settings.isContentGroupingEnabled,
|
||||
progressionMode: $settings.progressionMode
|
||||
progressionMode: $settings.progressionMode,
|
||||
welcomeEmailMessage: $settings.welcomeEmailMessage?.trim() ? $settings.welcomeEmailMessage : null
|
||||
} as NonNullable<Course['metadata']>;
|
||||
|
||||
const updatedCourse = {
|
||||
@@ -224,7 +226,8 @@
|
||||
allowNewStudents: !!course.metadata?.allowNewStudent,
|
||||
isContentGroupingEnabled: course.metadata?.isContentGroupingEnabled ?? true,
|
||||
progressionMode: course.metadata?.progressionMode ?? 'free',
|
||||
callout: normalizeCallout(course.callout)
|
||||
callout: normalizeCallout(course.callout),
|
||||
welcomeEmailMessage: course.metadata?.welcomeEmailMessage ?? ''
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -350,7 +353,7 @@
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button onclick={widgetControl}>
|
||||
<Button variant="secondary" onclick={widgetControl}>
|
||||
{$t('course.navItem.settings.replace')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={deleteBannerImage}>
|
||||
@@ -443,6 +446,83 @@
|
||||
|
||||
<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.Legend>{$t('course.navItem.settings.tags.title')}</Field.Legend>
|
||||
<Field.Description>{$t('course.navItem.settings.tags.description')}</Field.Description>
|
||||
@@ -592,50 +672,6 @@
|
||||
</Field.Field>
|
||||
</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}
|
||||
<Field.Separator />
|
||||
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
import { writable } from 'svelte/store';
|
||||
import type { CertificationEvaluationData } from '$features/course/utils/types';
|
||||
|
||||
export type ModalStep = 'checking' | 'eligible' | 'not-eligible';
|
||||
|
||||
export type CourseCompletionModalState = {
|
||||
open: boolean;
|
||||
courseId: string;
|
||||
step: ModalStep;
|
||||
evaluation: CertificationEvaluationData | null;
|
||||
/** Exercise to retake when not-eligible */
|
||||
exerciseId?: string;
|
||||
};
|
||||
|
||||
export const courseCompletionModal = writable<CourseCompletionModalState | null>(null);
|
||||
|
||||
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() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { resolveTemplateId } from '@cio/certificates';
|
||||
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
|
||||
@@ -12,6 +13,46 @@ export function parseCertificateThemeId(raw: string | null | undefined): Certifi
|
||||
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()`. */
|
||||
export function normalizeCertificateIssuedAt(value: string | null | undefined): string {
|
||||
if (!value) return new Date().toISOString();
|
||||
|
||||
@@ -15,6 +15,7 @@ type CourseSettings = {
|
||||
isContentGroupingEnabled: boolean;
|
||||
progressionMode: 'free' | 'sequential';
|
||||
callout: TCourseCallout | null;
|
||||
welcomeEmailMessage: string;
|
||||
};
|
||||
|
||||
export const settings = writable<CourseSettings>({
|
||||
@@ -34,5 +35,6 @@ export const settings = writable<CourseSettings>({
|
||||
isPublished: false,
|
||||
isContentGroupingEnabled: true,
|
||||
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 * as ResourceListRow from '@cio/ui/custom/resource-list-row';
|
||||
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 CoursePreviewModal from '$features/lms/components/course-preview-modal.svelte';
|
||||
import type { RecommendedCourses } from '$features/course/types';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
|
||||
import CopyIcon from '@lucide/svelte/icons/copy';
|
||||
import VideoIcon from '@lucide/svelte/icons/video';
|
||||
import { copyPublicCoursePageUrl, openCoursePreview } from '$features/course/utils/course-preview';
|
||||
import {
|
||||
getStudentCourseComplianceDate,
|
||||
@@ -59,6 +61,27 @@
|
||||
let inProgressCourses = $derived(coursesApi.enrolledCourses.filter((course) => !isStudentCourseComplete(course)));
|
||||
let highlightedCourses = $derived.by(() => getHighlightedCourses(inProgressCourses));
|
||||
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(() => {
|
||||
if (!$profile.id || !$currentOrg.id) return;
|
||||
@@ -127,6 +150,12 @@
|
||||
goto(`/courses/${id}/lessons?next=true`);
|
||||
}
|
||||
|
||||
function gotoCourseCertificates(id: string | undefined) {
|
||||
if (!id) return;
|
||||
|
||||
goto(`/courses/${id}/certificates`);
|
||||
}
|
||||
|
||||
function showPublishedPublicCourseLinks(course: EnrolledCourse | null) {
|
||||
if (!course || course.type !== 'PUBLIC') {
|
||||
return false;
|
||||
@@ -167,16 +196,28 @@
|
||||
</script>
|
||||
|
||||
<div class="space-y-6 pb-8">
|
||||
{#if upcomingSessions.length}
|
||||
<UpcomingSessionsCard sessions={upcomingSessions} />
|
||||
{/if}
|
||||
|
||||
<BlurFade delay={0.05} once>
|
||||
<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="max-w-xl space-y-2">
|
||||
<p class="ui:text-primary text-xs font-semibold tracking-[0.18em] uppercase">
|
||||
{$t('dashboard.pick_up_where_you_left_off')}
|
||||
{#if shouldShowCertificateHero}
|
||||
{$t('dashboard.get_your_certificate')}
|
||||
{:else}
|
||||
{$t('dashboard.pick_up_where_you_left_off')}
|
||||
{/if}
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold tracking-tight">
|
||||
{#if currentCourse}
|
||||
{$t('dashboard.course_awaits_you', { title: currentCourse.title })}
|
||||
{#if shouldShowCertificateHero}
|
||||
{currentCourse.title}
|
||||
{:else}
|
||||
{$t('dashboard.course_awaits_you', { title: currentCourse.title })}
|
||||
{/if}
|
||||
{:else}
|
||||
{$t('dashboard.learning_awaits_you')}
|
||||
{/if}
|
||||
@@ -205,10 +246,19 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
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}
|
||||
{$t('dashboard.continue_learning')}
|
||||
{#if shouldShowCertificateHero}
|
||||
{$t('dashboard.my_certificate')}
|
||||
{:else}
|
||||
{$t('dashboard.continue_learning')}
|
||||
{/if}
|
||||
{:else}
|
||||
{$t('dashboard.view_courses')}
|
||||
{/if}
|
||||
@@ -337,6 +387,11 @@
|
||||
<CopyIcon class="size-4" />
|
||||
</IconButton>
|
||||
{/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)}>
|
||||
{$t('dashboard.continue')}
|
||||
</Button>
|
||||
|
||||
@@ -2,3 +2,4 @@ export { default as LanguagePicker } from './language-picker.svelte';
|
||||
export { default as AuthGeneral } from './auth-general.svelte';
|
||||
export { default as AuthSso } from './auth-sso.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">
|
||||
import { goto } from '$app/navigation';
|
||||
import ZapIcon from '@lucide/svelte/icons/zap';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import debounce from 'lodash/debounce';
|
||||
import ColorPicker from 'svelte-awesome-color-picker';
|
||||
|
||||
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 { orgApi } from '$features/org/api/org.svelte';
|
||||
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { UploadImage, UnsavedChanges } from '$features/ui';
|
||||
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 hex = $derived($currentOrg.theme?.includes('#') ? $currentOrg.theme : undefined);
|
||||
</script>
|
||||
@@ -171,51 +165,6 @@
|
||||
</div>
|
||||
</Field.Field>
|
||||
</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>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
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 { isFreePlan, currentOrg } from '$lib/utils/store/org';
|
||||
@@ -23,32 +23,23 @@
|
||||
let errorMessage = $state('');
|
||||
let role = $state(ROLE.TUTOR.toString());
|
||||
let isRemoving: number | null = $state(null);
|
||||
let linkRole = $state(ROLE.TUTOR.toString());
|
||||
let copiedLink = $state(false);
|
||||
|
||||
function buildLinkInviteUrl(token: string): string {
|
||||
return `${window.location.origin}/invite/link/${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
async function onGenerateLink() {
|
||||
async function onCopyInviteLink() {
|
||||
if ($isFreePlan) {
|
||||
snackbar.error('upgrade.required');
|
||||
return;
|
||||
}
|
||||
|
||||
await orgApi.generateLinkInvite(parseInt(linkRole));
|
||||
}
|
||||
await orgApi.generateLinkInvite(parseInt(role));
|
||||
const linkInviteToken = orgApi.linkInvite?.token;
|
||||
|
||||
async function onCopyLink() {
|
||||
if (!orgApi.linkInvite?.token) return;
|
||||
if (!linkInviteToken) return;
|
||||
|
||||
await navigator.clipboard.writeText(buildLinkInviteUrl(orgApi.linkInvite.token));
|
||||
copiedLink = true;
|
||||
setTimeout(() => (copiedLink = false), 2000);
|
||||
}
|
||||
|
||||
async function onToggleLinkInvite(active: boolean) {
|
||||
await orgApi.toggleLinkInvite(!active);
|
||||
await navigator.clipboard.writeText(buildLinkInviteUrl(linkInviteToken));
|
||||
}
|
||||
|
||||
async function onSendInvite() {
|
||||
@@ -108,20 +99,18 @@
|
||||
if (!$currentOrg) return;
|
||||
|
||||
orgApi.getOrgTeam();
|
||||
orgApi.getLinkInvite();
|
||||
});
|
||||
</script>
|
||||
|
||||
<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.Legend>{$t('course.navItem.people.teams.add')}</Field.Legend>
|
||||
<Field.Description class="mb-5">{$t('course.navItem.people.teams.add_team')}</Field.Description>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('course.navItem.people.teams.invite')}</Field.Label>
|
||||
<div class="flex flex-col gap-4 lg:grid lg:grid-cols-[minmax(0,1fr)_16rem_auto] lg:items-start">
|
||||
<Field.Field class="min-w-0">
|
||||
<Input
|
||||
placeholder={$t('course.navItem.people.teams.placeholder')}
|
||||
bind:value={emailsStr}
|
||||
@@ -133,10 +122,9 @@
|
||||
{/if}
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label>{$t('course.navItem.people.teams.role')}</Field.Label>
|
||||
<div class="w-full lg:w-64">
|
||||
<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>
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
@@ -144,80 +132,31 @@
|
||||
<Select.Item value={ROLE.TUTOR.toString()}>{$t(ROLE_LABEL[ROLE.TUTOR])}</Select.Item>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Field.Field>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
onclick={onSendInvite}
|
||||
loading={orgApi.isLoading}
|
||||
disabled={orgApi.isLoading || $isFreePlan}
|
||||
>
|
||||
{$t('course.navItem.people.teams.send_invite')}
|
||||
</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}
|
||||
variant="link"
|
||||
size="sm"
|
||||
class="h-auto justify-start"
|
||||
onclick={onCopyInviteLink}
|
||||
loading={orgApi.isLoading}
|
||||
disabled={orgApi.isLoading || $isFreePlan}
|
||||
>
|
||||
{$t('course.navItem.people.teams.link_invite.generate')}
|
||||
<CopyIcon size={14} />
|
||||
{$t('course.navItem.people.teams.link_invite.copy_invite_link')}
|
||||
</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>
|
||||
</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}
|
||||
<div class="lg:self-start">
|
||||
<Button
|
||||
variant="default"
|
||||
onclick={onSendInvite}
|
||||
loading={orgApi.isLoading}
|
||||
disabled={orgApi.isLoading || $isFreePlan}
|
||||
>
|
||||
{$t('course.navItem.people.teams.send_invite')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
@@ -14,3 +14,17 @@ export const landingPageSettings: Writable<OrgLandingPageJson> = writable(create
|
||||
* no section is selected — the editor shows the section list.
|
||||
*/
|
||||
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(/\/$/, '');
|
||||
|
||||
if (normalizedPathname !== parentBase) {
|
||||
const activeSub = matchedNavItem.items.find((sub) => {
|
||||
const subBase = sub.url.replace(/\/$/, '');
|
||||
return normalizedPathname === subBase || normalizedPathname.startsWith(`${subBase}/`);
|
||||
});
|
||||
const activeSub = matchedNavItem.items.find((sub) => isActive(pathname, sub.url, sub.matchPattern, true));
|
||||
if (activeSub) {
|
||||
breadcrumbs.push({
|
||||
label: t(activeSub.title),
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,11 +201,27 @@ export const baseNavConfig: NavItemConfig[] = [
|
||||
items: [
|
||||
{
|
||||
titleKey: 'settings.tabs.profile_tab',
|
||||
path: '/settings'
|
||||
path: '/settings',
|
||||
matchPattern: '^/org/[^/]+/settings/?$'
|
||||
},
|
||||
{
|
||||
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',
|
||||
@@ -331,7 +347,9 @@ export function getOrgNavigationItems(
|
||||
isActive: isActive(pagePathname, subUrl, subMatchPattern, true),
|
||||
url: subUrl,
|
||||
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),
|
||||
url: subUrl,
|
||||
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');
|
||||
grade = gradeMax;
|
||||
}
|
||||
if (grade && grade < 0) {
|
||||
snackbar.error('grade cant be less than 0');
|
||||
grade = 0;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -24,6 +28,7 @@
|
||||
placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.points')}
|
||||
bind:value={grade}
|
||||
max={gradeMax}
|
||||
min={0}
|
||||
type="number"
|
||||
inputClassName="!w-16"
|
||||
isDisabled={disableGrading}
|
||||
|
||||
@@ -1,7 +1,42 @@
|
||||
import dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import timezone from 'dayjs/plugin/timezone';
|
||||
|
||||
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 => {
|
||||
return dayjs(date).fromNow(true) + ` ago`;
|
||||
|
||||
@@ -5,10 +5,10 @@ import { env } from '$env/dynamic/public';
|
||||
|
||||
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 =
|
||||
'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 {
|
||||
const envUrl = env.PUBLIC_OG_IMAGE_URL?.trim();
|
||||
@@ -68,7 +68,15 @@ export function getBaseMetaTags(url: URL, orgSiteInfo: OrgSiteInfo): MetaTagsPro
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
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": {
|
||||
"title": "Elevprofil",
|
||||
"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": {
|
||||
@@ -803,6 +812,12 @@
|
||||
"animation_waves": "Bølger",
|
||||
"animation_dotted": "Stiplet",
|
||||
"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": {
|
||||
@@ -1054,7 +1069,7 @@
|
||||
"add": "Tilføj",
|
||||
"add_team": "Tilføj dine holdkammerater eller samarbejdspartnere til din organisation. Begynd at arbejde sammen",
|
||||
"invite": "Inviter",
|
||||
"placeholder": "e-mail kommasepareret",
|
||||
"placeholder": "[email protected], [email protected]",
|
||||
"role": "Rolle",
|
||||
"send_invite": "Send invitation",
|
||||
"members": "Medlemmer",
|
||||
@@ -1066,6 +1081,7 @@
|
||||
"description": "Alle med dette link kan tilslutte sig din organisation.",
|
||||
"generate": "Generer link",
|
||||
"copy": "Kopiér",
|
||||
"copy_invite_link": "Kopiér invitationslink",
|
||||
"copied": "Kopieret!",
|
||||
"toggle_label": "Linkinvitation aktiv",
|
||||
"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_submitted": "Indsendt - enhver indsendelse tæller",
|
||||
"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": {
|
||||
"heading": "Ny øvelse",
|
||||
@@ -2046,6 +2068,41 @@
|
||||
"sections": "Afsnit",
|
||||
"lessons": "Lektioner",
|
||||
"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": {
|
||||
@@ -2211,7 +2268,10 @@
|
||||
"title": "Kursuskravene er fuldført!",
|
||||
"subtitle": "Tillykke – du kan se og downloade dit certifikat når som helst.",
|
||||
"view_certificate": "Se certifikat",
|
||||
"later": "Luk"
|
||||
"later": "Luk",
|
||||
"checking": "Tjekker berettigelse...",
|
||||
"not_eligible_title": "Næsten der",
|
||||
"retake_exercise": "Gentag øvelsen"
|
||||
}
|
||||
},
|
||||
"certificates": {
|
||||
@@ -2236,7 +2296,8 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"heading": "Hvilket kursus vil du lave?",
|
||||
@@ -2440,8 +2501,10 @@
|
||||
"learner": "Student",
|
||||
"progress": "Fremskridt",
|
||||
"pick_up_where_you_left_off": "Fortsæt hvor du slap",
|
||||
"get_your_certificate": "Hent dit certifikat",
|
||||
"course_awaits_you": "{title} 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_empty": "Din læringsvej er klar, når du er.",
|
||||
"compliance_score": "Overholdelsesscore",
|
||||
@@ -2459,7 +2522,8 @@
|
||||
"login_activity_logins": "logins",
|
||||
"open_academy": "Åbent Akademi",
|
||||
"explore_more_courses": "Udforsk flere kurser",
|
||||
"view_more": "Se mere"
|
||||
"view_more": "Se mere",
|
||||
"upcoming_sessions_heading": "Live sessioner"
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Dit skema",
|
||||
|
||||
@@ -677,6 +677,15 @@
|
||||
"user_analytics": {
|
||||
"title": "Studentenprofil",
|
||||
"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": {
|
||||
@@ -803,6 +812,12 @@
|
||||
"animation_waves": "Wellen",
|
||||
"animation_dotted": "Gepunktet",
|
||||
"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": {
|
||||
@@ -1054,7 +1069,7 @@
|
||||
"add": "Hinzufügen",
|
||||
"add_team": "Fügen Sie Ihre Teamkollegen oder Mitarbeiter zu Ihrer Organisation hinzu. Beginnen Sie mit der Zusammenarbeit",
|
||||
"invite": "Einladen",
|
||||
"placeholder": "E-Mail durch Komma getrennt",
|
||||
"placeholder": "[email protected], [email protected]",
|
||||
"role": "Rolle",
|
||||
"send_invite": "Einladung senden",
|
||||
"members": "Mitglieder",
|
||||
@@ -1066,6 +1081,7 @@
|
||||
"description": "Jeder mit diesem Link kann Ihrer Organisation beitreten.",
|
||||
"generate": "Link generieren",
|
||||
"copy": "Kopieren",
|
||||
"copy_invite_link": "Einladungslink kopieren",
|
||||
"copied": "Kopiert!",
|
||||
"toggle_label": "Linkeinladung aktiv",
|
||||
"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_submitted": "Eingereicht – jede Einreichung zählt",
|
||||
"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": {
|
||||
"heading": "Neue Übung",
|
||||
@@ -2046,6 +2068,41 @@
|
||||
"sections": "Abschnitte",
|
||||
"lessons": "Lektionen",
|
||||
"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": {
|
||||
@@ -2211,7 +2268,10 @@
|
||||
"title": "Kursvoraussetzungen erfüllt!",
|
||||
"subtitle": "Herzlichen Glückwunsch – Sie können Ihr Zertifikat jederzeit einsehen und herunterladen.",
|
||||
"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": {
|
||||
@@ -2236,7 +2296,8 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"heading": "Welchen Kurs möchten Sie erstellen?",
|
||||
@@ -2440,8 +2501,10 @@
|
||||
"learner": "Student",
|
||||
"progress": "Fortschritt",
|
||||
"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",
|
||||
"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_empty": "Ihr Lernpfad ist fertig, wenn Sie es sind.",
|
||||
"compliance_score": "Compliance-Score",
|
||||
@@ -2459,7 +2522,8 @@
|
||||
"login_activity_logins": "Anmeldungen",
|
||||
"open_academy": "Offene Akademie",
|
||||
"explore_more_courses": "Entdecken Sie weitere Kurse",
|
||||
"view_more": "Mehr anzeigen"
|
||||
"view_more": "Mehr anzeigen",
|
||||
"upcoming_sessions_heading": "Live-Sitzungen"
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Ihr Zeitplan",
|
||||
|
||||
@@ -290,7 +290,8 @@
|
||||
"common": {
|
||||
"unsaved_changes": {
|
||||
"message": "You have unsaved changes. Are you sure you want to leave?"
|
||||
}
|
||||
},
|
||||
"refresh": "Refresh"
|
||||
},
|
||||
"compliance": {
|
||||
"title": "Compliance",
|
||||
@@ -1665,9 +1666,12 @@
|
||||
"progress": "Progress",
|
||||
"continue": "Continue",
|
||||
"continue_learning": "Continue Learning",
|
||||
"upcoming_sessions_heading": "Live Sessions",
|
||||
"pick_up_where_you_left_off": "Pick up where you left off",
|
||||
"get_your_certificate": "Get your certificate",
|
||||
"course_awaits_you": "{title} 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_empty": "Your learning path is ready when you are.",
|
||||
"compliance_score": "Compliance Score",
|
||||
@@ -2116,6 +2120,7 @@
|
||||
"final_exercise_label": "Final exercise (optional)",
|
||||
"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_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_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.",
|
||||
@@ -2133,7 +2138,10 @@
|
||||
"title": "Course requirements complete!",
|
||||
"subtitle": "Congratulations — you can view and download your certificate anytime.",
|
||||
"view_certificate": "View certificate",
|
||||
"later": "Close"
|
||||
"later": "Close",
|
||||
"checking": "Checking eligibility…",
|
||||
"not_eligible_title": "Almost there",
|
||||
"retake_exercise": "Retake exercise"
|
||||
}
|
||||
},
|
||||
"header": {
|
||||
@@ -2233,6 +2241,41 @@
|
||||
"seek_blocked": "You cannot skip ahead on this 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.",
|
||||
"section_prompt": {
|
||||
"cta": "Enable Sections",
|
||||
@@ -2504,6 +2547,12 @@
|
||||
},
|
||||
"all_exercises": {
|
||||
"heading": "All Exercise",
|
||||
"notify": {
|
||||
"action": "Notify students",
|
||||
"sending": "Notifying students…",
|
||||
"sent": "Notified {count} students",
|
||||
"failed": "Couldn't notify students"
|
||||
},
|
||||
"questions": "Questions",
|
||||
"settings": "Settings",
|
||||
"settings_allow_multiple": "Allow multiple attempts",
|
||||
@@ -3260,7 +3309,7 @@
|
||||
"add": "Add",
|
||||
"add_team": "Add your team mates or collaborators to your organization. Start working together",
|
||||
"invite": "Invite",
|
||||
"placeholder": "email comma separated",
|
||||
"placeholder": "user1@domain.com, [email protected]",
|
||||
"role": "Role",
|
||||
"send_invite": "Send Invite",
|
||||
"members": "Members",
|
||||
@@ -3272,6 +3321,7 @@
|
||||
"description": "Anyone with this link can join your organization.",
|
||||
"generate": "Generate Link",
|
||||
"copy": "Copy",
|
||||
"copy_invite_link": "Copy invite link",
|
||||
"copied": "Copied!",
|
||||
"toggle_label": "Link invite active",
|
||||
"disabled_notice": "This link is disabled. Enable it to allow new members to join via link.",
|
||||
@@ -3532,6 +3582,11 @@
|
||||
"enabled": "Enabled",
|
||||
"order": "Order Lessons Tab",
|
||||
"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_description": "Group lessons and exercises by section in the content list.",
|
||||
"progression_mode_title": "Student progression",
|
||||
@@ -3550,7 +3605,8 @@
|
||||
"published": "Published",
|
||||
"unpublished": "Unpublished",
|
||||
"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",
|
||||
"self_paced": "Self paced",
|
||||
"compliance": "Compliance",
|
||||
@@ -3658,6 +3714,15 @@
|
||||
"select_all": "Select all",
|
||||
"selected_count": "{count} selected",
|
||||
"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": {
|
||||
"title": "Student profile",
|
||||
"page_subtitle": "Progress, grades, and activity across their courses."
|
||||
|
||||
@@ -677,6 +677,15 @@
|
||||
"user_analytics": {
|
||||
"title": "Perfil de estudiante",
|
||||
"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": {
|
||||
@@ -803,7 +812,13 @@
|
||||
"animation_waves": "Olas",
|
||||
"animation_dotted": "punteado",
|
||||
"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": {
|
||||
"title": "Analítica"
|
||||
@@ -1054,7 +1069,7 @@
|
||||
"add": "Añadir",
|
||||
"add_team": "Agregue a sus compañeros de equipo o colaboradores a su organización. Empezar a trabajar juntos",
|
||||
"invite": "invitar",
|
||||
"placeholder": "correo electrónico separado por comas",
|
||||
"placeholder": "[email protected], user2@domain.com",
|
||||
"role": "Rol",
|
||||
"send_invite": "Enviar invitación",
|
||||
"members": "Miembros",
|
||||
@@ -1066,6 +1081,7 @@
|
||||
"description": "Cualquiera que tenga este enlace puede unirse a su organización.",
|
||||
"generate": "Generar enlace",
|
||||
"copy": "Copiar",
|
||||
"copy_invite_link": "Copiar enlace de invitación",
|
||||
"copied": "¡Copiado!",
|
||||
"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.",
|
||||
@@ -1775,7 +1791,13 @@
|
||||
"settings_completion_policy": "Regla de finalización",
|
||||
"settings_completion_submitted": "Enviado: cualquier envío cuenta",
|
||||
"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": {
|
||||
"heading": "Nuevo ejercicio",
|
||||
@@ -2046,6 +2068,41 @@
|
||||
"sections": "Secciones",
|
||||
"lessons": "Lecciones",
|
||||
"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": {
|
||||
@@ -2211,7 +2268,10 @@
|
||||
"title": "¡Requisitos del curso completos!",
|
||||
"subtitle": "Felicitaciones, puede ver y descargar su certificado en cualquier momento.",
|
||||
"view_certificate": "Ver certificado",
|
||||
"later": "Cerrar"
|
||||
"later": "Cerrar",
|
||||
"checking": "Comprobando la elegibilidad...",
|
||||
"not_eligible_title": "Casi allí",
|
||||
"retake_exercise": "Rehacer ejercicio"
|
||||
}
|
||||
},
|
||||
"certificates": {
|
||||
@@ -2236,7 +2296,8 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"heading": "¿Qué curso quieres crear?",
|
||||
@@ -2440,8 +2501,10 @@
|
||||
"learner": "estudiante",
|
||||
"progress": "Progreso",
|
||||
"pick_up_where_you_left_off": "Continua donde lo dejaste",
|
||||
"get_your_certificate": "Obtén tu certificado",
|
||||
"course_awaits_you": "{title} 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_empty": "Tu camino de aprendizaje estará listo cuando tú lo estés.",
|
||||
"compliance_score": "Puntuación de cumplimiento",
|
||||
@@ -2459,7 +2522,8 @@
|
||||
"login_activity_logins": "inicios de sesión",
|
||||
"open_academy": "Academia Abierta",
|
||||
"explore_more_courses": "Explora más cursos",
|
||||
"view_more": "Ver más"
|
||||
"view_more": "Ver más",
|
||||
"upcoming_sessions_heading": "Sesiones en vivo"
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Tu horario",
|
||||
|
||||
@@ -677,6 +677,15 @@
|
||||
"user_analytics": {
|
||||
"title": "Profil étudiant",
|
||||
"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": {
|
||||
@@ -803,6 +812,12 @@
|
||||
"animation_waves": "Vagues",
|
||||
"animation_dotted": "Pointillé",
|
||||
"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": {
|
||||
@@ -1054,7 +1069,7 @@
|
||||
"add": "Ajouter",
|
||||
"add_team": "Ajoutez vos coéquipiers ou collaborateurs à votre organisation. Commencez à travailler ensemble",
|
||||
"invite": "Inviter",
|
||||
"placeholder": "email séparé par des virgules",
|
||||
"placeholder": "[email protected], [email protected]",
|
||||
"role": "Rôle",
|
||||
"send_invite": "Envoyer une invitation",
|
||||
"members": "Membres",
|
||||
@@ -1066,6 +1081,7 @@
|
||||
"description": "Toute personne disposant de ce lien peut rejoindre votre organisation.",
|
||||
"generate": "Générer un lien",
|
||||
"copy": "Copier",
|
||||
"copy_invite_link": "Copier le lien d'invitation",
|
||||
"copied": "Copié!",
|
||||
"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.",
|
||||
@@ -1775,7 +1791,13 @@
|
||||
"settings_completion_policy": "Règle d'achèvement",
|
||||
"settings_completion_submitted": "Soumis – toute soumission compte",
|
||||
"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": {
|
||||
"heading": "Nouvel exercice",
|
||||
@@ -2046,6 +2068,41 @@
|
||||
"sections": "Sections",
|
||||
"lessons": "Leçons",
|
||||
"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": {
|
||||
@@ -2211,7 +2268,10 @@
|
||||
"title": "Exigences du cours terminées !",
|
||||
"subtitle": "Félicitations : vous pouvez consulter et télécharger votre certificat à tout moment.",
|
||||
"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": {
|
||||
@@ -2236,7 +2296,8 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"heading": "Quel cours souhaitez-vous créer ?",
|
||||
@@ -2440,8 +2501,10 @@
|
||||
"learner": "Étudiant",
|
||||
"progress": "Progrès",
|
||||
"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",
|
||||
"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_empty": "Votre parcours d’apprentissage est prêt quand vous l’êtes.",
|
||||
"compliance_score": "Score de conformité",
|
||||
@@ -2459,7 +2522,8 @@
|
||||
"login_activity_logins": "connexions",
|
||||
"open_academy": "Académie ouverte",
|
||||
"explore_more_courses": "Explorez plus de cours",
|
||||
"view_more": "Voir plus"
|
||||
"view_more": "Voir plus",
|
||||
"upcoming_sessions_heading": "Séances en direct"
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Votre emploi du temps",
|
||||
|
||||
@@ -677,6 +677,15 @@
|
||||
"user_analytics": {
|
||||
"title": "छात्र प्रोफ़ाइल",
|
||||
"page_subtitle": "Progress, grades, and activity across their courses."
|
||||
},
|
||||
"delete": {
|
||||
"action": "हटाएँ",
|
||||
"title": "सदस्य हटाएँ",
|
||||
"description": "यह सदस्य को आपके संगठन से हटा देता है और सभी निर्दिष्ट पाठ्यक्रमों और कार्यक्रमों तक उनकी पहुंच रद्द कर देता है।",
|
||||
"confirmation": "क्या आप वाकई {name} ({email}) को हटाना चाहते हैं? इस एक्शन को वापस नहीं किया जा सकता।",
|
||||
"cancel": "रद्द करें",
|
||||
"confirm": "हटाएँ",
|
||||
"snackbar_success": "सदस्य हटा दिया गया"
|
||||
}
|
||||
},
|
||||
"community": {
|
||||
@@ -803,6 +812,12 @@
|
||||
"animation_waves": "लहरें",
|
||||
"animation_dotted": "बिंदीदार",
|
||||
"animation_none": "कोई नहीं"
|
||||
},
|
||||
"course_type_learn_more": "और जानें",
|
||||
"welcome_email": {
|
||||
"title": "आपका स्वागत है ईमेल",
|
||||
"description": "नामांकन के बाद छात्रों को प्राप्त होने वाले ईमेल को अनुकूलित करें। एक व्यक्तिगत नोट, निर्देश, या एक लाइव सत्र लिंक जोड़ें।",
|
||||
"placeholder": "नामांकन के समय विद्यार्थियों को मिलने वाला स्वागत संदेश लिखें..."
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
@@ -1054,7 +1069,7 @@
|
||||
"add": "जोड़ें",
|
||||
"add_team": "अपने टीम के साथियों या सहयोगियों को अपने संगठन में जोड़ें। एक साथ काम करना शुरू करें",
|
||||
"invite": "आमंत्रित करें",
|
||||
"placeholder": "ईमेल अल्पविराम से अलग किया गया",
|
||||
"placeholder": "[email protected], [email protected]",
|
||||
"role": "भूमिका",
|
||||
"send_invite": "आमंत्रण भेजें",
|
||||
"members": "सदस्य",
|
||||
@@ -1066,6 +1081,7 @@
|
||||
"description": "इस लिंक वाला कोई भी व्यक्ति आपके संगठन में शामिल हो सकता है।",
|
||||
"generate": "लिंक जनरेट करें",
|
||||
"copy": "प्रतिलिपि",
|
||||
"copy_invite_link": "आमंत्रण लिंक कॉपी करें",
|
||||
"copied": "नकल की गई!",
|
||||
"toggle_label": "लिंक आमंत्रण सक्रिय",
|
||||
"disabled_notice": "यह लिंक अक्षम है. नए सदस्यों को लिंक के माध्यम से जुड़ने की अनुमति देने के लिए इसे सक्षम करें।",
|
||||
@@ -1775,7 +1791,13 @@
|
||||
"settings_completion_policy": "समापन नियम",
|
||||
"settings_completion_submitted": "सबमिट किया गया - कोई भी सबमिशन मायने रखता है",
|
||||
"settings_completion_passed": "उत्तीर्ण - न्यूनतम अंक आवश्यक",
|
||||
"settings_pass_threshold": "पास सीमा (%)"
|
||||
"settings_pass_threshold": "पास सीमा (%)",
|
||||
"notify": {
|
||||
"action": "छात्रों को सूचित करें",
|
||||
"sending": "छात्रों को सूचित किया जा रहा है...",
|
||||
"sent": "{count} छात्रों को सूचित किया गया",
|
||||
"failed": "छात्रों को सूचित नहीं किया जा सका"
|
||||
}
|
||||
},
|
||||
"new_exercise_modal": {
|
||||
"heading": "नया व्यायाम",
|
||||
@@ -2046,6 +2068,41 @@
|
||||
"sections": "अनुभाग",
|
||||
"lessons": "सबक",
|
||||
"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": {
|
||||
@@ -2211,7 +2268,10 @@
|
||||
"title": "पाठ्यक्रम की आवश्यकताएँ पूर्ण!",
|
||||
"subtitle": "बधाई हो - आप अपना प्रमाणपत्र कभी भी देख और डाउनलोड कर सकते हैं।",
|
||||
"view_certificate": "प्रमाणपत्र देखें",
|
||||
"later": "बंद करें"
|
||||
"later": "बंद करें",
|
||||
"checking": "पात्रता की जाँच की जा रही है...",
|
||||
"not_eligible_title": "लगभग वहाँ",
|
||||
"retake_exercise": "पुनः व्यायाम करें"
|
||||
}
|
||||
},
|
||||
"certificates": {
|
||||
@@ -2236,7 +2296,8 @@
|
||||
"blocker_final_score": "\"{exerciseTitle}\" पर आपका सर्वोत्तम स्कोर {bestPercent}% है; {requiredPercent}% आवश्यक है.",
|
||||
"blocker_final_misconfigured": "प्रमाणन अभ्यास \"{exerciseTitle}\" में कोई बिंदु कॉन्फ़िगर नहीं किया गया है; अपने प्रशिक्षक से इसे ठीक करने के लिए कहें।",
|
||||
"email_message_label": "ईमेल संदेश",
|
||||
"email_message_placeholder": "जैसे हमें आपकी प्रगति पर गर्व है - सीखते रहें!"
|
||||
"email_message_placeholder": "जैसे हमें आपकी प्रगति पर गर्व है - सीखते रहें!",
|
||||
"final_exercise_multiple_attempts_note": "छात्रों को चयनित अभ्यास पर असीमित प्रयासों की अनुमति दी जाएगी।"
|
||||
},
|
||||
"creator": {
|
||||
"heading": "आप कौन सा कोर्स बनाना चाहते हैं?",
|
||||
@@ -2440,8 +2501,10 @@
|
||||
"learner": "छात्र",
|
||||
"progress": "प्रगति",
|
||||
"pick_up_where_you_left_off": "वहीं से शुरू करें जहां आपने छोड़ा था",
|
||||
"get_your_certificate": "अपना प्रमाणपत्र प्राप्त करें",
|
||||
"course_awaits_you": "{title} आपकी प्रतीक्षा कर रहा है",
|
||||
"learning_awaits_you": "आपका अगला कोर्स आपका इंतजार कर रहा है",
|
||||
"my_certificate": "मेरा प्रमाणपत्र",
|
||||
"lms_today_progress": "आप अपने सीखने के माध्यम से {progress}% कर रहे हैं - हर कदम मायने रखता है।",
|
||||
"lms_today_empty": "जब आप हों तो आपका सीखने का मार्ग तैयार है।",
|
||||
"compliance_score": "अनुपालन स्कोर",
|
||||
@@ -2459,7 +2522,8 @@
|
||||
"login_activity_logins": "लॉगिन",
|
||||
"open_academy": "अकादमी खोलें",
|
||||
"explore_more_courses": "अधिक पाठ्यक्रम खोजें",
|
||||
"view_more": "और देखें"
|
||||
"view_more": "और देखें",
|
||||
"upcoming_sessions_heading": "लाइव सत्र"
|
||||
},
|
||||
"schedule": {
|
||||
"title": "आपका शेड्यूल",
|
||||
|
||||
@@ -677,6 +677,15 @@
|
||||
"user_analytics": {
|
||||
"title": "Profil studenta",
|
||||
"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": {
|
||||
@@ -803,6 +812,12 @@
|
||||
"animation_waves": "Fale",
|
||||
"animation_dotted": "Przerywana",
|
||||
"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": {
|
||||
@@ -1054,7 +1069,7 @@
|
||||
"add": "Dodaj",
|
||||
"add_team": "Dodaj członków zespołu lub współpracowników do swojej organizacji. Zacznijcie współpracować",
|
||||
"invite": "Zaproś",
|
||||
"placeholder": "e-mail oddzielony przecinkami",
|
||||
"placeholder": "[email protected], [email protected]",
|
||||
"role": "Rola",
|
||||
"send_invite": "Wyślij zaproszenie",
|
||||
"members": "Członkowie",
|
||||
@@ -1066,6 +1081,7 @@
|
||||
"description": "Każda osoba mająca ten link może dołączyć do Twojej organizacji.",
|
||||
"generate": "Wygeneruj łącze",
|
||||
"copy": "Kopiuj",
|
||||
"copy_invite_link": "Skopiuj link zaproszenia",
|
||||
"copied": "Skopiowano!",
|
||||
"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.",
|
||||
@@ -1775,7 +1791,13 @@
|
||||
"settings_completion_policy": "Zasada uzupełnienia",
|
||||
"settings_completion_submitted": "Przesłane — liczy się każde przesłanie",
|
||||
"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": {
|
||||
"heading": "Nowe ćwiczenie",
|
||||
@@ -2046,6 +2068,41 @@
|
||||
"sections": "Sekcje",
|
||||
"lessons": "Lekcje",
|
||||
"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": {
|
||||
@@ -2211,7 +2268,10 @@
|
||||
"title": "Wymagania dotyczące kursu zostały spełnione!",
|
||||
"subtitle": "Gratulacje — możesz wyświetlić i pobrać swój certyfikat w dowolnym momencie.",
|
||||
"view_certificate": "Zobacz certyfikat",
|
||||
"later": "Zamknij"
|
||||
"later": "Zamknij",
|
||||
"checking": "Sprawdzam uprawnienia…",
|
||||
"not_eligible_title": "Prawie tam",
|
||||
"retake_exercise": "Powtórz ćwiczenie"
|
||||
}
|
||||
},
|
||||
"certificates": {
|
||||
@@ -2236,7 +2296,8 @@
|
||||
"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ł.",
|
||||
"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": {
|
||||
"heading": "Jaki kurs chcesz stworzyć?",
|
||||
@@ -2440,8 +2501,10 @@
|
||||
"learner": "Studenta",
|
||||
"progress": "Postęp",
|
||||
"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",
|
||||
"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_empty": "Twoja ścieżka edukacyjna jest gotowa, gdy Ty jesteś.",
|
||||
"compliance_score": "Wynik zgodności",
|
||||
@@ -2459,7 +2522,8 @@
|
||||
"login_activity_logins": "logowania",
|
||||
"open_academy": "Otwarta Akademia",
|
||||
"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": {
|
||||
"title": "Twój harmonogram",
|
||||
|
||||
@@ -677,6 +677,15 @@
|
||||
"user_analytics": {
|
||||
"title": "Perfil do aluno",
|
||||
"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": {
|
||||
@@ -803,6 +812,12 @@
|
||||
"animation_waves": "Ondas",
|
||||
"animation_dotted": "Pontilhado",
|
||||
"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": {
|
||||
@@ -1054,7 +1069,7 @@
|
||||
"add": "Adicionar",
|
||||
"add_team": "Adicione seus companheiros de equipe ou colaboradores à sua organização. Comece a trabalhar juntos",
|
||||
"invite": "Convidar",
|
||||
"placeholder": "e-mail separado por vírgula",
|
||||
"placeholder": "[email protected], [email protected]",
|
||||
"role": "Função",
|
||||
"send_invite": "Enviar convite",
|
||||
"members": "Membros",
|
||||
@@ -1066,6 +1081,7 @@
|
||||
"description": "Qualquer pessoa com este link pode ingressar na sua organização.",
|
||||
"generate": "Gerar link",
|
||||
"copy": "Copiar",
|
||||
"copy_invite_link": "Copiar link de convite",
|
||||
"copied": "Copiado!",
|
||||
"toggle_label": "Link de convite ativo",
|
||||
"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_submitted": "Enviado – qualquer envio conta",
|
||||
"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": {
|
||||
"heading": "Novo exercício",
|
||||
@@ -2046,6 +2068,41 @@
|
||||
"sections": "Seções",
|
||||
"lessons": "Lições",
|
||||
"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": {
|
||||
@@ -2211,7 +2268,10 @@
|
||||
"title": "Requisitos do curso concluídos!",
|
||||
"subtitle": "Parabéns – você pode visualizar e baixar seu certificado a qualquer momento.",
|
||||
"view_certificate": "Ver certificado",
|
||||
"later": "Fechar"
|
||||
"later": "Fechar",
|
||||
"checking": "Verificando elegibilidade…",
|
||||
"not_eligible_title": "Quase lá",
|
||||
"retake_exercise": "Refazer o exercício"
|
||||
}
|
||||
},
|
||||
"certificates": {
|
||||
@@ -2236,7 +2296,8 @@
|
||||
"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.",
|
||||
"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": {
|
||||
"heading": "Qual curso você deseja criar?",
|
||||
@@ -2440,8 +2501,10 @@
|
||||
"learner": "Estudante",
|
||||
"progress": "Progresso",
|
||||
"pick_up_where_you_left_off": "Continue de onde parou",
|
||||
"get_your_certificate": "Obtenha seu certificado",
|
||||
"course_awaits_you": "{title} 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_empty": "Seu caminho de aprendizagem estará pronto quando você estiver.",
|
||||
"compliance_score": "Pontuação de Conformidade",
|
||||
@@ -2459,7 +2522,8 @@
|
||||
"login_activity_logins": "logins",
|
||||
"open_academy": "Academia Aberta",
|
||||
"explore_more_courses": "Explorar mais cursos",
|
||||
"view_more": "Ver mais"
|
||||
"view_more": "Ver mais",
|
||||
"upcoming_sessions_heading": "Sessões ao vivo"
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Sua programação",
|
||||
|
||||
@@ -677,6 +677,15 @@
|
||||
"user_analytics": {
|
||||
"title": "Профиль студента",
|
||||
"page_subtitle": "Progress, grades, and activity across their courses."
|
||||
},
|
||||
"delete": {
|
||||
"action": "Удалить",
|
||||
"title": "Удалить участника",
|
||||
"description": "Это приведет к удалению участника из вашей организации и аннулированию его доступа ко всем назначенным курсам и программам.",
|
||||
"confirmation": "Вы уверены, что хотите удалить {name} ({email})? Это действие невозможно отменить.",
|
||||
"cancel": "Отмена",
|
||||
"confirm": "Удалить",
|
||||
"snackbar_success": "Участник удален"
|
||||
}
|
||||
},
|
||||
"community": {
|
||||
@@ -803,6 +812,12 @@
|
||||
"animation_waves": "Волны",
|
||||
"animation_dotted": "Пунктирный",
|
||||
"animation_none": "Нет"
|
||||
},
|
||||
"course_type_learn_more": "Узнать больше",
|
||||
"welcome_email": {
|
||||
"title": "Приветственное письмо",
|
||||
"description": "Настройте электронное письмо, которое студенты получат после регистрации. Добавьте личную заметку, инструкции или ссылку на сеанс в реальном времени.",
|
||||
"placeholder": "Напишите приветственное сообщение, которое студенты получат при зачислении…"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
@@ -1054,7 +1069,7 @@
|
||||
"add": "Добавить",
|
||||
"add_team": "Добавьте своих товарищей по команде или сотрудников в свою организацию. Начните работать вместе",
|
||||
"invite": "Пригласить",
|
||||
"placeholder": "электронная почта, разделенная запятой",
|
||||
"placeholder": "[email protected], [email protected]",
|
||||
"role": "Роль",
|
||||
"send_invite": "Отправить приглашение",
|
||||
"members": "Члены",
|
||||
@@ -1066,6 +1081,7 @@
|
||||
"description": "Любой, у кого есть эта ссылка, может присоединиться к вашей организации.",
|
||||
"generate": "Создать ссылку",
|
||||
"copy": "Копировать",
|
||||
"copy_invite_link": "Скопировать ссылку приглашения",
|
||||
"copied": "Скопировано!",
|
||||
"toggle_label": "Приглашение по ссылке активно",
|
||||
"disabled_notice": "Эта ссылка отключена. Включите его, чтобы позволить новым участникам присоединяться по ссылке.",
|
||||
@@ -1775,7 +1791,13 @@
|
||||
"settings_completion_policy": "Правило завершения",
|
||||
"settings_completion_submitted": "Отправлено — любая отправка засчитывается",
|
||||
"settings_completion_passed": "Пройдено — требуется минимальный балл",
|
||||
"settings_pass_threshold": "Порог прохождения (%)"
|
||||
"settings_pass_threshold": "Порог прохождения (%)",
|
||||
"notify": {
|
||||
"action": "Уведомить студентов",
|
||||
"sending": "Уведомление учащихся…",
|
||||
"sent": "Уведомлено учащихся: {count}",
|
||||
"failed": "Не удалось уведомить студентов"
|
||||
}
|
||||
},
|
||||
"new_exercise_modal": {
|
||||
"heading": "Новое упражнение",
|
||||
@@ -2046,6 +2068,41 @@
|
||||
"sections": "Разделы",
|
||||
"lessons": "Уроки",
|
||||
"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": {
|
||||
@@ -2211,7 +2268,10 @@
|
||||
"title": "Требования курса выполнены!",
|
||||
"subtitle": "Поздравляем — вы можете просмотреть и скачать свой сертификат в любое время.",
|
||||
"view_certificate": "Посмотреть сертификат",
|
||||
"later": "Закрыть"
|
||||
"later": "Закрыть",
|
||||
"checking": "Проверка пригодности…",
|
||||
"not_eligible_title": "Почти там",
|
||||
"retake_exercise": "Пересдать упражнение"
|
||||
}
|
||||
},
|
||||
"certificates": {
|
||||
@@ -2236,7 +2296,8 @@
|
||||
"blocker_final_score": "Ваш лучший результат в «{exerciseTitle}» — {bestPercent}%; Требуется {requiredPercent}%.",
|
||||
"blocker_final_misconfigured": "Для сертификационного упражнения «{exerciseTitle}» не настроены баллы; попросите инструктора это исправить.",
|
||||
"email_message_label": "Сообщение электронной почты",
|
||||
"email_message_placeholder": "например Мы гордимся вашим прогрессом — продолжайте учиться!"
|
||||
"email_message_placeholder": "например Мы гордимся вашим прогрессом — продолжайте учиться!",
|
||||
"final_exercise_multiple_attempts_note": "Студентам будет разрешено неограниченное количество попыток выполнения выбранного упражнения."
|
||||
},
|
||||
"creator": {
|
||||
"heading": "Какой курс вы хотите создать?",
|
||||
@@ -2440,8 +2501,10 @@
|
||||
"learner": "Студент",
|
||||
"progress": "Прогресс",
|
||||
"pick_up_where_you_left_off": "Продолжайте с того места, где вы остановились",
|
||||
"get_your_certificate": "Получите свой сертификат",
|
||||
"course_awaits_you": "{title} ждет вас",
|
||||
"learning_awaits_you": "Следующий курс ждет вас",
|
||||
"my_certificate": "Мой сертификат",
|
||||
"lms_today_progress": "Ваш прогресс в обучении – {progress} %. Каждый шаг имеет значение.",
|
||||
"lms_today_empty": "Ваш путь обучения готов, когда вы будете готовы.",
|
||||
"compliance_score": "Оценка соответствия",
|
||||
@@ -2459,7 +2522,8 @@
|
||||
"login_activity_logins": "логины",
|
||||
"open_academy": "Открытая Академия",
|
||||
"explore_more_courses": "Ознакомьтесь с другими курсами",
|
||||
"view_more": "Посмотреть больше"
|
||||
"view_more": "Посмотреть больше",
|
||||
"upcoming_sessions_heading": "Живые сессии"
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Ваше расписание",
|
||||
|
||||
@@ -677,6 +677,15 @@
|
||||
"user_analytics": {
|
||||
"title": "Hồ sơ sinh viên",
|
||||
"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": {
|
||||
@@ -803,6 +812,12 @@
|
||||
"animation_waves": "Sóng",
|
||||
"animation_dotted": "Chấm",
|
||||
"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": {
|
||||
@@ -1054,7 +1069,7 @@
|
||||
"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",
|
||||
"invite": "Mời",
|
||||
"placeholder": "email được phân tách bằng dấu phẩy",
|
||||
"placeholder": "[email protected], [email protected]",
|
||||
"role": "Vai trò",
|
||||
"send_invite": "Gửi lời mời",
|
||||
"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.",
|
||||
"generate": "Tạo liên kết",
|
||||
"copy": "Sao chép",
|
||||
"copy_invite_link": "Sao chép liên kết mời",
|
||||
"copied": "Đã sao chép!",
|
||||
"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.",
|
||||
@@ -1775,7 +1791,13 @@
|
||||
"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_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": {
|
||||
"heading": "Bài tập mới",
|
||||
@@ -2046,6 +2068,41 @@
|
||||
"sections": "Phần",
|
||||
"lessons": "Bài học",
|
||||
"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": {
|
||||
@@ -2211,7 +2268,10 @@
|
||||
"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.",
|
||||
"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": {
|
||||
@@ -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_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_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": {
|
||||
"heading": "Bạn muốn tạo khóa học nào?",
|
||||
@@ -2440,8 +2501,10 @@
|
||||
"learner": "sinh viên",
|
||||
"progress": "Tiến độ",
|
||||
"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",
|
||||
"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_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ủ",
|
||||
@@ -2459,7 +2522,8 @@
|
||||
"login_activity_logins": "thông tin đăng nhập",
|
||||
"open_academy": "Học viện mở",
|
||||
"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": {
|
||||
"title": "Lịch trình của bạn",
|
||||
|
||||
@@ -1,12 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
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>
|
||||
|
||||
<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?.()}
|
||||
</Page.Root>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { CustomizeLmsPage } from '$features/settings/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { settingsHeaderAction } from '$features/settings/utils/store';
|
||||
|
||||
let customizeLmsComponent: CustomizeLmsPage | null = $state(null);
|
||||
let isSaving = $state(false);
|
||||
@@ -15,27 +14,28 @@
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
<title>Customize LMS - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full md:max-w-4xl lg:mx-auto">
|
||||
<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>
|
||||
<CustomizeLmsPage bind:this={customizeLmsComponent} />
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
<script>
|
||||
import { onDestroy } from 'svelte';
|
||||
import { DomainsPage } from '$features/settings/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { settingsHeaderAction } from '$features/settings/utils/store';
|
||||
|
||||
$effect(() => {
|
||||
settingsHeaderAction.set({
|
||||
label: 'Save',
|
||||
disabled: true,
|
||||
loading: false,
|
||||
onClick: null
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
settingsHeaderAction.set({
|
||||
label: 'Save',
|
||||
disabled: true,
|
||||
loading: false,
|
||||
onClick: null
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Custom domain - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Header>
|
||||
<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>
|
||||
<DomainsPage />
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { OrgPage } from '$features/settings/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { settingsHeaderAction } from '$features/settings/utils/store';
|
||||
|
||||
let orgComponent: OrgPage | null = $state(null);
|
||||
let loading = $state(false);
|
||||
@@ -15,25 +14,28 @@
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
settingsHeaderAction.set({
|
||||
label: 'Save',
|
||||
disabled: loading,
|
||||
loading,
|
||||
onClick: handleUpdate
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
settingsHeaderAction.set({
|
||||
label: 'Save',
|
||||
disabled: true,
|
||||
loading: false,
|
||||
onClick: null
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Organization Settings - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Header isSticky>
|
||||
<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>
|
||||
<OrgPage bind:this={orgComponent} />
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
<script>
|
||||
import { onDestroy } from 'svelte';
|
||||
import { TeamsPage } from '$features/settings/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { settingsHeaderAction } from '$features/settings/utils/store';
|
||||
|
||||
$effect(() => {
|
||||
settingsHeaderAction.set({
|
||||
label: 'Save',
|
||||
disabled: true,
|
||||
loading: false,
|
||||
onClick: null
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
settingsHeaderAction.set({
|
||||
label: 'Save',
|
||||
disabled: true,
|
||||
loading: false,
|
||||
onClick: null
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Teams - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Header>
|
||||
<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>
|
||||
<TeamsPage />
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
const pageTitle = $derived(
|
||||
data.isOrgSite && data.org
|
||||
? 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(
|
||||
|
||||
+27
-2
@@ -19,7 +19,7 @@ const DEFAULT_META_TITLE =
|
||||
'ClassroomIO Docs | Customer, Partner, and Employee Education';
|
||||
const DEFAULT_META_DESCRIPTION =
|
||||
'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';
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
@@ -60,7 +60,7 @@ export const meta: MetaFunction = () => [
|
||||
},
|
||||
{
|
||||
property: 'og:image:type',
|
||||
content: 'image/png'
|
||||
content: 'image/jpeg'
|
||||
},
|
||||
{
|
||||
property: 'og:image:width',
|
||||
@@ -74,6 +74,31 @@ export const meta: MetaFunction = () => [
|
||||
property: 'og:image:secure_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',
|
||||
content: 'summary_large_image'
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
|
||||
|
||||
import type * as PageTree from 'fumadocs-core/page-tree';
|
||||
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 { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page';
|
||||
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> }) {
|
||||
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 { getDocsRouteData } = await import('@/lib/source.server');
|
||||
const routeData = await getDocsRouteData(slugs);
|
||||
|
||||
@@ -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.
|
||||
|
||||
<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
|
||||
|
||||
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.
|
||||
|
||||
<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
|
||||
|
||||
- [How Course Enrollment Works](/docs/how-to-guides/course-enrollment) — how students join courses
|
||||
- [How to Invite Students](/docs/how-to-guides/invite-students) — send course-specific invites
|
||||
- [Roles and Permissions](/docs/how-to-guides/roles-and-permissions) — difference between students and team members
|
||||
- [How Course Enrollment Works](/docs/course-enrollment) — how students join courses
|
||||
- [How to Invite Students](/docs/invite-students) — send course-specific invites
|
||||
- [Roles and Permissions](/docs/roles-and-permissions) — difference between students and team members
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
<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.
|
||||
|
||||

|
||||
|
||||
<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.
|
||||
|
||||

|
||||
</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.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
@@ -29,4 +29,4 @@ Admins can delete any question or comment. Authors can edit or delete their own
|
||||
## Related guides
|
||||
|
||||
- [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
|
||||
|
||||
+8
-7
@@ -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.
|
||||
|
||||
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">
|
||||
**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 |
|
||||
| --- | --- |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -181,9 +181,10 @@ Enable **Disable Signup** and **Internal Enrollment Only** in **Settings → Aut
|
||||
|
||||
## Related guides
|
||||
|
||||
- [How to Invite Students](/docs/how-to-guides/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
|
||||
- [How to Invite Students](/docs/invite-students) — create, send, and revoke course invite links
|
||||
- [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
|
||||
- [Set Up a Custom Domain](/docs/how-to-guides/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
|
||||
- [Creating Your First Course](/docs/how-to-guides/create-first-course) — create and publish a course
|
||||
- [Set Up a Custom Domain](/docs/custom-domain) — org URLs used in enrollment links
|
||||
- [Create a Course Landing Page](/docs/course-landingpage) — pricing, publish state, and what prospects see before enrolling
|
||||
- [Creating Your First Course](/docs/create-first-course) — create and publish a course
|
||||
+9
-9
@@ -6,7 +6,7 @@ description: Build and edit a course landing page, including pricing, hero butto
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
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
|
||||
|
||||
@@ -47,7 +47,7 @@ Use the preview control next to **Save** to see the page. Confirm behavior on th
|
||||
| **Instructors** | Instructor cards |
|
||||
| **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
|
||||
|
||||
@@ -72,11 +72,11 @@ Landing page content can be ready before you open enrollment:
|
||||
| **Allow New Students** | Public **Enroll** works (for free courses) |
|
||||
| **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
|
||||
|
||||
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">
|
||||
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
|
||||
|
||||
- [How Course Enrollment Works](/docs/how-to-guides/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
|
||||
- [Course Types](/docs/how-to-guides/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
|
||||
- [Creating Your First Course](/docs/how-to-guides/create-first-course)
|
||||
- [How Course Enrollment Works](/docs/course-enrollment) — who can join and how Explore relates to publish settings
|
||||
- [How to Invite Students](/docs/invite-students) — invite links when public enroll is off
|
||||
- [Course Types](/docs/course-types) — how type affects public vs LMS access
|
||||
- [Set Up a Custom Domain](/docs/custom-domain) — host for your landing page URL
|
||||
- [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. |
|
||||
|
||||

|
||||
|
||||
<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. |
|
||||
|
||||

|
||||
|
||||
<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.
|
||||
|
||||

|
||||
</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.
|
||||
|
||||

|
||||
</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.
|
||||
|
||||

|
||||
</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
|
||||
+6
-3
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
@@ -68,6 +69,8 @@ Open the course sidebar after changing type to confirm the right tools are avail
|
||||
## Related guides
|
||||
|
||||
- [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
|
||||
- [Creating Your First Course](/docs/how-to-guides/create-first-course)
|
||||
- [Creating Your First Course](/docs/create-first-course)
|
||||
@@ -5,7 +5,7 @@ description: Overview of courses in ClassroomIO — lifecycle, settings, admin a
|
||||
|
||||
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
|
||||
|
||||
@@ -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 |
|
||||
| **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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
<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>
|
||||
|
||||
## 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.
|
||||
|
||||
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
|
||||
|
||||
@@ -55,10 +55,10 @@ After enrolling, students open the course from **Home** or **My Learning** in th
|
||||
|
||||
## Related guides
|
||||
|
||||
- [Creating Your First Course](/docs/how-to-guides/create-first-course)
|
||||
- [Course Types](/docs/how-to-guides/course-types)
|
||||
- [How Course Enrollment Works](/docs/how-to-guides/course-enrollment)
|
||||
- [How to Invite Students](/docs/how-to-guides/invite-students)
|
||||
- [Create a Course Landing Page](/docs/how-to-guides/course-landingpage)
|
||||
- [Creating Your First Course](/docs/create-first-course)
|
||||
- [Course Types](/docs/course-types)
|
||||
- [How Course Enrollment Works](/docs/course-enrollment)
|
||||
- [How to Invite Students](/docs/invite-students)
|
||||
- [Create a Course Landing Page](/docs/course-landingpage)
|
||||
- [Student Dashboard](/docs/student-dashboard)
|
||||
|
||||
+23
-1
@@ -3,9 +3,10 @@ title: How to Create an Exercise
|
||||
description: Learn how to create exercises in ClassroomIO.
|
||||
---
|
||||
|
||||
import { Callout } from 'fumadocs-ui/components/callout';
|
||||
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>
|
||||
<Step>
|
||||
@@ -105,3 +106,24 @@ You've successfully created your first exercise and it is now accessible to stud
|
||||
|
||||
</Step>
|
||||
</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**.
|
||||
|
||||

|
||||
|
||||
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
|
||||
+5
-5
@@ -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 |
|
||||
| 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
|
||||
|
||||
@@ -73,8 +73,8 @@ Students should sign in on your academy so sessions and redirects stay on your a
|
||||
|
||||
## Related guides
|
||||
|
||||
- [Customize Your Organization](/docs/how-to-guides/customize-organization) — org profile and default subdomain
|
||||
- [Customize Your Academy Landing Page](/docs/how-to-guides/org-landing-page) — public homepage on your domain
|
||||
- [How to Invite Students](/docs/how-to-guides/invite-students) — enrollment links on your academy
|
||||
- [How Course Enrollment Works](/docs/how-to-guides/course-enrollment) — enrollment paths and org signup
|
||||
- [Customize Your Organization](/docs/customize-organization) — org profile and default subdomain
|
||||
- [Customize Your Academy Landing Page](/docs/org-landing-page) — public homepage on your domain
|
||||
- [How to Invite Students](/docs/invite-students) — enrollment links on your academy
|
||||
- [How Course Enrollment Works](/docs/course-enrollment) — enrollment paths and org signup
|
||||
- [Organization](/docs/organization) — org settings overview
|
||||
+5
-5
@@ -91,9 +91,9 @@ Click on Update Domain to save the changes.
|
||||
</Step>
|
||||
</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
|
||||
|
||||
@@ -102,6 +102,6 @@ We understand that every organization has a preferred color that suits their bra
|
||||
## Related guides
|
||||
|
||||
- [Organization](/docs/organization) — org settings overview
|
||||
- [Customize Your Academy Landing Page](/docs/how-to-guides/org-landing-page) — public homepage
|
||||
- [Set Up a Custom Domain](/docs/how-to-guides/custom-domain) — your own hostname
|
||||
- [Roles and Permissions](/docs/how-to-guides/roles-and-permissions) — team access
|
||||
- [Customize Your Academy Landing Page](/docs/org-landing-page) — public homepage
|
||||
- [Set Up a Custom Domain](/docs/custom-domain) — your own hostname
|
||||
- [Roles and Permissions](/docs/roles-and-permissions) — team access
|
||||
+2
-2
@@ -66,6 +66,6 @@ Click **Grade with AI** inside the grading modal to get an AI-suggested score an
|
||||
|
||||
## Related guides
|
||||
|
||||
- [How to Create an Exercise](/docs/how-to-guides/create-exercise) — create and configure exercises
|
||||
- [How to Create Certificates](/docs/how-to-guides/certificates) — certificates that depend on exercise scores
|
||||
- [How to Create an Exercise](/docs/create-exercise) — create and configure exercises
|
||||
- [How to Create Certificates](/docs/certificates) — certificates that depend on exercise scores
|
||||
- [Course](/docs/course) — course overview including the Submissions tab
|
||||
@@ -5,7 +5,7 @@ description: Learn how to create customer academies, partner certification progr
|
||||
|
||||
<img
|
||||
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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
+4
-4
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
@@ -80,7 +80,7 @@ Revoking does not remove students who have already joined; it only invalidates t
|
||||
|
||||
## Related guides
|
||||
|
||||
- [How Course Enrollment Works](/docs/how-to-guides/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
|
||||
- [Set Up a Custom Domain](/docs/how-to-guides/custom-domain) — which host appears in invite links
|
||||
- [How Course Enrollment Works](/docs/course-enrollment) — enrollment paths, Allow New Students, and Explore visibility
|
||||
- [Roles and Permissions](/docs/roles-and-permissions) — who can manage courses vs learn in the LMS
|
||||
- [Set Up a Custom Domain](/docs/custom-domain) — which host appears in invite links
|
||||
- [Student Dashboard](/docs/student-dashboard) — where students land after joining
|
||||
+6
-6
@@ -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.
|
||||
|
||||
<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**.
|
||||
|
||||
## Invite by email
|
||||
@@ -64,12 +68,8 @@ Toggle the link off to stop accepting new members through it. You can re-enable
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Callout type="warn" title="Paid plan required">
|
||||
Email invites and link invites for team members require a paid plan.
|
||||
</Callout>
|
||||
|
||||
## Related guides
|
||||
|
||||
- [Roles and Permissions](/docs/how-to-guides/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)
|
||||
- [Roles and Permissions](/docs/roles-and-permissions) — what admins and tutors can do
|
||||
- [How to Invite Students](/docs/invite-students) — invite students to a specific course (separate from team invites)
|
||||
- [Organization](/docs/organization) — org settings overview
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
<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.
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
<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.
|
||||
|
||||

|
||||
|
||||
<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
|
||||
@@ -18,8 +18,37 @@
|
||||
"student-dashboard",
|
||||
"enterprise-sso",
|
||||
|
||||
"---Guides---",
|
||||
"how-to-guides",
|
||||
"---Get started---",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
+10
-6
@@ -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**.
|
||||
|
||||
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
|
||||
|
||||
@@ -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 |
|
||||
| **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">
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -71,7 +75,7 @@ Always validate hero and footer links on the live site before sharing the URL ex
|
||||
|
||||
## Related guides
|
||||
|
||||
- [Customize Your Organization](/docs/how-to-guides/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
|
||||
- [Create a Course Landing Page](/docs/how-to-guides/course-landingpage) — per-course public pages (separate from the org homepage)
|
||||
- [Customize Your Organization](/docs/customize-organization) — org name, logo, brand color
|
||||
- [Set Up a Custom Domain](/docs/custom-domain) — use your own domain on the landing page
|
||||
- [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
|
||||
@@ -11,15 +11,15 @@ An **organization** in ClassroomIO holds courses, people, branding, and your aca
|
||||
|
||||
| Area | Where | Guide |
|
||||
| --- | --- | --- |
|
||||
| Name, logo, brand color | **Settings → Organization** | [Customize Your Organization](/docs/how-to-guides/customize-organization) |
|
||||
| Public homepage | **Settings → Landing Page** | [Customize Your Academy Landing Page](/docs/how-to-guides/org-landing-page) |
|
||||
| Custom domain | **Settings → Organization** | [Set Up a Custom Domain](/docs/how-to-guides/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) |
|
||||
| Signup rules, Internal Enrollment Only | **Settings → Authentication → General** | [How Course Enrollment Works](/docs/how-to-guides/course-enrollment) (org access section) |
|
||||
| Name, logo, brand color | **Settings → Organization** | [Customize Your Organization](/docs/customize-organization) |
|
||||
| Public homepage | **Settings → Landing Page** | [Customize Your Academy Landing Page](/docs/org-landing-page) |
|
||||
| Custom domain | **Settings → Organization** | [Set Up a Custom Domain](/docs/custom-domain) |
|
||||
| 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/course-enrollment) (org access section) |
|
||||
|
||||
## 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">
|
||||
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
|
||||
|
||||
- [Customize Your Organization](/docs/how-to-guides/customize-organization)
|
||||
- [Customize Your Academy Landing Page](/docs/how-to-guides/org-landing-page)
|
||||
- [Set Up a Custom Domain](/docs/how-to-guides/custom-domain)
|
||||
- [Roles and Permissions](/docs/how-to-guides/roles-and-permissions)
|
||||
- [How Course Enrollment Works](/docs/how-to-guides/course-enrollment)
|
||||
- [Customize Your Organization](/docs/customize-organization)
|
||||
- [Customize Your Academy Landing Page](/docs/org-landing-page)
|
||||
- [Set Up a Custom Domain](/docs/custom-domain)
|
||||
- [Roles and Permissions](/docs/roles-and-permissions)
|
||||
- [How Course Enrollment Works](/docs/course-enrollment)
|
||||
- [Course](/docs/course)
|
||||
- [Programs](/docs/programs)
|
||||
|
||||
@@ -62,6 +62,6 @@ Students who belong to a program open **Programs** in the LMS to see their track
|
||||
## Related guides
|
||||
|
||||
- [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
|
||||
- [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
|
||||
|
||||
@@ -53,7 +53,7 @@ You are successfully signed up and ready to use ClassroomIO when you see the das
|
||||
<Step>
|
||||
### 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>
|
||||
</Steps>
|
||||
|
||||
+5
-5
@@ -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.
|
||||
|
||||
<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>
|
||||
|
||||
## 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
|
||||
|
||||
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
|
||||
|
||||
@@ -55,8 +55,8 @@ Configure org signup under **Settings → Organization**. Configure per-course a
|
||||
|
||||
## Related guides
|
||||
|
||||
- [How Course Enrollment Works](/docs/how-to-guides/course-enrollment) — restrict who can join each course
|
||||
- [How to Invite Students](/docs/how-to-guides/invite-students) — course enrollment links
|
||||
- [Inviting a Team Member](/docs/how-to-guides/invite-team-member) — add admins and tutors
|
||||
- [How Course Enrollment Works](/docs/course-enrollment) — restrict who can join each course
|
||||
- [How to Invite Students](/docs/invite-students) — course enrollment links
|
||||
- [Inviting a Team Member](/docs/invite-team-member) — add admins and tutors
|
||||
- [Student Dashboard](/docs/student-dashboard) — what students see in the LMS
|
||||
- [Organization](/docs/organization) — org settings overview
|
||||
@@ -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).
|
||||
|
||||
<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>
|
||||
|
||||
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
|
||||
|
||||
- [How Course Enrollment Works](/docs/how-to-guides/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 Course Enrollment Works](/docs/course-enrollment) — how students join courses and what settings control access
|
||||
- [How to Invite Students](/docs/invite-students) — send enrollment links by email
|
||||
- [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
|
||||
|
||||
+2
-2
@@ -28,6 +28,6 @@ The table shows 10 students per page. Use the page controls at the bottom to mov
|
||||
|
||||
## Related guides
|
||||
|
||||
- [Creating Your First Course](/docs/how-to-guides/create-first-course) — adding lessons to a course
|
||||
- [How Course Enrollment Works](/docs/how-to-guides/course-enrollment) — how students join a course
|
||||
- [Creating Your First Course](/docs/create-first-course) — adding lessons to a course
|
||||
- [How Course Enrollment Works](/docs/course-enrollment) — how students join a course
|
||||
- [Course](/docs/course) — course overview and available tabs
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user