fix(dashboard): save validation feedback, public site light mode, global verify modal (#716)

* fix(dashboard): surface save errors, force public site light mode, global verify modal

- Show snackbar with first validation error when course update fails client-side validation
- Force light mode on public org-site pages; lesson player and dashboard keep theme selector
- Mount VerifyEmailModal in root layout for all authenticated routes; keep trigger=app email guard

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

* fix: instructor editor sync, auto-enroll after auth, email verification API

- Initialize instructor form from org fallbacks and batch metadata updates
- Auto-complete course enrollment after login/signup return from enroll page
- Block new org auto-enroll and course enroll until email is verified

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

* fix: address PR review on instructor setter, color scheme, auto-enroll

- Use existing setter with batched metadata.instructor updates in InstructorForm
- Allowlist public org-site paths so /lms and /courses/:id keep theme selector
- Auto-enroll logged-in users via Better Auth session; remove sessionStorage flow
- Retry enrollment after email verification without duplicate Join Course clicks

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

* fix: allow enrollment without verification; gate dashboard only

- Remove API email verification checks from enroll and auto-enroll flows
- Move VerifyEmailModal to (app) layout so public org-site pages stay usable
- Unverified users can enroll but hit the verification modal in LMS/dashboard

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

* fix: address PR feedback on verify modal, routes, instructor form

- Restore currentOrg.id guard on VerifyEmailModal open condition
- InstructorForm imports currentOrg directly instead of prop drilling
- Move public org-site light-mode route patterns to routes constants
- Reset enrollment attempt key on enroll failure to allow retry

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

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Rotimi Best
2026-07-10 01:55:13 +01:00
committed by GitHub
co-authored by Rotimi Best Cursor Agent
parent 35f5216839
commit be0085f090
10 changed files with 196 additions and 68 deletions
@@ -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;
}
@@ -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(() => {
@@ -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,
@@ -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);
});
});
@@ -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));
}