fix(dashboard): show course cards in landing page settings preview (#697)

* fix(dashboard): show course cards in landing page settings preview

Fetch public courses for the org and pass them to the theme preview
on the landing page settings screen, matching the editor preview.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* refactor(dashboard): skip redundant public course fetches

Add loadPublicCoursesIfNeeded so settings, editor, and live landing
page reuse cached courses for the same org site instead of clearing
and refetching on every mount.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(dashboard): always refetch courses on live org landing page

Keep loadPublicCoursesIfNeeded for settings preview and editor only.
The public-facing landing page should fetch fresh on each visit.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* feat(ui): add shared empty state for org landing page catalogs

Introduce OrgLandingPageCoursesEmpty using the dashboard Empty pattern
with landing-theme colors. Show it across all ten org landing templates
when no courses are published, and wire translated labels through
buildOrgLandingPageProps.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(dashboard): address Greptile review on public courses cache

- Add coursesLoaded prop so empty catalog state waits for fetch
- Deduplicate concurrent public course requests via in-flight promise
- Refresh courses on settings mount; reuse cache in editor only
- Expose invalidatePublicCourses for explicit cache clearing

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* fix(dashboard): load landing preview courses when org store hydrates

Replace onMount course fetch with a reactive effect keyed on the route
slug and currentOrg.siteName so public courses load after app init.
Mark the fetch as complete in finally so failed requests show the empty
state instead of a permanently blank catalog section.

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

---------
This commit is contained in:
Rotimi Best
2026-07-06 20:51:17 +01:00
committed by GitHub
co-authored by Rotimi Best
parent 8a22ee60bc
commit bce38aa530
28 changed files with 571 additions and 359 deletions
@@ -70,8 +70,11 @@ class OrgApi extends BaseApiWithErrors {
audiencePagination = $state<OrganizationAudiencePagination | null>(null);
publicCourses: OrgPublicCourses = $state([]);
hasMorePublicCourses = $state(false);
publicCoursesLoadedSiteName: string | null = $state(null);
isFetchingOrgPublicCourses = $state(false);
private activePublicCoursesFetch: Promise<void> | null = null;
private activePublicCoursesFetchSiteName: string | null = null;
private activeAudienceRequestController: AbortController | null = null;
cancelAudienceRequest() {
@@ -155,15 +158,52 @@ class OrgApi extends BaseApiWithErrors {
return response;
}
invalidatePublicCourses() {
this.publicCoursesLoadedSiteName = null;
}
/**
* Refetches public courses for a site, clearing any cached settings preview data.
*/
async refreshPublicCourses(siteName: string) {
if (!siteName) {
return;
}
this.invalidatePublicCourses();
await this.fetchPublicCoursesBySiteName(siteName);
}
/**
* Loads public courses for a site when they have not been fetched yet.
* Skips the request when courses are already in memory for the same site.
*/
async loadPublicCoursesIfNeeded(siteName: string) {
if (!siteName || this.publicCoursesLoadedSiteName === siteName) {
return;
}
await this.fetchPublicCoursesBySiteName(siteName);
}
/**
* Gets public courses by organization siteName (for landing pages)
* @param siteName Organization site name
* @returns Published courses array
*/
async getPublicCoursesBySiteName(siteName: string) {
this.invalidatePublicCourses();
await this.fetchPublicCoursesBySiteName(siteName);
}
private fetchPublicCoursesBySiteName(siteName: string): Promise<void> {
if (this.activePublicCoursesFetch && this.activePublicCoursesFetchSiteName === siteName) {
return this.activePublicCoursesFetch;
}
this.isFetchingOrgPublicCourses = true;
await this.execute<GetOrgPublicCoursesRequest>({
const fetchPromise = this.execute<GetOrgPublicCoursesRequest>({
requestFn: () =>
classroomio.organization.courses.public.$get({
query: { siteName }
@@ -172,10 +212,29 @@ class OrgApi extends BaseApiWithErrors {
onSuccess: (response) => {
this.publicCourses = response.data.courses;
this.hasMorePublicCourses = response.data.hasMoreCourses;
this.publicCoursesLoadedSiteName = siteName;
}
});
})
.then(() => undefined)
.finally(() => {
this.isFetchingOrgPublicCourses = false;
this.isFetchingOrgPublicCourses = false;
if (this.publicCoursesLoadedSiteName !== siteName) {
this.publicCourses = [];
this.hasMorePublicCourses = false;
this.publicCoursesLoadedSiteName = siteName;
}
if (this.activePublicCoursesFetchSiteName === siteName) {
this.activePublicCoursesFetch = null;
this.activePublicCoursesFetchSiteName = null;
}
});
this.activePublicCoursesFetch = fetchPromise;
this.activePublicCoursesFetchSiteName = siteName;
return fetchPromise;
}
/**
@@ -49,7 +49,8 @@
normalizeLandingPageSettings(org.landingpage),
orgApi.publicCourses,
orgApi.hasMorePublicCourses,
authAction
authAction,
{ coursesLoaded: hasLoadedCourses }
)
);
@@ -18,6 +18,7 @@ import {
labelMatchesSocialPlatform,
resolveFooterSocialPlatform
} from '@cio/ui/custom/org-landing-page/footer-social-platform';
import { t } from '$lib/utils/functions/translations';
export const landingPageThemes = [
'minimal',
@@ -689,12 +690,20 @@ export function mapPublicCoursesToLandingPageCourses(courses: OrgPublicCourses):
});
}
export function buildOrgLandingPageLabels(): OrgLandingPageProps['labels'] {
return {
catalogEmptyTitle: t.get('public_courses.catalog_empty.title'),
catalogEmptyDescription: t.get('public_courses.catalog_empty.description')
};
}
export function buildOrgLandingPageProps(
org: AccountOrg,
landingpage: unknown,
courses: OrgPublicCourses,
hasMoreCourses = false,
authAction?: OrgLandingPageProps['authAction']
authAction?: OrgLandingPageProps['authAction'],
options?: { coursesLoaded?: boolean }
): OrgLandingPageProps {
const normalizedLandingPage = normalizeLandingPageSettings(landingpage);
@@ -704,7 +713,9 @@ export function buildOrgLandingPageProps(
authAction,
...normalizedLandingPage,
courses: mapPublicCoursesToLandingPageCourses(courses),
hasMoreCourses
hasMoreCourses,
coursesLoaded: options?.coursesLoaded ?? true,
labels: buildOrgLandingPageLabels()
};
}
@@ -1,5 +1,6 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
@@ -134,7 +135,21 @@
: { label: t.get('navigation.login'), href: '/login' }
);
const previewProps = $derived(buildOrgLandingPageProps($currentOrg, normalized, [], false, authAction));
const previewSiteName = $derived($currentOrg.siteName || page.params.slug || '');
const coursesLoaded = $derived(!previewSiteName || orgApi.publicCoursesLoadedSiteName === previewSiteName);
const previewProps = $derived.by(() =>
buildOrgLandingPageProps($currentOrg, normalized, orgApi.publicCourses, orgApi.hasMorePublicCourses, authAction, {
coursesLoaded
})
);
$effect(() => {
if (!previewSiteName) return;
void orgApi.refreshPublicCourses(previewSiteName);
});
const ThemeComponent = $derived(landingPageThemeComponents[currentTheme] ?? landingPageThemeComponents.minimal);