fix: validation of allowing teachers create compliance courses without setting deadline (#778)
* Fixed Validation of allowing teachers create compliance courses without setting deadline * greptile highlightCertificateSettings decraration suggestion * greptile multiple error bar showing fix suggestion * organization race condition * Delete screenshots/Screenshot 2026-07-22 at 11.52.00 AM.png * deleted unwanted files * code review fixes * Changed AttentionHightlight component to work with just one parameter created fixed Route Enums, Paths and Sections for * Kebab-case naming fix * Changes for code comments --------- Co-authored-by: Rotimi Best <best@classroomio.com>
This commit is contained in:
co-authored by
Rotimi Best
parent
05936e788d
commit
60056a6236
@@ -23,13 +23,22 @@ class AiTutorApi extends BaseApiWithErrors {
|
|||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let settings: OrgAiTutorSettings | null = null;
|
||||||
|
|
||||||
await this.execute<GetOrgAiTutorRequest>({
|
await this.execute<GetOrgAiTutorRequest>({
|
||||||
requestFn: () => classroomio.organization['ai-tutor'].$get(),
|
requestFn: () => classroomio.organization['ai-tutor'].$get(),
|
||||||
logContext: 'fetching org AI tutor settings',
|
logContext: 'fetching org AI tutor settings',
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
|
settings = response.data;
|
||||||
this.orgSettings = response.data;
|
this.orgSettings = response.data;
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
settings = null;
|
||||||
|
this.orgSettings = null;
|
||||||
|
snackbar.error('aiTutor.snackbar.fetchError');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
return settings;
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { Switch } from '@cio/ui/base/switch';
|
import { Switch } from '@cio/ui/base/switch';
|
||||||
import { Label } from '@cio/ui/base/label';
|
import { Label } from '@cio/ui/base/label';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
import { AttentionHighlight } from '$features/ui';
|
||||||
import { aiTutorApi } from '../api/ai-tutor.svelte';
|
import { aiTutorApi } from '../api/ai-tutor.svelte';
|
||||||
import TutorSettingsForm from '../components/tutor-settings-form.svelte';
|
import TutorSettingsForm from '../components/tutor-settings-form.svelte';
|
||||||
|
|
||||||
@@ -91,6 +92,7 @@
|
|||||||
<p class="ui:text-muted-foreground text-sm">{$t('aiTutor.state.loading')}</p>
|
<p class="ui:text-muted-foreground text-sm">{$t('aiTutor.state.loading')}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<Field.Group>
|
<Field.Group>
|
||||||
|
<AttentionHighlight id="inheritance">
|
||||||
<Field.Set>
|
<Field.Set>
|
||||||
<Field.Legend>{$t('aiTutor.section.inheritance')}</Field.Legend>
|
<Field.Legend>{$t('aiTutor.section.inheritance')}</Field.Legend>
|
||||||
<Field.Description>{$t('aiTutor.section.inheritance_description')}</Field.Description>
|
<Field.Description>{$t('aiTutor.section.inheritance_description')}</Field.Description>
|
||||||
@@ -100,10 +102,13 @@
|
|||||||
<Label>{$t('aiTutor.field.inheritFromOrg')}</Label>
|
<Label>{$t('aiTutor.field.inheritFromOrg')}</Label>
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
</Field.Set>
|
</Field.Set>
|
||||||
|
</AttentionHighlight>
|
||||||
|
|
||||||
{#if !inheritFromOrg}
|
{#if !inheritFromOrg}
|
||||||
<Field.Separator />
|
<Field.Separator />
|
||||||
|
<AttentionHighlight id="tutor-settings">
|
||||||
<TutorSettingsForm store={formStore} disabled={aiTutorApi.saving} />
|
<TutorSettingsForm store={formStore} disabled={aiTutorApi.saving} />
|
||||||
|
</AttentionHighlight>
|
||||||
{/if}
|
{/if}
|
||||||
</Field.Group>
|
</Field.Group>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { defaultAiTutorSettings } from '@cio/ai-assistant/tutor-config';
|
import { defaultAiTutorSettings } from '@cio/ai-assistant/tutor-config';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
@@ -8,6 +7,7 @@
|
|||||||
import { aiTutorApi } from '../api/ai-tutor.svelte';
|
import { aiTutorApi } from '../api/ai-tutor.svelte';
|
||||||
import { applyOrgSettings, orgTutorSettingsStore } from '../store/tutor-settings-store';
|
import { applyOrgSettings, orgTutorSettingsStore } from '../store/tutor-settings-store';
|
||||||
import TutorSettingsForm from '../components/tutor-settings-form.svelte';
|
import TutorSettingsForm from '../components/tutor-settings-form.svelte';
|
||||||
|
import { currentOrg } from '$lib/utils/store/org';
|
||||||
|
|
||||||
let initialized = $state(false);
|
let initialized = $state(false);
|
||||||
let savedSettingsSnapshot = $state('');
|
let savedSettingsSnapshot = $state('');
|
||||||
@@ -22,11 +22,25 @@
|
|||||||
savedSettingsSnapshot = currentSettingsSnapshot;
|
savedSettingsSnapshot = currentSettingsSnapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
let fetchedOrgId = $state<string | null>(null);
|
||||||
|
|
||||||
|
async function intitializeSettings() {
|
||||||
await aiTutorApi.fetchOrgSettings();
|
await aiTutorApi.fetchOrgSettings();
|
||||||
|
|
||||||
applyOrgSettings(aiTutorApi.orgSettings ?? defaultAiTutorSettings);
|
applyOrgSettings(aiTutorApi.orgSettings ?? defaultAiTutorSettings);
|
||||||
|
|
||||||
captureSavedSnapshot();
|
captureSavedSnapshot();
|
||||||
|
|
||||||
initialized = true;
|
initialized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const orgId = $currentOrg?.id;
|
||||||
|
|
||||||
|
if (!orgId || fetchedOrgId === orgId) return;
|
||||||
|
fetchedOrgId = orgId;
|
||||||
|
|
||||||
|
intitializeSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
|
|||||||
@@ -322,6 +322,15 @@ export class CourseApi extends BaseApiWithErrors {
|
|||||||
orgName: get(currentOrg)?.name ?? ''
|
orgName: get(currentOrg)?.name ?? ''
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
} else if (
|
||||||
|
typeof result === 'object' &&
|
||||||
|
result !== null &&
|
||||||
|
'code' in result &&
|
||||||
|
result.code === 'VALIDATION_ERROR' &&
|
||||||
|
'field' in result &&
|
||||||
|
result.field === 'certificate.deadline'
|
||||||
|
) {
|
||||||
|
snackbar.error('course.certification.deadline_required_enroll');
|
||||||
} else if (typeof result === 'object' && result !== null && 'error' in result) {
|
} else if (typeof result === 'object' && result !== null && 'error' in result) {
|
||||||
snackbar.error((result as { error: string }).error);
|
snackbar.error((result as { error: string }).error);
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
|
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
|
||||||
@@ -12,7 +13,17 @@
|
|||||||
activeTab?: string;
|
activeTab?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { errors, activeTab = $bindable('design') }: Props = $props();
|
let { errors }: Props = $props();
|
||||||
|
|
||||||
|
let activeTab = $state('design');
|
||||||
|
|
||||||
|
// Initialize activeTab from URL parameter if present
|
||||||
|
$effect(() => {
|
||||||
|
const tabParam = page.url.searchParams.get('tab');
|
||||||
|
if (tabParam && (tabParam === 'design' || tabParam === 'settings')) {
|
||||||
|
activeTab = tabParam;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<UpgradeBanner>{$t('upgrade.certificate')}</UpgradeBanner>
|
<UpgradeBanner>{$t('upgrade.certificate')}</UpgradeBanner>
|
||||||
|
|||||||
+30
-1
@@ -7,7 +7,9 @@
|
|||||||
import { courseApi } from '$features/course/api';
|
import { courseApi } from '$features/course/api';
|
||||||
import { isFreePlan } from '$lib/utils/store/org';
|
import { isFreePlan } from '$lib/utils/store/org';
|
||||||
import { getOrderedNavigableContent } from '$features/course/utils/content';
|
import { getOrderedNavigableContent } from '$features/course/utils/content';
|
||||||
|
import type { CourseCertificate } from '$features/course/utils/types';
|
||||||
import { ContentType } from '@cio/utils/constants/content';
|
import { ContentType } from '@cio/utils/constants/content';
|
||||||
|
import { AttentionHighlight } from '$features/ui';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
errors: Record<string, string>;
|
errors: Record<string, string>;
|
||||||
@@ -70,6 +72,16 @@
|
|||||||
function onEmailMessageInput(e: Event) {
|
function onEmailMessageInput(e: Event) {
|
||||||
updateCertificate({ emailMessage: (e.currentTarget as HTMLTextAreaElement).value || null });
|
updateCertificate({ emailMessage: (e.currentTarget as HTMLTextAreaElement).value || null });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateCertificateSettings(certificate: CourseCertificate | null | undefined): string[] {
|
||||||
|
const errors: string[] = [];
|
||||||
|
if (certificate?.isDownloadable && !certificate?.deadline) {
|
||||||
|
errors.push('course.certification.deadline_required');
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
const certificateValidationErrors = $derived(validateCertificateSettings(courseApi.course?.certificate));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Field.Group class="w-full max-w-md! px-2">
|
<Field.Group class="w-full max-w-md! px-2">
|
||||||
@@ -85,14 +97,25 @@
|
|||||||
}}
|
}}
|
||||||
disabled={$isFreePlan}
|
disabled={$isFreePlan}
|
||||||
/>
|
/>
|
||||||
|
<div class="flex-1">
|
||||||
<Field.Label for="certificate-downloadable" class="text-gray-600">
|
<Field.Label for="certificate-downloadable" class="text-gray-600">
|
||||||
{$t('course.navItem.certificates.allow')}
|
{$t('course.navItem.certificates.allow')}
|
||||||
</Field.Label>
|
</Field.Label>
|
||||||
|
{#if courseApi.course?.certificate?.isDownloadable && certificateValidationErrors.includes('course.certification.deadline_required')}
|
||||||
|
<Field.Error class="mt-1">{$t('course.certification.deadline_required')}</Field.Error>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
|
||||||
<Field.Group>
|
<Field.Group>
|
||||||
|
<AttentionHighlight id="cert-deadline">
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label for="cert-deadline">{$t('course.certification.deadline_label')}</Field.Label>
|
<Field.Label for="cert-deadline">
|
||||||
|
{$t('course.certification.deadline_label')}
|
||||||
|
{#if courseApi.course?.certificate?.isDownloadable}
|
||||||
|
<span class="ui:text-red-600">*</span>
|
||||||
|
{/if}
|
||||||
|
</Field.Label>
|
||||||
<Input
|
<Input
|
||||||
id="cert-deadline"
|
id="cert-deadline"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
@@ -105,8 +128,13 @@
|
|||||||
{#if errors['certificate.deadline']}
|
{#if errors['certificate.deadline']}
|
||||||
<Field.Error>{errors['certificate.deadline']}</Field.Error>
|
<Field.Error>{errors['certificate.deadline']}</Field.Error>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if certificateValidationErrors.includes('course.certification.deadline_required')}
|
||||||
|
<Field.Error>{$t('course.certification.deadline_required')}</Field.Error>
|
||||||
|
{/if}
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
</AttentionHighlight>
|
||||||
|
|
||||||
|
<AttentionHighlight id="cert-threshold">
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label for="cert-threshold">{$t('course.certification.threshold_label')}</Field.Label>
|
<Field.Label for="cert-threshold">{$t('course.certification.threshold_label')}</Field.Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -124,6 +152,7 @@
|
|||||||
<Field.Error>{errors['certificate.threshold']}</Field.Error>
|
<Field.Error>{errors['certificate.threshold']}</Field.Error>
|
||||||
{/if}
|
{/if}
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
</AttentionHighlight>
|
||||||
|
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label for="cert-final-exercise">{$t('course.certification.final_exercise_label')}</Field.Label>
|
<Field.Label for="cert-final-exercise">{$t('course.certification.final_exercise_label')}</Field.Label>
|
||||||
|
|||||||
+3
-6
@@ -7,6 +7,8 @@
|
|||||||
import { copyPublicCoursePageUrl, openCoursePreview } from '$features/course/utils/course-preview';
|
import { copyPublicCoursePageUrl, openCoursePreview } from '$features/course/utils/course-preview';
|
||||||
import { currentOrgDomain } from '$lib/utils/store/org';
|
import { currentOrgDomain } from '$lib/utils/store/org';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
|
import { goAndHighlight } from '$lib/routing/go-and-highlight';
|
||||||
|
import { ROUTE_NAME, ROUTE_SECTIONS } from '$lib/routing/routes';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -65,12 +67,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handlePublishCourse() {
|
function handlePublishCourse() {
|
||||||
if (page.url.pathname === courseSettingsPath) {
|
goAndHighlight(ROUTE_NAME.COURSE_SETTINGS, ROUTE_SECTIONS[ROUTE_NAME.COURSE_SETTINGS].PUBLISH, { id });
|
||||||
scrollToSettingsSection('publish');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
redirect(`/courses/${id}/settings#publish`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDeleteCourse() {
|
function handleDeleteCourse() {
|
||||||
|
|||||||
@@ -418,7 +418,7 @@ export function handleReorderQuestionsInSection(exerciseSectionId: string, order
|
|||||||
.map((questionId) => questionById.get(String(questionId)))
|
.map((questionId) => questionById.get(String(questionId)))
|
||||||
.filter(
|
.filter(
|
||||||
(question): question is Question =>
|
(question): question is Question =>
|
||||||
Boolean(question) && !question.deletedAt && question.exerciseSectionId === exerciseSectionId
|
Boolean(question) && !question?.deletedAt && question?.exerciseSectionId === exerciseSectionId
|
||||||
);
|
);
|
||||||
const missingSectionQuestions = sectionQuestions.filter(
|
const missingSectionQuestions = sectionQuestions.filter(
|
||||||
(question) => !orderedQuestionIdSet.has(String(question.id))
|
(question) => !orderedQuestionIdSet.has(String(question.id))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { Switch } from '@cio/ui/base/switch';
|
import { Switch } from '@cio/ui/base/switch';
|
||||||
import * as RadioGroup from '@cio/ui/base/radio-group';
|
import * as RadioGroup from '@cio/ui/base/radio-group';
|
||||||
import * as Select from '@cio/ui/base/select';
|
import * as Select from '@cio/ui/base/select';
|
||||||
|
import * as Dialog from '@cio/ui/base/dialog';
|
||||||
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
|
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
|
||||||
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
|
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
|
||||||
import XIcon from '@lucide/svelte/icons/x';
|
import XIcon from '@lucide/svelte/icons/x';
|
||||||
@@ -16,8 +17,10 @@
|
|||||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||||
import { InputField } from '@cio/ui/custom/input-field';
|
import { InputField } from '@cio/ui/custom/input-field';
|
||||||
import * as Field from '@cio/ui/base/field';
|
import * as Field from '@cio/ui/base/field';
|
||||||
import { UpgradeBanner, UnsavedChanges, UploadWidget, TextEditor } from '$features/ui';
|
import { UpgradeBanner, UnsavedChanges, UploadWidget, TextEditor, AttentionHighlight } from '$features/ui';
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
|
import { goAndHighlight } from '$lib/routing/go-and-highlight';
|
||||||
|
import { ROUTE_NAME, ROUTE_SECTIONS } from '$lib/routing/routes';
|
||||||
|
|
||||||
import { settings } from '$features/course/utils/settings-store';
|
import { settings } from '$features/course/utils/settings-store';
|
||||||
import Copy from '@lucide/svelte/icons/copy';
|
import Copy from '@lucide/svelte/icons/copy';
|
||||||
@@ -48,6 +51,7 @@
|
|||||||
|
|
||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let isDeleting = $state(false);
|
let isDeleting = $state(false);
|
||||||
|
let openCertificateDeadlineDialog = $state(false);
|
||||||
let errors: {
|
let errors: {
|
||||||
title: string | undefined;
|
title: string | undefined;
|
||||||
description: string | undefined;
|
description: string | undefined;
|
||||||
@@ -158,6 +162,38 @@
|
|||||||
isDeleting = false;
|
isDeleting = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onPublishToggle(checked: boolean) {
|
||||||
|
if (!checked) {
|
||||||
|
$settings.isPublished = false;
|
||||||
|
hasUnsavedChanges = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCourseCompliance = $settings.type === 'COMPLIANCE';
|
||||||
|
const hasCertificateDeadline = !!courseApi.course?.certificate?.deadline;
|
||||||
|
|
||||||
|
if (isCourseCompliance && !hasCertificateDeadline) {
|
||||||
|
openCertificateDeadlineDialog = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, publish normally
|
||||||
|
$settings.isPublished = true;
|
||||||
|
$settings.allowNewStudents = true;
|
||||||
|
hasUnsavedChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToCertificateSettings() {
|
||||||
|
openCertificateDeadlineDialog = false;
|
||||||
|
const courseId = courseApi.course?.id;
|
||||||
|
if (!courseId) return;
|
||||||
|
|
||||||
|
goAndHighlight(ROUTE_NAME.COURSE_CERTIFICATE, ROUTE_SECTIONS[ROUTE_NAME.COURSE_CERTIFICATE].CERT_DEADLINE, {
|
||||||
|
id: courseId,
|
||||||
|
tab: 'settings'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleSave() {
|
export async function handleSave() {
|
||||||
if (!$settings.courseTitle) {
|
if (!$settings.courseTitle) {
|
||||||
errors.title = $t('snackbar.course_settings.error.title');
|
errors.title = $t('snackbar.course_settings.error.title');
|
||||||
@@ -347,17 +383,6 @@
|
|||||||
loadCourseTags(courseId);
|
loadCourseTags(courseId);
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const sectionId = $page.url.hash.replace('#', '').trim();
|
|
||||||
if (!sectionId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
document.getElementById(sectionId)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedTagChips = $derived.by(() => {
|
const selectedTagChips = $derived.by(() => {
|
||||||
const allTags = tagApi.tagGroups.flatMap((group) =>
|
const allTags = tagApi.tagGroups.flatMap((group) =>
|
||||||
group.tags.map((tag) => ({
|
group.tags.map((tag) => ({
|
||||||
@@ -397,6 +422,25 @@
|
|||||||
|
|
||||||
<DeleteModal onDelete={handleDeleteCourse} bind:open={openDeleteModal} />
|
<DeleteModal onDelete={handleDeleteCourse} bind:open={openDeleteModal} />
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={openCertificateDeadlineDialog}>
|
||||||
|
<Dialog.Content class="max-w-md">
|
||||||
|
<Dialog.Header>
|
||||||
|
<Dialog.Title>{$t('course.navItem.settings.certificate_deadline_required')}</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
{$t('course.navItem.settings.certificate_deadline_required_description')}
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
<Dialog.Footer>
|
||||||
|
<Button variant="outline" onclick={() => (openCertificateDeadlineDialog = false)}>
|
||||||
|
{$t('cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button onclick={goToCertificateSettings}>
|
||||||
|
{$t('course.navItem.settings.go_to_certificate_settings')}
|
||||||
|
</Button>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
<Field.Group class="w-full max-w-md! px-2">
|
<Field.Group class="w-full max-w-md! px-2">
|
||||||
<Field.Set>
|
<Field.Set>
|
||||||
<Field.Legend>{$t('course.navItem.settings.cover_image')}</Field.Legend>
|
<Field.Legend>{$t('course.navItem.settings.cover_image')}</Field.Legend>
|
||||||
@@ -848,20 +892,14 @@
|
|||||||
<Field.Set id="publish">
|
<Field.Set id="publish">
|
||||||
<Field.Legend>{$t('course.navItem.settings.publish')}</Field.Legend>
|
<Field.Legend>{$t('course.navItem.settings.publish')}</Field.Legend>
|
||||||
<Field.Description>{$t('course.navItem.settings.determines')}</Field.Description>
|
<Field.Description>{$t('course.navItem.settings.determines')}</Field.Description>
|
||||||
|
<AttentionHighlight id="publish">
|
||||||
<Field.Field orientation="horizontal">
|
<Field.Field orientation="horizontal">
|
||||||
<Switch
|
<Switch id="is-published" checked={$settings.isPublished} onCheckedChange={onPublishToggle} />
|
||||||
id="is-published"
|
|
||||||
checked={$settings.isPublished}
|
|
||||||
onCheckedChange={(checked) => {
|
|
||||||
$settings.isPublished = checked;
|
|
||||||
if (checked) $settings.allowNewStudents = true;
|
|
||||||
hasUnsavedChanges = true;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Label for="publish">
|
<Label for="publish">
|
||||||
{$settings.isPublished ? $t('course.navItem.settings.published') : $t('course.navItem.settings.unpublished')}
|
{$settings.isPublished ? $t('course.navItem.settings.published') : $t('course.navItem.settings.unpublished')}
|
||||||
</Label>
|
</Label>
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
</AttentionHighlight>
|
||||||
|
|
||||||
{#if showLockedContentNotice}
|
{#if showLockedContentNotice}
|
||||||
<Alert.Root variant={isLiveClassCourse ? 'information' : 'warning'}>
|
<Alert.Root variant={isLiveClassCourse ? 'information' : 'warning'}>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { replaceState } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
id: string;
|
||||||
|
duration?: number;
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { id, duration = 3, children }: Props = $props();
|
||||||
|
|
||||||
|
let containerRef = $state<HTMLDivElement | null>(null);
|
||||||
|
let pulsing = $state(false);
|
||||||
|
let handledFor = $state<string | null>(null);
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function getActiveHighlightId(url: URL): string | null {
|
||||||
|
const query = url.searchParams.get('highlight');
|
||||||
|
if (query) return query;
|
||||||
|
|
||||||
|
const hash = url.hash.replace('#', '').trim();
|
||||||
|
return hash || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const activeHighlightId = getActiveHighlightId(page.url);
|
||||||
|
|
||||||
|
if (activeHighlightId !== id) {
|
||||||
|
handledFor = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (handledFor === activeHighlightId) return;
|
||||||
|
|
||||||
|
handledFor = activeHighlightId;
|
||||||
|
pulsing = true;
|
||||||
|
|
||||||
|
containerRef?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
|
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
pulsing = false;
|
||||||
|
timeoutId = null;
|
||||||
|
|
||||||
|
const url = new URL(page.url);
|
||||||
|
const highlightActive = url.searchParams.get('highlight') === id;
|
||||||
|
const hashActive = url.hash.replace('#', '').trim() === id;
|
||||||
|
|
||||||
|
if (highlightActive) url.searchParams.delete('highlight');
|
||||||
|
if (hashActive) url.hash = '';
|
||||||
|
|
||||||
|
if (highlightActive || hashActive) {
|
||||||
|
replaceState(resolve(`${url.pathname}${url.search}`, {}), page.state);
|
||||||
|
}
|
||||||
|
}, duration * 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div bind:this={containerRef} class={`rounded-md p-2 ${pulsing ? 'ui:border-primary animate-pulse border' : ''}`}>
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export { default as ActivityCard } from './analytics/activity-card.svelte';
|
export { default as ActivityCard } from './analytics/activity-card.svelte';
|
||||||
export { default as AppHeader } from './navigation/app-header.svelte';
|
export { default as AppHeader } from './navigation/app-header.svelte';
|
||||||
|
export { default as AttentionHighlight } from './attention-highlight.svelte';
|
||||||
export { default as AuthUI } from './auth-ui.svelte';
|
export { default as AuthUI } from './auth-ui.svelte';
|
||||||
export { default as Backdrop } from './backdrop.svelte';
|
export { default as Backdrop } from './backdrop.svelte';
|
||||||
export { default as CloseButton } from './close-btn.svelte';
|
export { default as CloseButton } from './close-btn.svelte';
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { ROUTE_PATHS, type ROUTE_NAME, type RouteValues, type SectionsFor } from './routes';
|
||||||
|
|
||||||
|
export function goAndHighlight<R extends ROUTE_NAME>(
|
||||||
|
routeName: R,
|
||||||
|
sectionName: SectionsFor<R>,
|
||||||
|
routeValues: RouteValues<R>
|
||||||
|
) {
|
||||||
|
let path: string = ROUTE_PATHS[routeName];
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(routeValues)) {
|
||||||
|
if (value == null) continue;
|
||||||
|
const token = `[${key}]`;
|
||||||
|
if (path.includes(token)) {
|
||||||
|
path = path.replace(token, encodeURIComponent(value));
|
||||||
|
} else {
|
||||||
|
query.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query.set('highlight', sectionName);
|
||||||
|
goto(resolve(`${path}?${query.toString()}`, {}));
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export enum ROUTE_NAME {
|
||||||
|
COURSE = 'COURSE',
|
||||||
|
COURSE_SETTINGS = 'COURSE_SETTINGS',
|
||||||
|
COURSE_CERTIFICATE = 'COURSE_CERTIFICATE',
|
||||||
|
COURSE_AI_TUTOR = 'COURSE_AI_TUTOR'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ROUTE_PATHS = {
|
||||||
|
[ROUTE_NAME.COURSE]: '/courses/[id]',
|
||||||
|
[ROUTE_NAME.COURSE_SETTINGS]: '/courses/[id]/settings',
|
||||||
|
[ROUTE_NAME.COURSE_CERTIFICATE]: '/courses/[id]/certificates',
|
||||||
|
[ROUTE_NAME.COURSE_AI_TUTOR]: '/courses/[id]/ai-tutor'
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const ROUTE_SECTIONS = {
|
||||||
|
[ROUTE_NAME.COURSE_SETTINGS]: {
|
||||||
|
PUBLISH: 'publish'
|
||||||
|
},
|
||||||
|
|
||||||
|
[ROUTE_NAME.COURSE_CERTIFICATE]: {
|
||||||
|
THRESHOLD: 'cert-threshold',
|
||||||
|
CERT_DEADLINE: 'cert-deadline',
|
||||||
|
CERT_EMAIL_MESSAGE: 'cert-email-message'
|
||||||
|
},
|
||||||
|
|
||||||
|
[ROUTE_NAME.COURSE_AI_TUTOR]: {
|
||||||
|
INHERITANCE: 'inheritance',
|
||||||
|
TUTOR_SETTINGS: 'tutor-settings'
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type SectionsFor<R extends ROUTE_NAME> = R extends keyof typeof ROUTE_SECTIONS
|
||||||
|
? (typeof ROUTE_SECTIONS)[R][keyof (typeof ROUTE_SECTIONS)[R]]
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export type ExtractParams<T extends string> = T extends `${string}[${infer Param}]${infer Rest}`
|
||||||
|
? Param | ExtractParams<Rest>
|
||||||
|
: never;
|
||||||
|
|
||||||
|
export type RouteValues<R extends ROUTE_NAME> = Record<ExtractParams<(typeof ROUTE_PATHS)[R]>, string> &
|
||||||
|
Record<string, string | undefined>;
|
||||||
@@ -2028,6 +2028,8 @@
|
|||||||
"rules_heading": "Rules",
|
"rules_heading": "Rules",
|
||||||
"deadline_label": "Certification deadline",
|
"deadline_label": "Certification deadline",
|
||||||
"deadline_helper": "Students must meet the completion percentage before this datetime (local time). Leave empty for no deadline.",
|
"deadline_helper": "Students must meet the completion percentage before this datetime (local time). Leave empty for no deadline.",
|
||||||
|
"deadline_required": "Certificate deadline is required when certificates are downloadable.",
|
||||||
|
"deadline_required_enroll": "Compliance courses require a certificate deadline before learners can be enrolled.",
|
||||||
"threshold_label": "Minimum completion (%)",
|
"threshold_label": "Minimum completion (%)",
|
||||||
"threshold_helper": "Combined lesson and exercise progress required to earn the certificate (0–100). Default is 100%.",
|
"threshold_helper": "Combined lesson and exercise progress required to earn the certificate (0–100). Default is 100%.",
|
||||||
"final_exercise_label": "Final exercise (optional)",
|
"final_exercise_label": "Final exercise (optional)",
|
||||||
@@ -3649,6 +3651,9 @@
|
|||||||
"determines": "This determines if your course displays on your landing page",
|
"determines": "This determines if your course displays on your landing page",
|
||||||
"published": "Published",
|
"published": "Published",
|
||||||
"unpublished": "Unpublished",
|
"unpublished": "Unpublished",
|
||||||
|
"certificate_deadline_required": "Set Certificate Deadline First",
|
||||||
|
"certificate_deadline_required_description": "Compliance courses require a certificate deadline before learners can be enrolled. Please set a deadline in the certificate settings before publishing.",
|
||||||
|
"go_to_certificate_settings": "Go to Certificate Settings",
|
||||||
"locked_content": {
|
"locked_content": {
|
||||||
"title": "{count} course item(s) are locked",
|
"title": "{count} course item(s) are locked",
|
||||||
"description": "Enrolled students won't be able to take locked lessons or exercises until you unlock them.",
|
"description": "Enrolled students won't be able to take locked lessons or exercises until you unlock them.",
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
import { profile } from '$lib/utils/store/user';
|
import { profile } from '$lib/utils/store/user';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
|
import { setupProgressApi } from '$features/setup/api/setup-progress.svelte';
|
||||||
|
import { ROUTE_NAME, ROUTE_SECTIONS } from '$lib/routing/routes';
|
||||||
|
import { goAndHighlight } from '$lib/routing/go-and-highlight';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -96,7 +98,9 @@
|
|||||||
const courses = setupProgressApi.progress.courses || [];
|
const courses = setupProgressApi.progress.courses || [];
|
||||||
const courseId = courses[0]?.id;
|
const courseId = courses[0]?.id;
|
||||||
if (courseId) {
|
if (courseId) {
|
||||||
goto(`/courses/${courseId}/settings`);
|
goAndHighlight(ROUTE_NAME.COURSE_SETTINGS, ROUTE_SECTIONS[ROUTE_NAME.COURSE_SETTINGS].PUBLISH, {
|
||||||
|
id: courseId
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
snackbar.info('setup.info_course');
|
snackbar.info('setup.info_course');
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
let enrollmentSucceeded = $state(false);
|
let enrollmentSucceeded = $state(false);
|
||||||
let verificationEmailSent = $state(false);
|
let verificationEmailSent = $state(false);
|
||||||
let enrolledPendingVerification = $state(false);
|
let enrolledPendingVerification = $state(false);
|
||||||
|
let enrollmentError = $state<string | null>(null);
|
||||||
|
|
||||||
const session = authClient.useSession();
|
const session = authClient.useSession();
|
||||||
const sessionReady = $derived(!$session.isPending && !$session.isRefetching);
|
const sessionReady = $derived(!$session.isPending && !$session.isRefetching);
|
||||||
@@ -110,6 +111,7 @@
|
|||||||
|
|
||||||
enrollmentInFlight = true;
|
enrollmentInFlight = true;
|
||||||
loading = true;
|
loading = true;
|
||||||
|
enrollmentError = null;
|
||||||
|
|
||||||
let navigatingAway = false;
|
let navigatingAway = false;
|
||||||
|
|
||||||
@@ -118,7 +120,7 @@
|
|||||||
const result = await courseApi.enroll(data.course.id, body);
|
const result = await courseApi.enroll(data.course.id, body);
|
||||||
|
|
||||||
if (!result?.data) {
|
if (!result?.data) {
|
||||||
snackbar.error('snackbar.invite.failed_join');
|
enrollmentError = t.get('snackbar.invite.failed_join');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,6 +149,10 @@
|
|||||||
|
|
||||||
navigatingAway = true;
|
navigatingAway = true;
|
||||||
await goto(resolve(getStudentCourseContinuePath(data.course.id), {}));
|
await goto(resolve(getStudentCourseContinuePath(data.course.id), {}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[enroll] unexpected error during enrollment:', error);
|
||||||
|
enrollmentError = t.get('snackbar.invite.failed_join');
|
||||||
|
snackbar.error('snackbar.invite.failed_join');
|
||||||
} finally {
|
} finally {
|
||||||
enrollmentInFlight = false;
|
enrollmentInFlight = false;
|
||||||
if (!navigatingAway) {
|
if (!navigatingAway) {
|
||||||
@@ -186,7 +192,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!sessionReady || !isLoggedIn || !canJoinCourse || enrollmentInFlight || loading || enrollmentSucceeded) {
|
if (
|
||||||
|
!sessionReady ||
|
||||||
|
!isLoggedIn ||
|
||||||
|
!canJoinCourse ||
|
||||||
|
enrollmentInFlight ||
|
||||||
|
loading ||
|
||||||
|
enrollmentSucceeded ||
|
||||||
|
enrollmentError
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +259,8 @@
|
|||||||
>
|
>
|
||||||
{$t('course.navItem.landing_page.enroll_page.back_to_course')}
|
{$t('course.navItem.landing_page.enroll_page.back_to_course')}
|
||||||
</a>
|
</a>
|
||||||
|
{:else if enrollmentError}
|
||||||
|
<p class="mt-3 text-center text-sm text-red-500">{enrollmentError}</p>
|
||||||
{:else if !canJoinCourse}
|
{:else if !canJoinCourse}
|
||||||
<p class="mt-3 text-center text-sm text-red-500">{getBlockedMessage()}</p>
|
<p class="mt-3 text-center text-sm text-red-500">{getBlockedMessage()}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user