feat(course): auto-enrol button for paid course join requests (#769)

* feat(course): add auto-enrol flow for paid course join requests

- Extend teacher buy-request email with manual access steps and an
  Auto Enrol this Student button
- Link teachers to the course People page with grantAccess query param
- Add grant-access modal that sends the same org audience invite used
  by the Invite new students flow

Co-authored-by: Rotimi Best <[email protected]>

* fix(i18n): translate grant access modal strings

Replace English placeholders copied into non-en locale files with
proper translations for each supported language.

Co-authored-by: Rotimi Best <[email protected]>

* fix(email): shorten auto-enrol button label per review

Co-authored-by: Rotimi Best <[email protected]>

* fix(email): use American spelling Enroll in auto-enroll CTA

Co-authored-by: Rotimi Best <[email protected]>

* fix(i18n): localize Danish grant-access send invite label

Co-authored-by: Rotimi Best <[email protected]>

---------

Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Rotimi Best
2026-07-19 12:02:11 +01:00
committed by GitHub
co-authored by Rotimi Best
parent 634ffeab0f
commit 6c3a982daf
14 changed files with 180 additions and 0 deletions
@@ -5,6 +5,7 @@ import { getCourseWithOrgData } from '@cio/db/queries/course';
import { buildEmailFromName, buildEmailBranding } from '@cio/email';
import { enqueueTransactionalEmail } from '@api/services/jobs';
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
export interface PaymentRequestData {
courseId: string;
@@ -47,6 +48,15 @@ export async function createPaymentRequest(data: PaymentRequestData) {
const teacherEmail = teacherResult[0].email;
const dashboardOrg = {
siteName: course.orgSiteName,
customDomain: course.orgCustomDomain,
isCustomDomainVerified: course.orgIsCustomDomainVerified
};
const dashboardBaseUrl = getDashboardBaseUrl(dashboardOrg);
const courseUrl = `${dashboardBaseUrl}/courses/${data.courseId}`;
const autoEnrollUrl = `${dashboardBaseUrl}/courses/${data.courseId}/people?grantAccess=${encodeURIComponent(data.studentEmail)}`;
try {
await enqueueTransactionalEmail('teacherStudentBuyRequest', {
to: teacherEmail,
@@ -54,6 +64,8 @@ export async function createPaymentRequest(data: PaymentRequestData) {
courseName,
studentEmail: data.studentEmail,
studentFullname: data.studentFullname,
courseUrl,
autoEnrollUrl,
branding
},
from: buildEmailFromName('ClassroomIO'),
@@ -0,0 +1,91 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import { courseApi } from '$features/course/api';
import { orgApi } from '$features/org/api/org.svelte';
import { profile } from '$lib/utils/store/user';
import { isStudentLimitReached } from '$lib/utils/store/org';
import { UpgradeBanner } from '$features/ui';
const GRANT_ACCESS_PARAM = 'grantAccess';
let courseId = $derived(courseApi.course?.id ?? '');
const studentEmail = $derived(new URLSearchParams(page.url.search).get(GRANT_ACCESS_PARAM)?.trim() ?? '');
const isOpen = $derived(Boolean(studentEmail));
let isSubmitting = $state(false);
function closeModal() {
const nextParams = new URLSearchParams(page.url.search);
nextParams.delete(GRANT_ACCESS_PARAM);
const query = nextParams.toString();
goto(resolve(`${page.url.pathname}${query ? `?${query}` : ''}`, {}));
}
async function handleSendInvite() {
if (!courseId || !studentEmail || $isStudentLimitReached) {
return;
}
isSubmitting = true;
try {
const response = await orgApi.importAudienceMembers({
recipientCsv: studentEmail,
courseIds: [courseId],
sendEmail: true
});
if (!response) {
return;
}
await courseApi.refreshCourse(courseId, $profile.id);
closeModal();
} finally {
isSubmitting = false;
}
}
</script>
<Dialog.Root
open={isOpen}
onOpenChange={(open) => {
if (!open) closeModal();
}}
>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.people.grant_access_modal.title')}</Dialog.Title>
<Dialog.Description>
{$t('course.navItem.people.grant_access_modal.description', { email: studentEmail })}
</Dialog.Description>
</Dialog.Header>
{#if $isStudentLimitReached}
<UpgradeBanner removeParams={[GRANT_ACCESS_PARAM]}>
{$t('course.navItem.people.invite_modal.student_limit_reached')}
</UpgradeBanner>
{/if}
<div class="flex justify-end gap-2">
<Button type="button" variant="outline" onclick={closeModal} disabled={isSubmitting}>
{$t('course.navItem.people.grant_access_modal.cancel')}
</Button>
<Button
type="button"
variant="secondary"
onclick={handleSendInvite}
loading={isSubmitting}
disabled={$isStudentLimitReached || !studentEmail}
>
{$t('course.navItem.people.grant_access_modal.send_invite')}
</Button>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -11,6 +11,7 @@
import * as Avatar from '@cio/ui/base/avatar';
import { ComingSoon, RoleBasedSecurity, UpgradeBanner } from '$features/ui';
import InvitationModal from '$features/course/components/people/invitation-modal.svelte';
import GrantAccessModal from '$features/course/components/people/grant-access-modal.svelte';
import DeleteConfirmation from '$features/course/components/people/delete-confirmation.svelte';
import { isStudentLimitReached } from '$lib/utils/store/org';
@@ -101,6 +102,7 @@
</script>
<InvitationModal />
<GrantAccessModal />
{#if $isStudentLimitReached}
<UpgradeBanner className="mb-2">{$t('course.navItem.people.invite_modal.student_limit_reached')}</UpgradeBanner>
@@ -1198,6 +1198,12 @@
"no_matching_existing_students": "No organization students match your search.",
"notify_existing_students": "Send course access email",
"grant_access": "Grant Access",
"grant_access_modal": {
"title": "Giv studerende adgang",
"description": "Giv {email} adgang til dette kursus. Vi sender en invitationsmail, så vedkommende kan tilslutte sig din organisation og tilmelde sig kurset.",
"cancel": "Annuller",
"send_invite": "Send invitationen"
},
"invite_new_students_title": "Invite new students",
"invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.",
"invite_new_students": "Invite Students",
@@ -1198,6 +1198,12 @@
"no_matching_existing_students": "No organization students match your search.",
"notify_existing_students": "Send course access email",
"grant_access": "Grant Access",
"grant_access_modal": {
"title": "Schülerzugang gewähren",
"description": "Gewähren Sie {email} Zugang zu diesem Kurs. Wir senden eine Einladungs-E-Mail, damit die Person Ihrer Organisation beitreten und in diesem Kurs eingeschrieben werden kann.",
"cancel": "Abbrechen",
"send_invite": "Einladung senden"
},
"invite_new_students_title": "Invite new students",
"invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.",
"invite_new_students": "Invite Students",
@@ -3230,6 +3230,12 @@
"no_matching_existing_students": "No organization students match your search.",
"notify_existing_students": "Send course access email",
"grant_access": "Grant Access",
"grant_access_modal": {
"title": "Give student access",
"description": "Give {email} access to this course. We'll send them an invite email to join your organization and enroll in this course.",
"cancel": "Cancel",
"send_invite": "Send invite"
},
"invite_new_students_title": "Invite new students",
"invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.",
"invite_new_students": "Invite Students",
@@ -1198,6 +1198,12 @@
"no_matching_existing_students": "No organization students match your search.",
"notify_existing_students": "Send course access email",
"grant_access": "Grant Access",
"grant_access_modal": {
"title": "Dar acceso al estudiante",
"description": "Da acceso a {email} a este curso. Le enviaremos un correo de invitación para unirse a tu organización e inscribirse en este curso.",
"cancel": "Cancelar",
"send_invite": "Enviar invitación"
},
"invite_new_students_title": "Invite new students",
"invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.",
"invite_new_students": "Invite Students",
@@ -1198,6 +1198,12 @@
"no_matching_existing_students": "No organization students match your search.",
"notify_existing_students": "Send course access email",
"grant_access": "Grant Access",
"grant_access_modal": {
"title": "Accorder l'accès à l'étudiant",
"description": "Accordez à {email} l'accès à ce cours. Nous lui enverrons un e-mail d'invitation pour rejoindre votre organisation et s'inscrire à ce cours.",
"cancel": "Annuler",
"send_invite": "Envoyer l'invitation"
},
"invite_new_students_title": "Invite new students",
"invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.",
"invite_new_students": "Invite Students",
@@ -1198,6 +1198,12 @@
"no_matching_existing_students": "No organization students match your search.",
"notify_existing_students": "Send course access email",
"grant_access": "Grant Access",
"grant_access_modal": {
"title": "छात्र को एक्सेस दें",
"description": "{email} को इस कोर्स का एक्सेस दें। हम उन्हें आपके संगठन में शामिल होने और इस कोर्स में नामांकन के लिए आमंत्रण ईमेल भेजेंगे।",
"cancel": "रद्द करें",
"send_invite": "निमंत्रण भेजें"
},
"invite_new_students_title": "Invite new students",
"invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.",
"invite_new_students": "Invite Students",
@@ -1198,6 +1198,12 @@
"no_matching_existing_students": "No organization students match your search.",
"notify_existing_students": "Send course access email",
"grant_access": "Grant Access",
"grant_access_modal": {
"title": "Udostępnij dostęp studentowi",
"description": "Przyznaj {email} dostęp do tego kursu. Wyślemy zaproszenie e-mailem, aby dołączył do Twojej organizacji i zapisał się na ten kurs.",
"cancel": "Anuluj",
"send_invite": "Wyślij zaproszenie"
},
"invite_new_students_title": "Invite new students",
"invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.",
"invite_new_students": "Invite Students",
@@ -1198,6 +1198,12 @@
"no_matching_existing_students": "No organization students match your search.",
"notify_existing_students": "Send course access email",
"grant_access": "Grant Access",
"grant_access_modal": {
"title": "Conceder acesso ao aluno",
"description": "Conceda acesso a {email} a este curso. Enviaremos um e-mail de convite para que a pessoa entre na sua organização e se inscreva neste curso.",
"cancel": "Cancelar",
"send_invite": "Enviar convite"
},
"invite_new_students_title": "Invite new students",
"invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.",
"invite_new_students": "Invite Students",
@@ -1198,6 +1198,12 @@
"no_matching_existing_students": "No organization students match your search.",
"notify_existing_students": "Send course access email",
"grant_access": "Grant Access",
"grant_access_modal": {
"title": "Предоставить доступ студенту",
"description": "Предоставьте {email} доступ к этому курсу. Мы отправим приглашение по электронной почте, чтобы присоединиться к вашей организации и записаться на курс.",
"cancel": "Отмена",
"send_invite": "Отправить приглашение"
},
"invite_new_students_title": "Invite new students",
"invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.",
"invite_new_students": "Invite Students",
@@ -1198,6 +1198,12 @@
"no_matching_existing_students": "No organization students match your search.",
"notify_existing_students": "Send course access email",
"grant_access": "Grant Access",
"grant_access_modal": {
"title": "Cấp quyền truy cập cho học viên",
"description": "Cấp cho {email} quyền truy cập khóa học này. Chúng tôi sẽ gửi email mời để tham gia tổ chức của bạn và đăng ký khóa học.",
"cancel": "Hủy",
"send_invite": "Gửi lời mời"
},
"invite_new_students_title": "Invite new students",
"invite_new_students_description": "Invite new people into your organization and pre-assign them to this course. They will create an account, accept the invite, and get access.",
"invite_new_students": "Invite Students",