diff --git a/apps/dashboard/src/lib/features/org/api/org.svelte.ts b/apps/dashboard/src/lib/features/org/api/org.svelte.ts index 2a58287c..be9ebe2d 100644 --- a/apps/dashboard/src/lib/features/org/api/org.svelte.ts +++ b/apps/dashboard/src/lib/features/org/api/org.svelte.ts @@ -70,8 +70,11 @@ class OrgApi extends BaseApiWithErrors { audiencePagination = $state(null); publicCourses: OrgPublicCourses = $state([]); hasMorePublicCourses = $state(false); + publicCoursesLoadedSiteName: string | null = $state(null); isFetchingOrgPublicCourses = $state(false); + private activePublicCoursesFetch: Promise | 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 { + if (this.activePublicCoursesFetch && this.activePublicCoursesFetchSiteName === siteName) { + return this.activePublicCoursesFetch; + } + this.isFetchingOrgPublicCourses = true; - await this.execute({ + const fetchPromise = this.execute({ 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; } /** diff --git a/apps/dashboard/src/lib/features/org/components/landing-page/landing-page.svelte b/apps/dashboard/src/lib/features/org/components/landing-page/landing-page.svelte index 5b0ade71..bca0b339 100644 --- a/apps/dashboard/src/lib/features/org/components/landing-page/landing-page.svelte +++ b/apps/dashboard/src/lib/features/org/components/landing-page/landing-page.svelte @@ -49,7 +49,8 @@ normalizeLandingPageSettings(org.landingpage), orgApi.publicCourses, orgApi.hasMorePublicCourses, - authAction + authAction, + { coursesLoaded: hasLoadedCourses } ) ); diff --git a/apps/dashboard/src/lib/features/org/utils/landing-page.ts b/apps/dashboard/src/lib/features/org/utils/landing-page.ts index 7576f1fb..06866f30 100644 --- a/apps/dashboard/src/lib/features/org/utils/landing-page.ts +++ b/apps/dashboard/src/lib/features/org/utils/landing-page.ts @@ -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() }; } diff --git a/apps/dashboard/src/lib/features/settings/pages/landingpage.svelte b/apps/dashboard/src/lib/features/settings/pages/landingpage.svelte index 4b844783..3cc4c4c7 100644 --- a/apps/dashboard/src/lib/features/settings/pages/landingpage.svelte +++ b/apps/dashboard/src/lib/features/settings/pages/landingpage.svelte @@ -1,5 +1,6 @@ + +
+ +
diff --git a/packages/ui/src/custom/org-landing-page/editorial/org.svelte b/packages/ui/src/custom/org-landing-page/editorial/org.svelte index efe69f77..96dd8ccd 100644 --- a/packages/ui/src/custom/org-landing-page/editorial/org.svelte +++ b/packages/ui/src/custom/org-landing-page/editorial/org.svelte @@ -7,6 +7,7 @@ import EditorialNav from './nav.svelte'; import EditorialHero from './hero.svelte'; import EditorialCourseCard from './course-card.svelte'; + import OrgLandingPageCoursesEmpty from '../courses-empty.svelte'; import { themeStyle } from '../theme-style'; import { Button } from '../../../base/button'; import { getCourseTypeLandingMeta } from '../landing-page-utils'; @@ -19,6 +20,7 @@ hero, courses, hasMoreCourses = false, + coursesLoaded = true, disableCourseLinks = false, embed, callout, @@ -96,37 +98,39 @@ {/snippet} - {#if courses.length > 0} -
-
-
-
-

- {labels?.catalogEyebrow ?? 'Catalog'} -

-

- {labels?.catalogHeading ?? 'Courses starting this season.'} -

-

- {labels?.catalogDescription ?? - 'Cohort-based and self-paced — every course ends with a reviewed project and a certificate of completion.'} -

-
- {#if hasMoreCourses} - - {/if} +
+
+
+
+

+ {labels?.catalogEyebrow ?? 'Catalog'} +

+

+ {labels?.catalogHeading ?? 'Courses starting this season.'} +

+

+ {labels?.catalogDescription ?? + 'Cohort-based and self-paced — every course ends with a reviewed project and a certificate of completion.'} +

+ {#if hasMoreCourses && courses.length > 0} + + {/if} +
+ {#if coursesLoaded && courses.length === 0} + + {:else} {#if visibleFilterDefs.length > 1}
{#each visibleFilterDefs as def (def.key)} @@ -169,9 +173,9 @@
{/if} -
-
- {/if} + {/if} +
+
diff --git a/packages/ui/src/custom/org-landing-page/index.ts b/packages/ui/src/custom/org-landing-page/index.ts index dfabbbc7..be653e00 100644 --- a/packages/ui/src/custom/org-landing-page/index.ts +++ b/packages/ui/src/custom/org-landing-page/index.ts @@ -69,6 +69,7 @@ export { courseCard as VibrantLandingCourseCard } from './vibrant'; +export { default as OrgLandingPageCoursesEmpty } from './courses-empty.svelte'; export { default as LandingButton } from './landing-button.svelte'; export { default as OrgLandingPageEmbed } from './embed.svelte'; export { default as OrgLandingPageCallout } from './callout.svelte'; diff --git a/packages/ui/src/custom/org-landing-page/minimal/org.svelte b/packages/ui/src/custom/org-landing-page/minimal/org.svelte index d026312a..4c4f2298 100644 --- a/packages/ui/src/custom/org-landing-page/minimal/org.svelte +++ b/packages/ui/src/custom/org-landing-page/minimal/org.svelte @@ -7,6 +7,7 @@ import MinimalNav from './nav.svelte'; import MinimalHero from './hero.svelte'; import MinimalCourseCard from './course-card.svelte'; + import OrgLandingPageCoursesEmpty from '../courses-empty.svelte'; import { Button } from '../../../base/button'; import { themeStyle } from '../theme-style'; @@ -18,6 +19,7 @@ hero, courses, hasMoreCourses = false, + coursesLoaded = true, disableCourseLinks = false, embed, callout, @@ -41,23 +43,27 @@

{labels?.catalogHeading ?? 'Our Courses'}

-
- {#each courses as course, index (course.id)} - - {/each} -
- - {#if hasMoreCourses} -
- + {#if coursesLoaded && courses.length === 0} + + {:else} +
+ {#each courses as course, index (course.id)} + + {/each}
+ + {#if hasMoreCourses} +
+ +
+ {/if} {/if}
diff --git a/packages/ui/src/custom/org-landing-page/saas/org.svelte b/packages/ui/src/custom/org-landing-page/saas/org.svelte index 84944599..9f59293c 100644 --- a/packages/ui/src/custom/org-landing-page/saas/org.svelte +++ b/packages/ui/src/custom/org-landing-page/saas/org.svelte @@ -7,6 +7,7 @@ import SaasNav from './nav.svelte'; import SaasHero from './hero.svelte'; import SaasCourseCard from './course-card.svelte'; + import OrgLandingPageCoursesEmpty from '../courses-empty.svelte'; import { Button } from '../../../base/button'; import { themeStyle } from '../theme-style'; @@ -18,6 +19,7 @@ hero, courses, hasMoreCourses = false, + coursesLoaded = true, disableCourseLinks = false, embed, callout, @@ -38,44 +40,46 @@ {/snippet} - {#if courses.length > 0} -
+
-
-
-
-
-

- {labels?.catalogEyebrow ?? 'Catalog'} -

-

- {labels?.catalogHeading ?? 'Featured courses.'} -

-
- {#if hasMoreCourses} - - {/if} +
+
+
+
+

+ {labels?.catalogEyebrow ?? 'Catalog'} +

+

+ {labels?.catalogHeading ?? 'Featured courses.'} +

+ {#if hasMoreCourses && courses.length > 0} + + {/if} +
+ {#if coursesLoaded && courses.length === 0} + + {:else}
{#each courses as course, index (course.id)} {/each}
-
-
- {/if} + {/if} +
+
{#if links && links.cards.length > 0}
diff --git a/packages/ui/src/custom/org-landing-page/studio/org.svelte b/packages/ui/src/custom/org-landing-page/studio/org.svelte index 4db903da..da1483fe 100644 --- a/packages/ui/src/custom/org-landing-page/studio/org.svelte +++ b/packages/ui/src/custom/org-landing-page/studio/org.svelte @@ -7,6 +7,7 @@ import StudioNav from './nav.svelte'; import StudioHero from './hero.svelte'; import StudioCourseCard from './course-card.svelte'; + import OrgLandingPageCoursesEmpty from '../courses-empty.svelte'; import { Button } from '../../../base/button'; import { themeStyle } from '../theme-style'; @@ -18,6 +19,7 @@ hero, courses, hasMoreCourses = false, + coursesLoaded = true, disableCourseLinks = false, embed, callout, @@ -38,41 +40,43 @@ {/snippet} - {#if courses.length > 0} -
-
-
-
-

- - {labels?.catalogEyebrow ?? 'Catalog'} -

-

- {labels?.catalogHeading ?? 'Courses your team can actually finish.'} -

-
- {#if hasMoreCourses} -
- -
- {/if} +
+
+
+
+

+ + {labels?.catalogEyebrow ?? 'Catalog'} +

+

+ {labels?.catalogHeading ?? 'Courses your team can actually finish.'} +

+ {#if hasMoreCourses && courses.length > 0} +
+ +
+ {/if} +
+ {#if coursesLoaded && courses.length === 0} + + {:else}
{#each courses as course, index (course.id)} {/each}
-
-
- {/if} + {/if} +
+
diff --git a/packages/ui/src/custom/org-landing-page/tech/org.svelte b/packages/ui/src/custom/org-landing-page/tech/org.svelte index f431e320..97131e02 100644 --- a/packages/ui/src/custom/org-landing-page/tech/org.svelte +++ b/packages/ui/src/custom/org-landing-page/tech/org.svelte @@ -7,6 +7,7 @@ import TechNav from './nav.svelte'; import TechHero from './hero.svelte'; import TechCourseCard from './course-card.svelte'; + import OrgLandingPageCoursesEmpty from '../courses-empty.svelte'; import { Button } from '../../../base/button'; import { themeStyle } from '../theme-style'; @@ -18,6 +19,7 @@ hero, courses, hasMoreCourses = false, + coursesLoaded = true, disableCourseLinks = false, embed, callout, @@ -38,32 +40,32 @@ {/snippet} - {#if courses.length > 0} -
-
-
-
-

- {labels?.catalogEyebrow ?? '// catalog'} -

-

- {labels?.catalogHeading ?? 'Latest courses'} -

-
- {#if hasMoreCourses} - - {/if} +
+
+
+
+

+ {labels?.catalogEyebrow ?? '// catalog'} +

+

+ {labels?.catalogHeading ?? 'Latest courses'} +

+ {#if hasMoreCourses && courses.length > 0} + + {/if} +
+ {#if coursesLoaded && courses.length === 0} + + {:else}
@@ -71,9 +73,9 @@ {/each}
-
-
- {/if} + {/if} +
+
diff --git a/packages/ui/src/custom/org-landing-page/terminal/org.svelte b/packages/ui/src/custom/org-landing-page/terminal/org.svelte index 05361395..fab0eb04 100644 --- a/packages/ui/src/custom/org-landing-page/terminal/org.svelte +++ b/packages/ui/src/custom/org-landing-page/terminal/org.svelte @@ -7,6 +7,7 @@ import TerminalNav from './nav.svelte'; import TerminalHero from './hero.svelte'; import TerminalCourseCard from './course-card.svelte'; + import OrgLandingPageCoursesEmpty from '../courses-empty.svelte'; import { Button } from '../../../base/button'; import { themeStyle } from '../theme-style'; import { getCourseTypeLandingMeta } from '../landing-page-utils'; @@ -19,6 +20,7 @@ hero, courses, hasMoreCourses = false, + coursesLoaded = true, disableCourseLinks = false, embed, callout, @@ -81,34 +83,36 @@ {/snippet} - {#if courses.length > 0} -
-
-

- - {labels?.catalogEyebrow ?? 'Catalog'} -

+
+
+

+ + {labels?.catalogEyebrow ?? 'Catalog'} +

-

- {labels?.catalogHeading ?? 'Courses your team can actually finish.'} -

+

+ {labels?.catalogHeading ?? 'Courses your team can actually finish.'} +

-

- {labels?.catalogDescription ?? - 'Tracks built around real-world artifacts — pull requests, postmortems, and runbooks. No filler.'} -

+

+ {labels?.catalogDescription ?? + 'Tracks built around real-world artifacts — pull requests, postmortems, and runbooks. No filler.'} +

+ {#if coursesLoaded && courses.length === 0} + + {:else} {#if tabs.length > 1}
{/if} -
-
- {/if} + {/if} +
+
diff --git a/packages/ui/src/custom/org-landing-page/types.ts b/packages/ui/src/custom/org-landing-page/types.ts index 7013f8d3..3cc70261 100644 --- a/packages/ui/src/custom/org-landing-page/types.ts +++ b/packages/ui/src/custom/org-landing-page/types.ts @@ -178,6 +178,10 @@ export interface OrgLandingPageLabels { calloutEyebrow?: string; /** Card footer CTA on the resources/links section. Default per template ("Learn more", "Visit"). Separate from the existing per-template `boldVisitLabel` / `classicLearnMoreLabel` on `LandingPageLinks`, which still apply to those specific templates. */ learnMoreLabel?: string; + /** Empty catalog title when no courses are published. Default: "No courses published yet". */ + catalogEmptyTitle?: string; + /** Empty catalog description when no courses are published. Default: "Check back soon for new programs." */ + catalogEmptyDescription?: string; } export type CourseCurriculumLesson = { @@ -342,6 +346,8 @@ export interface OrgLandingPageProps { }; courses: CourseItem[]; hasMoreCourses?: boolean; + /** When false, templates suppress the empty catalog state while courses are still loading. */ + coursesLoaded?: boolean; disableCourseLinks?: boolean; embed?: LandingPageEmbed; callout?: LandingPageCallout; diff --git a/packages/ui/src/custom/org-landing-page/vibrant/org.svelte b/packages/ui/src/custom/org-landing-page/vibrant/org.svelte index 0299f5bc..27d7f359 100644 --- a/packages/ui/src/custom/org-landing-page/vibrant/org.svelte +++ b/packages/ui/src/custom/org-landing-page/vibrant/org.svelte @@ -7,6 +7,7 @@ import VibrantNav from './nav.svelte'; import VibrantHero from './hero.svelte'; import VibrantCourseCard from './course-card.svelte'; + import OrgLandingPageCoursesEmpty from '../courses-empty.svelte'; import { themeStyle } from '../theme-style'; import { Button } from '../../../base/button'; import { getCourseTypeLandingMeta, defaultLessonsLabel, defaultExercisesLabel } from '../landing-page-utils'; @@ -19,6 +20,7 @@ hero, courses, hasMoreCourses = false, + coursesLoaded = true, disableCourseLinks = false, embed, callout, @@ -56,153 +58,161 @@ {/snippet} - {#if featured} -
+ {#if coursesLoaded && courses.length === 0} +
-
-
- {#if featuredTypeMeta} - +
+
+ {:else} + {#if featured} +
+
+
+
+ {#if featuredTypeMeta} + + {labels?.featuredLabel ?? 'Featured'} · {featuredTypeMeta.label} + + {/if} +

- {labels?.featuredLabel ?? 'Featured'} · {featuredTypeMeta.label} - - {/if} -

- {featured.title} -

-

- {featured.description} -

-
- {#if featured.lessonCount} - {resolvedLessonsLabel(featured.lessonCount)} - {/if} - {#if featured.exerciseCount} - - {resolvedExercisesLabel(featured.exerciseCount)} - {/if} - {#if featured.duration} - - {featured.duration} - {/if} -
-
- - - {featured.price || formatCost(featured.cost, featured.currency)} - + {#if featured.lessonCount} + {resolvedLessonsLabel(featured.lessonCount)} + {/if} + {#if featured.exerciseCount} + + {resolvedExercisesLabel(featured.exerciseCount)} + {/if} + {#if featured.duration} + + {featured.duration} + {/if} +
+
+ + + {featured.price || formatCost(featured.cost, featured.currency)} + +
-
-
-
-

Course outline

-

- Pick up where you left off — anytime. -

-
- {#each Array.from({ length: Math.min(featured.lessonCount ?? 4, 4) }) as _, idx (idx)} -
- +

Course outline

+

+ Pick up where you left off — anytime. +

+
+ {#each Array.from({ length: Math.min(featured.lessonCount ?? 4, 4) }) as _, idx (idx)} +
- {String(idx + 1).padStart(2, '0')} - - - {idx === 0 - ? 'Foundations & setup' - : idx === 1 - ? 'Core concepts' - : idx === 2 - ? 'Hands-on practice' - : 'Capstone project'} - - - {idx === 0 ? '42 min' : idx === 1 ? '1h 12m' : idx === 2 ? '58 min' : '2h 30m'} - -
- {/each} + + {String(idx + 1).padStart(2, '0')} + + + {idx === 0 + ? 'Foundations & setup' + : idx === 1 + ? 'Core concepts' + : idx === 2 + ? 'Hands-on practice' + : 'Capstone project'} + + + {idx === 0 ? '42 min' : idx === 1 ? '1h 12m' : idx === 2 ? '58 min' : '2h 30m'} + +
+ {/each} +
- -
- {/if} +
+ {/if} - {#if remainingCourses.length > 0 || (!featured && courses.length > 0)} - {@const catalogCourses = featured ? remainingCourses : courses} -
-
-
-
-

- {labels?.catalogEyebrow ?? 'Catalog'} -

-

- {labels?.catalogHeading ?? 'More courses to take next.'} -

+ {#if remainingCourses.length > 0 || (!featured && courses.length > 0)} + {@const catalogCourses = featured ? remainingCourses : courses} +
+
+
+
+

+ {labels?.catalogEyebrow ?? 'Catalog'} +

+

+ {labels?.catalogHeading ?? 'More courses to take next.'} +

+
+ {#if hasMoreCourses} + + {/if}
- {#if hasMoreCourses} - - {/if} -
-
- {#each catalogCourses as course, index (course.id)} - - {/each} +
+ {#each catalogCourses as course, index (course.id)} + + {/each} +
-
-
+ + {/if} {/if}