diff --git a/apps/dashboard/src/lib/features/course/api/course.svelte.ts b/apps/dashboard/src/lib/features/course/api/course.svelte.ts index 66cca766..288dc61d 100644 --- a/apps/dashboard/src/lib/features/course/api/course.svelte.ts +++ b/apps/dashboard/src/lib/features/course/api/course.svelte.ts @@ -440,6 +440,10 @@ export class CourseApi extends BaseApiWithErrors { const result = ZCourseUpdate.safeParse(fields); if (!result.success) { this.errors = mapZodErrorsToTranslations(result.error, 'course'); + const firstError = this.errors.general ?? Object.values(this.errors).find(Boolean); + if (firstError) { + snackbar.error(firstError); + } return null; } diff --git a/apps/dashboard/src/lib/features/ui/course-landing-page/components/editor/instructor-form.svelte b/apps/dashboard/src/lib/features/ui/course-landing-page/components/editor/instructor-form.svelte index 55398326..4c58de29 100644 --- a/apps/dashboard/src/lib/features/ui/course-landing-page/components/editor/instructor-form.svelte +++ b/apps/dashboard/src/lib/features/ui/course-landing-page/components/editor/instructor-form.svelte @@ -7,19 +7,31 @@ import { uploadImage } from '$lib/utils/services/upload'; import type { Course } from '$features/course/utils/types'; import { t } from '$lib/utils/functions/translations'; + import { currentOrg } from '$lib/utils/store/org'; interface Props { course: Course; - setter: (value: any, key: string) => void; + setter: (value: unknown, key: string) => void; } let { course = $bindable(), setter }: Props = $props(); - let name = $state(get(course, 'metadata.instructor.name')); - let role = $state(get(course, 'metadata.instructor.role')); - let imgUrl = $state(get(course, 'metadata.instructor.imgUrl')); - let description = $state(get(course, 'metadata.instructor.description')); - let courseNo = $state(get(course, 'metadata.instructor.coursesNo') ?? get(course, 'metadata.instructor.courseNo')); + function readInstructorField(path: string, fallback = '') { + const value = get(course, path); + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + + return fallback; + } + + let name = $state(readInstructorField('metadata.instructor.name', $currentOrg.name ?? '')); + let role = $state(readInstructorField('metadata.instructor.role')); + let imgUrl = $state(readInstructorField('metadata.instructor.imgUrl', $currentOrg.avatarUrl ?? '')); + let description = $state(readInstructorField('metadata.instructor.description')); + let courseNo = $state( + get(course, 'metadata.instructor.coursesNo') ?? get(course, 'metadata.instructor.courseNo') ?? '' + ); let avatar: File | undefined = $state(); let isUploading = $state(false); @@ -29,26 +41,26 @@ isUploading = true; const logo = await uploadImage(_avatar); - if (!logo) return; + if (!logo) { + isUploading = false; + return; + } imgUrl = logo; isUploading = false; } $effect(() => { - setter(name, 'metadata.instructor.name'); - }); - $effect(() => { - setter(role, 'metadata.instructor.role'); - }); - $effect(() => { - setter(imgUrl, 'metadata.instructor.imgUrl'); - }); - $effect(() => { - setter(description, 'metadata.instructor.description'); - }); - $effect(() => { - setter(courseNo, 'metadata.instructor.coursesNo'); + setter( + { + name, + role, + imgUrl, + description, + coursesNo: courseNo + }, + 'metadata.instructor' + ); }); $effect(() => { diff --git a/apps/dashboard/src/lib/utils/constants/routes.ts b/apps/dashboard/src/lib/utils/constants/routes.ts index 43e37660..da36a1fb 100644 --- a/apps/dashboard/src/lib/utils/constants/routes.ts +++ b/apps/dashboard/src/lib/utils/constants/routes.ts @@ -20,6 +20,13 @@ export const ROUTE = { VERIFY_EMAIL_ERROR: '/verify-email-error' }; +export const PUBLIC_ORG_SITE_LIGHT_MODE_ROUTES = [ + `^${ROUTE.HOME}$`, + `^${ROUTE.COURSES}/?$`, + `^${ROUTE.COURSE}/[^/]+/?$`, + `^${ROUTE.COURSE}/[^/]+/enroll/?$` +] as const; + export const PUBLIC_ROUTES = [ `^${ROUTE.HOME}$`, ROUTE.LOGIN, diff --git a/apps/dashboard/src/lib/utils/functions/color-scheme.test.ts b/apps/dashboard/src/lib/utils/functions/color-scheme.test.ts new file mode 100644 index 00000000..dee39e4b --- /dev/null +++ b/apps/dashboard/src/lib/utils/functions/color-scheme.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { isPublicOrgSitePage } from './color-scheme'; + +describe('isPublicOrgSitePage', () => { + it('returns false for non-org-site routes', () => { + expect(isPublicOrgSitePage(false, '/lms')).toBe(false); + expect(isPublicOrgSitePage(false, '/courses/abc')).toBe(false); + }); + + it('returns true for public org-site pages', () => { + expect(isPublicOrgSitePage(true, '/')).toBe(true); + expect(isPublicOrgSitePage(true, '/courses')).toBe(true); + expect(isPublicOrgSitePage(true, '/courses/')).toBe(true); + expect(isPublicOrgSitePage(true, '/course/my-course')).toBe(true); + expect(isPublicOrgSitePage(true, '/course/my-course/enroll')).toBe(true); + }); + + it('returns false for authenticated org-site app routes', () => { + expect(isPublicOrgSitePage(true, '/lms')).toBe(false); + expect(isPublicOrgSitePage(true, '/lms/settings')).toBe(false); + expect(isPublicOrgSitePage(true, '/courses/abc')).toBe(false); + expect(isPublicOrgSitePage(true, '/courses/abc/newsfeed')).toBe(false); + expect(isPublicOrgSitePage(true, '/course/my-course/lesson/intro')).toBe(false); + }); +}); diff --git a/apps/dashboard/src/lib/utils/functions/color-scheme.ts b/apps/dashboard/src/lib/utils/functions/color-scheme.ts new file mode 100644 index 00000000..ebf2de5c --- /dev/null +++ b/apps/dashboard/src/lib/utils/functions/color-scheme.ts @@ -0,0 +1,23 @@ +import { PUBLIC_ORG_SITE_LIGHT_MODE_ROUTES } from '$lib/utils/constants/routes'; + +function normalizePathname(pathname: string): string { + const pathOnly = pathname.split('?')[0] ?? '/'; + if (pathOnly === '/') { + return '/'; + } + + return pathOnly.replace(/\/+$/, '') || '/'; +} + +/** + * Public org-site pages (landing, catalog, course landing, enroll) always use light mode. + * Authenticated areas on the same tenant host (e.g. /lms, /courses/:id) keep the theme selector. + */ +export function isPublicOrgSitePage(isOrgSite: boolean, pathname: string): boolean { + if (!isOrgSite) { + return false; + } + + const normalizedPath = normalizePathname(pathname); + return PUBLIC_ORG_SITE_LIGHT_MODE_ROUTES.some((pattern) => new RegExp(pattern).test(normalizedPath)); +} diff --git a/apps/dashboard/src/routes/(app)/+layout.svelte b/apps/dashboard/src/routes/(app)/+layout.svelte index eb36241e..dd0a1690 100644 --- a/apps/dashboard/src/routes/(app)/+layout.svelte +++ b/apps/dashboard/src/routes/(app)/+layout.svelte @@ -2,6 +2,7 @@ import { page } from '$app/state'; import { UpgradeModal, PageLoadProgress, PageRestricted } from '$features/ui'; + import { VerifyEmailModal } from '$features/onboarding/components'; import { CommandPalette, KeyboardShortcutListener } from '$features/search'; import { isPublicRoute } from '$lib/utils/functions/routes/isPublicRoute'; import { currentOrg } from '$lib/utils/store/org'; @@ -42,6 +43,7 @@ + diff --git a/apps/dashboard/src/routes/(app)/org/[slug]/+layout.svelte b/apps/dashboard/src/routes/(app)/org/[slug]/+layout.svelte index 531f9c6d..e657d934 100644 --- a/apps/dashboard/src/routes/(app)/org/[slug]/+layout.svelte +++ b/apps/dashboard/src/routes/(app)/org/[slug]/+layout.svelte @@ -10,8 +10,6 @@ import { AppHeader } from '$features/ui'; import { PUBLIC_IS_SELFHOSTED } from '$env/static/public'; - import { VerifyEmailModal } from '$features/onboarding/components'; - import { OrgSidebar } from '$features/ui/sidebar/org-sidebar'; import { AddOrgModal } from '$features/org'; @@ -41,8 +39,6 @@ {/if} - - diff --git a/apps/dashboard/src/routes/(org-site)/course/[slug]/enroll/+page.svelte b/apps/dashboard/src/routes/(org-site)/course/[slug]/enroll/+page.svelte index 20106e3a..478f6912 100644 --- a/apps/dashboard/src/routes/(org-site)/course/[slug]/enroll/+page.svelte +++ b/apps/dashboard/src/routes/(org-site)/course/[slug]/enroll/+page.svelte @@ -11,10 +11,18 @@ import { snackbar } from '$features/ui/snackbar/store'; import { capturePosthogEvent } from '$lib/utils/services/posthog'; import { resolve } from '$app/paths'; + import { authClient } from '$lib/utils/services/auth/client'; let { data } = $props(); let loading = $state(false); + let enrollmentInFlight = $state(false); + let enrollmentAttemptKey = $state(''); + + const session = authClient.useSession(); + const sessionReady = $derived(!$session.isPending && !$session.isRefetching); + const sessionUser = $derived($session.data?.user ?? null); + const isLoggedIn = $derived(sessionReady && Boolean(sessionUser)); const inviteStatus = $derived(data.invite?.status ?? 'INVALID'); const hasActiveInvite = $derived(Boolean(data.invite) && inviteStatus === 'ACTIVE'); @@ -51,12 +59,59 @@ return ''; } + async function completeEnrollment() { + if (!data.course?.id || enrollmentInFlight) { + return; + } + + enrollmentInFlight = true; + loading = true; + + let navigatingAway = false; + + try { + const body = data.token ? { inviteToken: data.token } : {}; + const result = await courseApi.enroll(data.course.id, body); + + if (!result?.data) { + return; + } + + const studentId = $profile.id || sessionUser?.id || ''; + const studentEmail = $profile.email || sessionUser?.email || ''; + + capturePosthogEvent('student_joined_course', { + course_name: data.course?.title, + student_id: studentId, + student_email: studentEmail, + already_joined: result.data.alreadyJoined + }); + + navigatingAway = true; + window.location.href = result.data.redirectTo || '/lms'; + } finally { + enrollmentInFlight = false; + if (!navigatingAway) { + loading = false; + enrollmentAttemptKey = ''; + } + } + } + async function handleSubmit() { if (!canJoinCourse) { snackbar.error(getBlockedMessage()); return; } - console.log('profile', $profile); + + if (!sessionReady) { + return; + } + + if (isLoggedIn) { + await completeEnrollment(); + return; + } loading = true; @@ -64,41 +119,28 @@ const redirectSearch = data.token ? `?invite_token=${encodeURIComponent(data.token)}` : ''; const redirectUrl = `${redirectPath}${redirectSearch}`; - if (!$profile.id || !$profile.email) { - const inviteEmail = data.inviteEmail ?? ''; - const target = data.inviteEmailExists ? '/login' : '/signup'; - const params = new URLSearchParams({ redirect: redirectUrl }); - if (inviteEmail) params.set('email', inviteEmail); + const inviteEmail = data.inviteEmail ?? ''; + const target = data.inviteEmailExists ? '/login' : '/signup'; + const params = new URLSearchParams({ redirect: redirectUrl }); + if (inviteEmail) params.set('email', inviteEmail); - goto(resolve(`${target}?${params.toString()}`, {})); - loading = false; + goto(resolve(`${target}?${params.toString()}`, {})); + loading = false; + } + + $effect(() => { + if (!sessionReady || !isLoggedIn || !canJoinCourse || enrollmentInFlight || loading) { return; } - let navigatingAway = false; - - try { - const body = data.token ? { inviteToken: data.token } : {}; - const result = await courseApi.enroll(data.course!.id, body); - - if (!result?.data) { - return; - } - - capturePosthogEvent('student_joined_course', { - course_name: data.course?.title, - student_id: $profile.id, - student_email: $profile.email, - already_joined: result.data.alreadyJoined - }); - - navigatingAway = true; - // need to force page reload to avoid cache issues - window.location.href = result.data.redirectTo || '/lms'; - } finally { - if (!navigatingAway) loading = false; + const attemptKey = `${sessionUser?.id ?? ''}:${data.course?.id ?? ''}`; + if (enrollmentAttemptKey === attemptKey) { + return; } - } + + enrollmentAttemptKey = attemptKey; + void completeEnrollment(); + }); $effect(() => { if (!data.currentOrg) return; @@ -107,12 +149,12 @@ const themeString = typeof rawTheme === 'string' ? rawTheme : ''; setTheme(themeString); - currentOrg.update((o) => ({ - ...o, - id: data.currentOrg?.id ?? o.id, - name: data.currentOrg?.name ?? o.name, - siteName: data.currentOrg?.siteName ?? o.siteName, - theme: themeString || o.theme + currentOrg.update((organization) => ({ + ...organization, + id: data.currentOrg?.id ?? organization.id, + name: data.currentOrg?.name ?? organization.name, + siteName: data.currentOrg?.siteName ?? organization.siteName, + theme: themeString || organization.theme })); }); @@ -122,7 +164,13 @@ - +

{data.course?.title}

{data.course?.description}

@@ -141,9 +189,11 @@ {/if}
-
- -
+ {#if !isLoggedIn} +
+ +
+ {/if}
diff --git a/apps/dashboard/src/routes/+layout.svelte b/apps/dashboard/src/routes/+layout.svelte index 50fadd06..0b971523 100644 --- a/apps/dashboard/src/routes/+layout.svelte +++ b/apps/dashboard/src/routes/+layout.svelte @@ -14,6 +14,7 @@ import { MetaTags } from 'svelte-meta-tags'; import { ModeWatcher } from '@cio/ui/base/dark-mode'; import OrgSiteFavicon from '$features/app/org-site-favicon.svelte'; + import { isPublicOrgSitePage } from '$lib/utils/functions/color-scheme'; import '../app.css'; @@ -24,6 +25,7 @@ setUploadLimitsContext(data.uploadLimits); const metaTags = $derived(merge(data.baseMetaTags, page.data.pageMetaTags)); + const forceLightMode = $derived(isPublicOrgSitePage(data.isOrgSite, page.url.pathname)); onMount(() => { console.log('Layout', data); @@ -75,7 +77,13 @@
- + {#key forceLightMode} + {#if forceLightMode} + + {:else} + + {/if} + {/key} diff --git a/packages/utils/src/constants/error-codes.ts b/packages/utils/src/constants/error-codes.ts index d616b9f9..7a75e770 100644 --- a/packages/utils/src/constants/error-codes.ts +++ b/packages/utils/src/constants/error-codes.ts @@ -95,6 +95,7 @@ export const ErrorCodes = { // Auth errors FORBIDDEN: 'FORBIDDEN', + EMAIL_NOT_VERIFIED: 'EMAIL_NOT_VERIFIED', // Course errors COURSE_NOT_FOUND: 'COURSE_NOT_FOUND',