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); audiencePagination = $state<OrganizationAudiencePagination | null>(null);
publicCourses: OrgPublicCourses = $state([]); publicCourses: OrgPublicCourses = $state([]);
hasMorePublicCourses = $state(false); hasMorePublicCourses = $state(false);
publicCoursesLoadedSiteName: string | null = $state(null);
isFetchingOrgPublicCourses = $state(false); isFetchingOrgPublicCourses = $state(false);
private activePublicCoursesFetch: Promise<void> | null = null;
private activePublicCoursesFetchSiteName: string | null = null;
private activeAudienceRequestController: AbortController | null = null; private activeAudienceRequestController: AbortController | null = null;
cancelAudienceRequest() { cancelAudienceRequest() {
@@ -155,15 +158,52 @@ class OrgApi extends BaseApiWithErrors {
return response; 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) * Gets public courses by organization siteName (for landing pages)
* @param siteName Organization site name * @param siteName Organization site name
* @returns Published courses array * @returns Published courses array
*/ */
async getPublicCoursesBySiteName(siteName: string) { 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; this.isFetchingOrgPublicCourses = true;
await this.execute<GetOrgPublicCoursesRequest>({ const fetchPromise = this.execute<GetOrgPublicCoursesRequest>({
requestFn: () => requestFn: () =>
classroomio.organization.courses.public.$get({ classroomio.organization.courses.public.$get({
query: { siteName } query: { siteName }
@@ -172,10 +212,29 @@ class OrgApi extends BaseApiWithErrors {
onSuccess: (response) => { onSuccess: (response) => {
this.publicCourses = response.data.courses; this.publicCourses = response.data.courses;
this.hasMorePublicCourses = response.data.hasMoreCourses; 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), normalizeLandingPageSettings(org.landingpage),
orgApi.publicCourses, orgApi.publicCourses,
orgApi.hasMorePublicCourses, orgApi.hasMorePublicCourses,
authAction authAction,
{ coursesLoaded: hasLoadedCourses }
) )
); );
@@ -18,6 +18,7 @@ import {
labelMatchesSocialPlatform, labelMatchesSocialPlatform,
resolveFooterSocialPlatform resolveFooterSocialPlatform
} from '@cio/ui/custom/org-landing-page/footer-social-platform'; } from '@cio/ui/custom/org-landing-page/footer-social-platform';
import { t } from '$lib/utils/functions/translations';
export const landingPageThemes = [ export const landingPageThemes = [
'minimal', '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( export function buildOrgLandingPageProps(
org: AccountOrg, org: AccountOrg,
landingpage: unknown, landingpage: unknown,
courses: OrgPublicCourses, courses: OrgPublicCourses,
hasMoreCourses = false, hasMoreCourses = false,
authAction?: OrgLandingPageProps['authAction'] authAction?: OrgLandingPageProps['authAction'],
options?: { coursesLoaded?: boolean }
): OrgLandingPageProps { ): OrgLandingPageProps {
const normalizedLandingPage = normalizeLandingPageSettings(landingpage); const normalizedLandingPage = normalizeLandingPageSettings(landingpage);
@@ -704,7 +713,9 @@ export function buildOrgLandingPageProps(
authAction, authAction,
...normalizedLandingPage, ...normalizedLandingPage,
courses: mapPublicCoursesToLandingPageCourses(courses), courses: mapPublicCoursesToLandingPageCourses(courses),
hasMoreCourses hasMoreCourses,
coursesLoaded: options?.coursesLoaded ?? true,
labels: buildOrgLandingPageLabels()
}; };
} }
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { page } from '$app/state';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right'; import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical'; import EllipsisVerticalIcon from '@lucide/svelte/icons/ellipsis-vertical';
@@ -134,7 +135,21 @@
: { label: t.get('navigation.login'), href: '/login' } : { 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); const ThemeComponent = $derived(landingPageThemeComponents[currentTheme] ?? landingPageThemeComponents.minimal);
@@ -3739,6 +3739,10 @@
"title": "Ingen kurser fundet", "title": "Ingen kurser fundet",
"description": "Prøv at rydde dine filtre, eller tjek igen senere." "description": "Prøv at rydde dine filtre, eller tjek igen senere."
}, },
"catalog_empty": {
"title": "Ingen kurser udgivet endnu",
"description": "Kom snart tilbage for nye programmer."
},
"filters": { "filters": {
"title": "Filtrer efter tags", "title": "Filtrer efter tags",
"help": "Vælg et eller flere tags for at filtrere kurser", "help": "Vælg et eller flere tags for at filtrere kurser",
@@ -3739,6 +3739,10 @@
"title": "Keine Kurse gefunden", "title": "Keine Kurse gefunden",
"description": "Versuchen Sie, Ihre Filter zu löschen, oder schauen Sie später noch einmal vorbei." "description": "Versuchen Sie, Ihre Filter zu löschen, oder schauen Sie später noch einmal vorbei."
}, },
"catalog_empty": {
"title": "Noch keine Kurse veröffentlicht",
"description": "Schauen Sie bald wieder vorbei für neue Programme."
},
"filters": { "filters": {
"title": "Nach Tags filtern", "title": "Nach Tags filtern",
"help": "Wählen Sie einen oder mehrere Tags aus, um Kurse zu filtern", "help": "Wählen Sie einen oder mehrere Tags aus, um Kurse zu filtern",
@@ -1231,6 +1231,10 @@
"empty": { "empty": {
"title": "No courses found", "title": "No courses found",
"description": "Try clearing your filters or check back later." "description": "Try clearing your filters or check back later."
},
"catalog_empty": {
"title": "No courses published yet",
"description": "Check back soon for new programs."
} }
}, },
"org_navigation": { "org_navigation": {
@@ -3739,6 +3739,10 @@
"title": "No se encontraron cursos", "title": "No se encontraron cursos",
"description": "Intente borrar sus filtros o vuelva a consultar más tarde." "description": "Intente borrar sus filtros o vuelva a consultar más tarde."
}, },
"catalog_empty": {
"title": "Aún no hay cursos publicados",
"description": "Vuelva pronto para ver nuevos programas."
},
"filters": { "filters": {
"title": "Filtrar por etiquetas", "title": "Filtrar por etiquetas",
"help": "Seleccione una o más etiquetas para filtrar cursos", "help": "Seleccione una o más etiquetas para filtrar cursos",
@@ -3739,6 +3739,10 @@
"title": "Aucun cours trouvé", "title": "Aucun cours trouvé",
"description": "Essayez de supprimer vos filtres ou revenez plus tard." "description": "Essayez de supprimer vos filtres ou revenez plus tard."
}, },
"catalog_empty": {
"title": "Aucun cours publié pour le moment",
"description": "Revenez bientôt pour découvrir de nouveaux programmes."
},
"filters": { "filters": {
"title": "Filtrer par balises", "title": "Filtrer par balises",
"help": "Sélectionnez une ou plusieurs balises pour filtrer les cours", "help": "Sélectionnez une ou plusieurs balises pour filtrer les cours",
@@ -3739,6 +3739,10 @@
"title": "कोई पाठ्यक्रम नहीं मिला", "title": "कोई पाठ्यक्रम नहीं मिला",
"description": "अपने फ़िल्टर साफ़ करने का प्रयास करें या बाद में दोबारा जाँचें।" "description": "अपने फ़िल्टर साफ़ करने का प्रयास करें या बाद में दोबारा जाँचें।"
}, },
"catalog_empty": {
"title": "अभी तक कोई पाठ्यक्रम प्रकाशित नहीं हुआ",
"description": "नए कार्यक्रमों के लिए जल्द ही वापस आएँ।"
},
"filters": { "filters": {
"title": "टैग द्वारा फ़िल्टर करें", "title": "टैग द्वारा फ़िल्टर करें",
"help": "पाठ्यक्रमों को फ़िल्टर करने के लिए एक या अधिक टैग चुनें", "help": "पाठ्यक्रमों को फ़िल्टर करने के लिए एक या अधिक टैग चुनें",
@@ -3739,6 +3739,10 @@
"title": "Nie znaleziono kursów", "title": "Nie znaleziono kursów",
"description": "Spróbuj wyczyścić filtry lub sprawdź później." "description": "Spróbuj wyczyścić filtry lub sprawdź później."
}, },
"catalog_empty": {
"title": "Brak opublikowanych kursów",
"description": "Wróć wkrótce po nowe programy."
},
"filters": { "filters": {
"title": "Filtruj według tagów", "title": "Filtruj według tagów",
"help": "Wybierz jeden lub więcej tagów, aby filtrować kursy", "help": "Wybierz jeden lub więcej tagów, aby filtrować kursy",
@@ -3739,6 +3739,10 @@
"title": "Nenhum curso encontrado", "title": "Nenhum curso encontrado",
"description": "Tente limpar os filtros ou volte mais tarde." "description": "Tente limpar os filtros ou volte mais tarde."
}, },
"catalog_empty": {
"title": "Nenhum curso publicado ainda",
"description": "Volte em breve para novos programas."
},
"filters": { "filters": {
"title": "Filtrar por tags", "title": "Filtrar por tags",
"help": "Selecione uma ou mais tags para filtrar cursos", "help": "Selecione uma ou mais tags para filtrar cursos",
@@ -3739,6 +3739,10 @@
"title": "Курсы не найдены", "title": "Курсы не найдены",
"description": "Попробуйте очистить фильтры или зайдите позже." "description": "Попробуйте очистить фильтры или зайдите позже."
}, },
"catalog_empty": {
"title": "Пока нет опубликованных курсов",
"description": "Загляните позже — скоро появятся новые программы."
},
"filters": { "filters": {
"title": "Фильтровать по тегам", "title": "Фильтровать по тегам",
"help": "Выберите один или несколько тегов, чтобы отфильтровать курсы.", "help": "Выберите один или несколько тегов, чтобы отфильтровать курсы.",
@@ -3739,6 +3739,10 @@
"title": "Không tìm thấy khóa học nào", "title": "Không tìm thấy khóa học nào",
"description": "Hãy thử xóa bộ lọc của bạn hoặc kiểm tra lại sau." "description": "Hãy thử xóa bộ lọc của bạn hoặc kiểm tra lại sau."
}, },
"catalog_empty": {
"title": "Chưa có khóa học nào được xuất bản",
"description": "Hãy quay lại sớm để xem các chương trình mới."
},
"filters": { "filters": {
"title": "Lọc theo thẻ", "title": "Lọc theo thẻ",
"help": "Chọn một hoặc nhiều thẻ để lọc các khóa học", "help": "Chọn một hoặc nhiều thẻ để lọc các khóa học",
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import { onMount, type Component } from 'svelte'; import type { Component } from 'svelte';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { page } from '$app/state'; import { page } from '$app/state';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@@ -90,24 +90,27 @@
} }
); );
const previewProps = $derived( const previewSiteName = $derived($currentOrg.siteName || page.params.slug || '');
const coursesLoaded = $derived(!previewSiteName || orgApi.publicCoursesLoadedSiteName === previewSiteName);
const previewProps = $derived.by(() =>
buildOrgLandingPageProps( buildOrgLandingPageProps(
$currentOrg, $currentOrg,
previewLandingPageSettings, previewLandingPageSettings,
orgApi.publicCourses, orgApi.publicCourses,
orgApi.hasMorePublicCourses, orgApi.hasMorePublicCourses,
authAction authAction,
{ coursesLoaded }
) )
); );
const ThemeComponent = $derived(landingPageThemeComponents[previewTheme] ?? landingPageThemeComponents.minimal); const ThemeComponent = $derived(landingPageThemeComponents[previewTheme] ?? landingPageThemeComponents.minimal);
onMount(() => { $effect(() => {
if ($currentOrg.siteName) { if (!previewSiteName) return;
orgApi.publicCourses = [];
orgApi.hasMorePublicCourses = false; void orgApi.loadPublicCoursesIfNeeded(previewSiteName);
void orgApi.getPublicCoursesBySiteName($currentOrg.siteName);
}
}); });
$effect(() => { $effect(() => {
@@ -7,6 +7,7 @@
import BoldNav from './nav.svelte'; import BoldNav from './nav.svelte';
import BoldHero from './hero.svelte'; import BoldHero from './hero.svelte';
import BoldCourseCard from './course-card.svelte'; import BoldCourseCard from './course-card.svelte';
import OrgLandingPageCoursesEmpty from '../courses-empty.svelte';
import { Button } from '../../../base/button'; import { Button } from '../../../base/button';
import { DotPattern } from '../../animation/dot-pattern'; import { DotPattern } from '../../animation/dot-pattern';
import { themeStyle } from '../theme-style'; import { themeStyle } from '../theme-style';
@@ -19,6 +20,7 @@
hero, hero,
courses, courses,
hasMoreCourses = false, hasMoreCourses = false,
coursesLoaded = true,
disableCourseLinks = false, disableCourseLinks = false,
embed, embed,
callout, callout,
@@ -43,23 +45,27 @@
<div class="ui:flex ui:items-end ui:justify-between ui:mb-12"> <div class="ui:flex ui:items-end ui:justify-between ui:mb-12">
<h2 class="ui:text-4xl ui:font-black ui:tracking-tight">{labels?.catalogHeading ?? 'Latest Courses'}</h2> <h2 class="ui:text-4xl ui:font-black ui:tracking-tight">{labels?.catalogHeading ?? 'Latest Courses'}</h2>
</div> </div>
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-8"> {#if coursesLoaded && courses.length === 0}
{#each courses as course, index (course.id)} <OrgLandingPageCoursesEmpty {labels} />
<BoldCourseCard {course} {disableCourseLinks} {labels} /> {:else}
{/each} <div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-8">
</div> {#each courses as course, index (course.id)}
<BoldCourseCard {course} {disableCourseLinks} {labels} />
{#if hasMoreCourses} {/each}
<div class="ui:mt-12 ui:flex ui:justify-center">
<Button
href={disableCourseLinks ? undefined : '/courses'}
size="lg"
class="ui:rounded-xl ui:px-8"
disabled={disableCourseLinks}
>
{labels?.browseCoursesLabel ?? 'View more courses'}
</Button>
</div> </div>
{#if hasMoreCourses}
<div class="ui:mt-12 ui:flex ui:justify-center">
<Button
href={disableCourseLinks ? undefined : '/courses'}
size="lg"
class="ui:rounded-xl ui:px-8"
disabled={disableCourseLinks}
>
{labels?.browseCoursesLabel ?? 'View more courses'}
</Button>
</div>
{/if}
{/if} {/if}
</div> </div>
</section> </section>
@@ -7,6 +7,7 @@
import ClassicNav from './nav.svelte'; import ClassicNav from './nav.svelte';
import ClassicHero from './hero.svelte'; import ClassicHero from './hero.svelte';
import ClassicCourseCard from './course-card.svelte'; import ClassicCourseCard from './course-card.svelte';
import OrgLandingPageCoursesEmpty from '../courses-empty.svelte';
import { Button } from '../../../base/button'; import { Button } from '../../../base/button';
import { themeStyle } from '../theme-style'; import { themeStyle } from '../theme-style';
@@ -18,6 +19,7 @@
hero, hero,
courses, courses,
hasMoreCourses = false, hasMoreCourses = false,
coursesLoaded = true,
disableCourseLinks = false, disableCourseLinks = false,
embed, embed,
callout, callout,
@@ -44,24 +46,28 @@
<div class="ui:mt-6 ui:h-px ui:w-24 ui:bg-[var(--landing-border)] ui:mx-auto"></div> <div class="ui:mt-6 ui:h-px ui:w-24 ui:bg-[var(--landing-border)] ui:mx-auto"></div>
</div> </div>
<div class="ui:grid ui:grid-cols-1 ui:sm:grid-cols-2 ui:lg:grid-cols-3 ui:gap-8 ui:justify-items-center"> {#if coursesLoaded && courses.length === 0}
{#each courses as course, index (course.id)} <OrgLandingPageCoursesEmpty {labels} />
<ClassicCourseCard {course} {disableCourseLinks} {labels} /> {:else}
{/each} <div class="ui:grid ui:grid-cols-1 ui:sm:grid-cols-2 ui:lg:grid-cols-3 ui:gap-8 ui:justify-items-center">
</div> {#each courses as course, index (course.id)}
<ClassicCourseCard {course} {disableCourseLinks} {labels} />
{#if hasMoreCourses} {/each}
<div class="ui:mt-12 ui:flex ui:justify-center">
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
size="lg"
class="ui:px-8"
disabled={disableCourseLinks}
>
{labels?.browseCoursesLabel ?? 'View more courses'}
</Button>
</div> </div>
{#if hasMoreCourses}
<div class="ui:mt-12 ui:flex ui:justify-center">
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
size="lg"
class="ui:px-8"
disabled={disableCourseLinks}
>
{labels?.browseCoursesLabel ?? 'View more courses'}
</Button>
</div>
{/if}
{/if} {/if}
</section> </section>
</main> </main>
@@ -7,6 +7,7 @@
import CorporateNav from './nav.svelte'; import CorporateNav from './nav.svelte';
import CorporateHero from './hero.svelte'; import CorporateHero from './hero.svelte';
import CorporateCourseCard from './course-card.svelte'; import CorporateCourseCard from './course-card.svelte';
import OrgLandingPageCoursesEmpty from '../courses-empty.svelte';
import { Button } from '../../../base/button'; import { Button } from '../../../base/button';
import { themeStyle } from '../theme-style'; import { themeStyle } from '../theme-style';
@@ -18,6 +19,7 @@
hero, hero,
courses, courses,
hasMoreCourses = false, hasMoreCourses = false,
coursesLoaded = true,
disableCourseLinks = false, disableCourseLinks = false,
embed, embed,
callout, callout,
@@ -38,32 +40,32 @@
{/snippet} {/snippet}
</CorporateHero> </CorporateHero>
{#if courses.length > 0} <section class="ui:py-24 ui:px-6 ui:border-t ui:border-[var(--landing-border)]">
<section class="ui:py-24 ui:px-6 ui:border-t ui:border-[var(--landing-border)]"> <div class="ui:max-w-[1120px] ui:mx-auto">
<div class="ui:max-w-[1120px] ui:mx-auto"> <div class="ui:flex ui:items-end ui:justify-between ui:flex-wrap ui:gap-4 ui:mb-10">
<div class="ui:flex ui:items-end ui:justify-between ui:flex-wrap ui:gap-4 ui:mb-10"> <div>
<div> <p class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-[var(--landing-fg)] ui:mb-3">
<p {labels?.catalogEyebrow ?? 'Catalog'}
class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-[var(--landing-fg)] ui:mb-3" </p>
> <h2 class="ui:text-3xl ui:lg:text-4xl ui:font-semibold ui:tracking-tight ui:m-0 ui:max-w-xl">
{labels?.catalogEyebrow ?? 'Catalog'} {labels?.catalogHeading ?? 'Courses your teams are taking'}
</p> </h2>
<h2 class="ui:text-3xl ui:lg:text-4xl ui:font-semibold ui:tracking-tight ui:m-0 ui:max-w-xl">
{labels?.catalogHeading ?? 'Courses your teams are taking'}
</h2>
</div>
{#if hasMoreCourses}
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
class="ui:rounded-none ui:font-medium"
disabled={disableCourseLinks}
>
{labels?.browseCoursesLabel ?? 'Browse catalog →'}
</Button>
{/if}
</div> </div>
{#if hasMoreCourses && courses.length > 0}
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
class="ui:rounded-none ui:font-medium"
disabled={disableCourseLinks}
>
{labels?.browseCoursesLabel ?? 'Browse catalog →'}
</Button>
{/if}
</div>
{#if coursesLoaded && courses.length === 0}
<OrgLandingPageCoursesEmpty {labels} />
{:else}
<div <div
class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:border-t ui:border-l ui:border-[var(--landing-border)]" class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:border-t ui:border-l ui:border-[var(--landing-border)]"
> >
@@ -71,9 +73,9 @@
<CorporateCourseCard {course} {disableCourseLinks} {labels} /> <CorporateCourseCard {course} {disableCourseLinks} {labels} />
{/each} {/each}
</div> </div>
</div> {/if}
</section> </div>
{/if} </section>
</main> </main>
<OrgLandingPageLinks {links} {labels} variant="corporate" /> <OrgLandingPageLinks {links} {labels} variant="corporate" />
@@ -0,0 +1,28 @@
<script lang="ts">
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
import { Empty } from '../empty';
import type { OrgLandingPageLabels } from './types';
interface Props {
labels?: OrgLandingPageLabels;
class?: string;
}
let { labels, class: className = '' }: Props = $props();
const title = $derived(labels?.catalogEmptyTitle ?? 'No courses published yet');
const description = $derived(labels?.catalogEmptyDescription ?? 'Check back soon for new programs.');
</script>
<div
class="ui:[&_[data-slot=empty-title]]:text-[var(--landing-fg)] ui:[&_[data-slot=empty-description]]:text-[var(--landing-fg-muted)] {className}"
>
<Empty
icon={LibraryBigIcon}
{title}
{description}
variant="page"
class="ui:border-[var(--landing-border)] ui:bg-[var(--landing-card-soft)]/40"
iconClass="ui:text-[var(--landing-fg-muted)]"
/>
</div>
@@ -7,6 +7,7 @@
import EditorialNav from './nav.svelte'; import EditorialNav from './nav.svelte';
import EditorialHero from './hero.svelte'; import EditorialHero from './hero.svelte';
import EditorialCourseCard from './course-card.svelte'; import EditorialCourseCard from './course-card.svelte';
import OrgLandingPageCoursesEmpty from '../courses-empty.svelte';
import { themeStyle } from '../theme-style'; import { themeStyle } from '../theme-style';
import { Button } from '../../../base/button'; import { Button } from '../../../base/button';
import { getCourseTypeLandingMeta } from '../landing-page-utils'; import { getCourseTypeLandingMeta } from '../landing-page-utils';
@@ -19,6 +20,7 @@
hero, hero,
courses, courses,
hasMoreCourses = false, hasMoreCourses = false,
coursesLoaded = true,
disableCourseLinks = false, disableCourseLinks = false,
embed, embed,
callout, callout,
@@ -96,37 +98,39 @@
{/snippet} {/snippet}
</EditorialHero> </EditorialHero>
{#if courses.length > 0} <section id="courses" class="ui:py-10 ui:px-6 ui:md:px-8 ui:bg-[var(--landing-bg)]">
<section id="courses" class="ui:py-10 ui:px-6 ui:md:px-8 ui:bg-[var(--landing-bg)]"> <div class="ui:max-w-[1240px] ui:mx-auto">
<div class="ui:max-w-[1240px] ui:mx-auto"> <div class="ui:flex ui:flex-col ui:md:flex-row ui:md:items-end ui:justify-between ui:gap-6 ui:mb-8">
<div class="ui:flex ui:flex-col ui:md:flex-row ui:md:items-end ui:justify-between ui:gap-6 ui:mb-8"> <div>
<div> <p class="ui:text-[13px] ui:m-0 ui:mb-2 ui:text-[var(--landing-fg-muted)]">
<p class="ui:text-[13px] ui:m-0 ui:mb-2 ui:text-[var(--landing-fg-muted)]"> {labels?.catalogEyebrow ?? 'Catalog'}
{labels?.catalogEyebrow ?? 'Catalog'} </p>
</p> <h2
<h2 class="ui:text-3xl ui:md:text-[40px] ui:font-medium ui:tracking-tight ui:m-0 ui:leading-[1.1] ui:text-[var(--landing-fg)]"
class="ui:text-3xl ui:md:text-[40px] ui:font-medium ui:tracking-tight ui:m-0 ui:leading-[1.1] ui:text-[var(--landing-fg)]" style="letter-spacing: -0.025em;"
style="letter-spacing: -0.025em;" >
> {labels?.catalogHeading ?? 'Courses starting this season.'}
{labels?.catalogHeading ?? 'Courses starting this season.'} </h2>
</h2> <p class="ui:text-base ui:mt-3 ui:max-w-xl ui:m-0 ui:text-[var(--landing-fg-muted)]">
<p class="ui:text-base ui:mt-3 ui:max-w-xl ui:m-0 ui:text-[var(--landing-fg-muted)]"> {labels?.catalogDescription ??
{labels?.catalogDescription ?? 'Cohort-based and self-paced — every course ends with a reviewed project and a certificate of completion.'}
'Cohort-based and self-paced — every course ends with a reviewed project and a certificate of completion.'} </p>
</p>
</div>
{#if hasMoreCourses}
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
class="ui:rounded-full ui:px-5 ui:font-medium ui:bg-transparent ui:border-[var(--landing-border)] ui:text-[var(--landing-fg)] ui:hover:bg-[var(--landing-bg-section)]"
disabled={disableCourseLinks}
>
{labels?.browseCoursesLabel ?? 'Browse all courses →'}
</Button>
{/if}
</div> </div>
{#if hasMoreCourses && courses.length > 0}
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
class="ui:rounded-full ui:px-5 ui:font-medium ui:bg-transparent ui:border-[var(--landing-border)] ui:text-[var(--landing-fg)] ui:hover:bg-[var(--landing-bg-section)]"
disabled={disableCourseLinks}
>
{labels?.browseCoursesLabel ?? 'Browse all courses →'}
</Button>
{/if}
</div>
{#if coursesLoaded && courses.length === 0}
<OrgLandingPageCoursesEmpty {labels} />
{:else}
{#if visibleFilterDefs.length > 1} {#if visibleFilterDefs.length > 1}
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-1.5 ui:mb-8"> <div class="ui:flex ui:flex-wrap ui:items-center ui:gap-1.5 ui:mb-8">
{#each visibleFilterDefs as def (def.key)} {#each visibleFilterDefs as def (def.key)}
@@ -169,9 +173,9 @@
</Button> </Button>
</div> </div>
{/if} {/if}
</div> {/if}
</section> </div>
{/if} </section>
</main> </main>
<OrgLandingPageLinks {links} {labels} variant="editorial" /> <OrgLandingPageLinks {links} {labels} variant="editorial" />
@@ -69,6 +69,7 @@ export {
courseCard as VibrantLandingCourseCard courseCard as VibrantLandingCourseCard
} from './vibrant'; } from './vibrant';
export { default as OrgLandingPageCoursesEmpty } from './courses-empty.svelte';
export { default as LandingButton } from './landing-button.svelte'; export { default as LandingButton } from './landing-button.svelte';
export { default as OrgLandingPageEmbed } from './embed.svelte'; export { default as OrgLandingPageEmbed } from './embed.svelte';
export { default as OrgLandingPageCallout } from './callout.svelte'; export { default as OrgLandingPageCallout } from './callout.svelte';
@@ -7,6 +7,7 @@
import MinimalNav from './nav.svelte'; import MinimalNav from './nav.svelte';
import MinimalHero from './hero.svelte'; import MinimalHero from './hero.svelte';
import MinimalCourseCard from './course-card.svelte'; import MinimalCourseCard from './course-card.svelte';
import OrgLandingPageCoursesEmpty from '../courses-empty.svelte';
import { Button } from '../../../base/button'; import { Button } from '../../../base/button';
import { themeStyle } from '../theme-style'; import { themeStyle } from '../theme-style';
@@ -18,6 +19,7 @@
hero, hero,
courses, courses,
hasMoreCourses = false, hasMoreCourses = false,
coursesLoaded = true,
disableCourseLinks = false, disableCourseLinks = false,
embed, embed,
callout, callout,
@@ -41,23 +43,27 @@
<section class="ui:py-20 ui:px-4"> <section class="ui:py-20 ui:px-4">
<div class="ui:max-w-[1200px] ui:mx-auto"> <div class="ui:max-w-[1200px] ui:mx-auto">
<h2 class="ui:text-2xl ui:font-semibold ui:mb-8">{labels?.catalogHeading ?? 'Our Courses'}</h2> <h2 class="ui:text-2xl ui:font-semibold ui:mb-8">{labels?.catalogHeading ?? 'Our Courses'}</h2>
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-6"> {#if coursesLoaded && courses.length === 0}
{#each courses as course, index (course.id)} <OrgLandingPageCoursesEmpty {labels} />
<MinimalCourseCard {course} {disableCourseLinks} {labels} /> {:else}
{/each} <div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-6">
</div> {#each courses as course, index (course.id)}
<MinimalCourseCard {course} {disableCourseLinks} {labels} />
{#if hasMoreCourses} {/each}
<div class="ui:mt-10 ui:flex ui:justify-center">
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
size="lg"
disabled={disableCourseLinks}
>
{labels?.browseCoursesLabel ?? 'View more courses'}
</Button>
</div> </div>
{#if hasMoreCourses}
<div class="ui:mt-10 ui:flex ui:justify-center">
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
size="lg"
disabled={disableCourseLinks}
>
{labels?.browseCoursesLabel ?? 'View more courses'}
</Button>
</div>
{/if}
{/if} {/if}
</div> </div>
</section> </section>
@@ -7,6 +7,7 @@
import SaasNav from './nav.svelte'; import SaasNav from './nav.svelte';
import SaasHero from './hero.svelte'; import SaasHero from './hero.svelte';
import SaasCourseCard from './course-card.svelte'; import SaasCourseCard from './course-card.svelte';
import OrgLandingPageCoursesEmpty from '../courses-empty.svelte';
import { Button } from '../../../base/button'; import { Button } from '../../../base/button';
import { themeStyle } from '../theme-style'; import { themeStyle } from '../theme-style';
@@ -18,6 +19,7 @@
hero, hero,
courses, courses,
hasMoreCourses = false, hasMoreCourses = false,
coursesLoaded = true,
disableCourseLinks = false, disableCourseLinks = false,
embed, embed,
callout, callout,
@@ -38,44 +40,46 @@
{/snippet} {/snippet}
</SaasHero> </SaasHero>
{#if courses.length > 0} <div class="plus-row"></div>
<div class="plus-row"></div>
<section class="ui:py-12 ui:md:py-20 ui:px-4 ui:md:px-6"> <section class="ui:py-12 ui:md:py-20 ui:px-4 ui:md:px-6">
<div class="ui:max-w-[1180px] ui:mx-auto"> <div class="ui:max-w-[1180px] ui:mx-auto">
<div <div
class="ui:flex ui:flex-col ui:md:flex-row ui:md:items-end ui:md:justify-between ui:gap-4 ui:mb-8 ui:md:mb-12" class="ui:flex ui:flex-col ui:md:flex-row ui:md:items-end ui:md:justify-between ui:gap-4 ui:mb-8 ui:md:mb-12"
> >
<div> <div>
<p <p
class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-[var(--landing-accent)] ui:mb-3" class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-[var(--landing-accent)] ui:mb-3"
> >
{labels?.catalogEyebrow ?? 'Catalog'} {labels?.catalogEyebrow ?? 'Catalog'}
</p> </p>
<h2 class="ui:text-3xl ui:md:text-4xl ui:font-bold ui:tracking-tight"> <h2 class="ui:text-3xl ui:md:text-4xl ui:font-bold ui:tracking-tight">
{labels?.catalogHeading ?? 'Featured courses.'} {labels?.catalogHeading ?? 'Featured courses.'}
</h2> </h2>
</div>
{#if hasMoreCourses}
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
class="ui:rounded-full ui:self-start ui:md:self-auto"
disabled={disableCourseLinks}
>
{labels?.browseCoursesLabel ?? 'Browse catalog'}
</Button>
{/if}
</div> </div>
{#if hasMoreCourses && courses.length > 0}
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
class="ui:rounded-full ui:self-start ui:md:self-auto"
disabled={disableCourseLinks}
>
{labels?.browseCoursesLabel ?? 'Browse catalog'}
</Button>
{/if}
</div>
{#if coursesLoaded && courses.length === 0}
<OrgLandingPageCoursesEmpty {labels} />
{:else}
<div class="course-grid ui:relative"> <div class="course-grid ui:relative">
{#each courses as course, index (course.id)} {#each courses as course, index (course.id)}
<SaasCourseCard {course} {disableCourseLinks} {labels} /> <SaasCourseCard {course} {disableCourseLinks} {labels} />
{/each} {/each}
</div> </div>
</div> {/if}
</section> </div>
{/if} </section>
{#if links && links.cards.length > 0} {#if links && links.cards.length > 0}
<div class="plus-row"></div> <div class="plus-row"></div>
@@ -7,6 +7,7 @@
import StudioNav from './nav.svelte'; import StudioNav from './nav.svelte';
import StudioHero from './hero.svelte'; import StudioHero from './hero.svelte';
import StudioCourseCard from './course-card.svelte'; import StudioCourseCard from './course-card.svelte';
import OrgLandingPageCoursesEmpty from '../courses-empty.svelte';
import { Button } from '../../../base/button'; import { Button } from '../../../base/button';
import { themeStyle } from '../theme-style'; import { themeStyle } from '../theme-style';
@@ -18,6 +19,7 @@
hero, hero,
courses, courses,
hasMoreCourses = false, hasMoreCourses = false,
coursesLoaded = true,
disableCourseLinks = false, disableCourseLinks = false,
embed, embed,
callout, callout,
@@ -38,41 +40,43 @@
{/snippet} {/snippet}
</StudioHero> </StudioHero>
{#if courses.length > 0} <section class="ui:py-24 ui:px-6 ui:border-t ui:border-[var(--landing-border)]">
<section class="ui:py-24 ui:px-6 ui:border-t ui:border-[var(--landing-border)]"> <div class="ui:max-w-[1080px] ui:mx-auto">
<div class="ui:max-w-[1080px] ui:mx-auto"> <div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-12 ui:items-end ui:mb-12">
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-12 ui:items-end ui:mb-12"> <div>
<div> <p class="ui:text-sm ui:text-[var(--landing-fg-muted)] ui:mb-1.5 ui:inline-flex ui:items-center ui:gap-2">
<p class="ui:text-sm ui:text-[var(--landing-fg-muted)] ui:mb-1.5 ui:inline-flex ui:items-center ui:gap-2"> <span class="ui:size-1.5 ui:rounded-full ui:bg-[var(--landing-accent)]"></span>
<span class="ui:size-1.5 ui:rounded-full ui:bg-[var(--landing-accent)]"></span> {labels?.catalogEyebrow ?? 'Catalog'}
{labels?.catalogEyebrow ?? 'Catalog'} </p>
</p> <h2 class="ui:text-3xl ui:lg:text-4xl ui:font-semibold ui:tracking-tight ui:m-0">
<h2 class="ui:text-3xl ui:lg:text-4xl ui:font-semibold ui:tracking-tight ui:m-0"> {labels?.catalogHeading ?? 'Courses your team can actually finish.'}
{labels?.catalogHeading ?? 'Courses your team can actually finish.'} </h2>
</h2>
</div>
{#if hasMoreCourses}
<div class="ui:flex ui:justify-start ui:md:justify-end">
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
class="ui:rounded-md"
disabled={disableCourseLinks}
>
{labels?.browseCoursesLabel ?? 'Browse all →'}
</Button>
</div>
{/if}
</div> </div>
{#if hasMoreCourses && courses.length > 0}
<div class="ui:flex ui:justify-start ui:md:justify-end">
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
class="ui:rounded-md"
disabled={disableCourseLinks}
>
{labels?.browseCoursesLabel ?? 'Browse all →'}
</Button>
</div>
{/if}
</div>
{#if coursesLoaded && courses.length === 0}
<OrgLandingPageCoursesEmpty {labels} />
{:else}
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-3"> <div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-3">
{#each courses as course, index (course.id)} {#each courses as course, index (course.id)}
<StudioCourseCard {course} {disableCourseLinks} {labels} /> <StudioCourseCard {course} {disableCourseLinks} {labels} />
{/each} {/each}
</div> </div>
</div> {/if}
</section> </div>
{/if} </section>
</main> </main>
<OrgLandingPageLinks {links} {labels} variant="studio" /> <OrgLandingPageLinks {links} {labels} variant="studio" />
@@ -7,6 +7,7 @@
import TechNav from './nav.svelte'; import TechNav from './nav.svelte';
import TechHero from './hero.svelte'; import TechHero from './hero.svelte';
import TechCourseCard from './course-card.svelte'; import TechCourseCard from './course-card.svelte';
import OrgLandingPageCoursesEmpty from '../courses-empty.svelte';
import { Button } from '../../../base/button'; import { Button } from '../../../base/button';
import { themeStyle } from '../theme-style'; import { themeStyle } from '../theme-style';
@@ -18,6 +19,7 @@
hero, hero,
courses, courses,
hasMoreCourses = false, hasMoreCourses = false,
coursesLoaded = true,
disableCourseLinks = false, disableCourseLinks = false,
embed, embed,
callout, callout,
@@ -38,32 +40,32 @@
{/snippet} {/snippet}
</TechHero> </TechHero>
{#if courses.length > 0} <section class="ui:py-24 ui:px-6">
<section class="ui:py-24 ui:px-6"> <div class="ui:max-w-[1280px] ui:mx-auto">
<div class="ui:max-w-[1280px] ui:mx-auto"> <div class="ui:flex ui:items-end ui:justify-between ui:flex-wrap ui:gap-4 ui:mb-14">
<div class="ui:flex ui:items-end ui:justify-between ui:flex-wrap ui:gap-4 ui:mb-14"> <div>
<div> <p class="ui:font-mono ui:text-xs ui:tracking-widest ui:uppercase ui:text-[var(--landing-accent)] ui:mb-3">
<p {labels?.catalogEyebrow ?? '// catalog'}
class="ui:font-mono ui:text-xs ui:tracking-widest ui:uppercase ui:text-[var(--landing-accent)] ui:mb-3" </p>
> <h2 class="ui:text-4xl ui:lg:text-5xl ui:font-extrabold ui:tracking-tight ui:m-0">
{labels?.catalogEyebrow ?? '// catalog'} {labels?.catalogHeading ?? 'Latest courses'}
</p> </h2>
<h2 class="ui:text-4xl ui:lg:text-5xl ui:font-extrabold ui:tracking-tight ui:m-0">
{labels?.catalogHeading ?? 'Latest courses'}
</h2>
</div>
{#if hasMoreCourses}
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
class="ui:rounded-none ui:font-mono ui:text-sm"
disabled={disableCourseLinks}
>
{labels?.browseCoursesLabel ?? 'view all →'}
</Button>
{/if}
</div> </div>
{#if hasMoreCourses && courses.length > 0}
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
class="ui:rounded-none ui:font-mono ui:text-sm"
disabled={disableCourseLinks}
>
{labels?.browseCoursesLabel ?? 'view all →'}
</Button>
{/if}
</div>
{#if coursesLoaded && courses.length === 0}
<OrgLandingPageCoursesEmpty {labels} />
{:else}
<div <div
class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:border-t ui:border-l ui:border-[var(--landing-border)]" class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:border-t ui:border-l ui:border-[var(--landing-border)]"
> >
@@ -71,9 +73,9 @@
<TechCourseCard {course} {disableCourseLinks} {labels} /> <TechCourseCard {course} {disableCourseLinks} {labels} />
{/each} {/each}
</div> </div>
</div> {/if}
</section> </div>
{/if} </section>
</main> </main>
<OrgLandingPageLinks {links} {labels} variant="tech" /> <OrgLandingPageLinks {links} {labels} variant="tech" />
@@ -7,6 +7,7 @@
import TerminalNav from './nav.svelte'; import TerminalNav from './nav.svelte';
import TerminalHero from './hero.svelte'; import TerminalHero from './hero.svelte';
import TerminalCourseCard from './course-card.svelte'; import TerminalCourseCard from './course-card.svelte';
import OrgLandingPageCoursesEmpty from '../courses-empty.svelte';
import { Button } from '../../../base/button'; import { Button } from '../../../base/button';
import { themeStyle } from '../theme-style'; import { themeStyle } from '../theme-style';
import { getCourseTypeLandingMeta } from '../landing-page-utils'; import { getCourseTypeLandingMeta } from '../landing-page-utils';
@@ -19,6 +20,7 @@
hero, hero,
courses, courses,
hasMoreCourses = false, hasMoreCourses = false,
coursesLoaded = true,
disableCourseLinks = false, disableCourseLinks = false,
embed, embed,
callout, callout,
@@ -81,34 +83,36 @@
{/snippet} {/snippet}
</TerminalHero> </TerminalHero>
{#if courses.length > 0} <section
<section class="ui:py-24 ui:px-6 ui:bg-[var(--landing-bg)]"
class="ui:py-24 ui:px-6 ui:bg-[var(--landing-bg)]" style="border-top: 1px solid var(--landing-border-soft);"
style="border-top: 1px solid var(--landing-border-soft);" >
> <div class="ui:max-w-[1120px] ui:mx-auto">
<div class="ui:max-w-[1120px] ui:mx-auto"> <p
<p class="ui:font-mono ui:text-[11px] ui:tracking-[0.12em] ui:uppercase ui:mb-3 ui:inline-flex ui:items-center ui:gap-2"
class="ui:font-mono ui:text-[11px] ui:tracking-[0.12em] ui:uppercase ui:mb-3 ui:inline-flex ui:items-center ui:gap-2" style="color: var(--landing-accent);"
style="color: var(--landing-accent);" >
> <span
<span class="ui:size-1.5 ui:rounded-full"
class="ui:size-1.5 ui:rounded-full" style="background: var(--landing-accent); box-shadow: 0 0 12px var(--landing-accent);"
style="background: var(--landing-accent); box-shadow: 0 0 12px var(--landing-accent);" ></span>
></span> {labels?.catalogEyebrow ?? 'Catalog'}
{labels?.catalogEyebrow ?? 'Catalog'} </p>
</p>
<h2 <h2
class="ui:text-4xl ui:lg:text-[40px] ui:font-semibold ui:tracking-tight ui:leading-[1.08] ui:m-0 ui:mb-3 ui:max-w-[700px] ui:text-[var(--landing-fg)]" class="ui:text-4xl ui:lg:text-[40px] ui:font-semibold ui:tracking-tight ui:leading-[1.08] ui:m-0 ui:mb-3 ui:max-w-[700px] ui:text-[var(--landing-fg)]"
> >
{labels?.catalogHeading ?? 'Courses your team can actually finish.'} {labels?.catalogHeading ?? 'Courses your team can actually finish.'}
</h2> </h2>
<p class="ui:text-[15px] ui:max-w-xl ui:m-0 ui:mb-8 ui:text-[var(--landing-fg-muted)]"> <p class="ui:text-[15px] ui:max-w-xl ui:m-0 ui:mb-8 ui:text-[var(--landing-fg-muted)]">
{labels?.catalogDescription ?? {labels?.catalogDescription ??
'Tracks built around real-world artifacts — pull requests, postmortems, and runbooks. No filler.'} 'Tracks built around real-world artifacts — pull requests, postmortems, and runbooks. No filler.'}
</p> </p>
{#if coursesLoaded && courses.length === 0}
<OrgLandingPageCoursesEmpty {labels} />
{:else}
{#if tabs.length > 1} {#if tabs.length > 1}
<div <div
class="ui:inline-flex ui:items-center ui:gap-0.5 ui:p-1 ui:rounded-full ui:mb-8 ui:bg-[var(--landing-card)]" class="ui:inline-flex ui:items-center ui:gap-0.5 ui:p-1 ui:rounded-full ui:mb-8 ui:bg-[var(--landing-card)]"
@@ -151,9 +155,9 @@
</Button> </Button>
</div> </div>
{/if} {/if}
</div> {/if}
</section> </div>
{/if} </section>
</main> </main>
<OrgLandingPageLinks {links} {labels} variant="terminal" /> <OrgLandingPageLinks {links} {labels} variant="terminal" />
@@ -178,6 +178,10 @@ export interface OrgLandingPageLabels {
calloutEyebrow?: string; 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. */ /** 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; 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 = { export type CourseCurriculumLesson = {
@@ -342,6 +346,8 @@ export interface OrgLandingPageProps {
}; };
courses: CourseItem[]; courses: CourseItem[];
hasMoreCourses?: boolean; hasMoreCourses?: boolean;
/** When false, templates suppress the empty catalog state while courses are still loading. */
coursesLoaded?: boolean;
disableCourseLinks?: boolean; disableCourseLinks?: boolean;
embed?: LandingPageEmbed; embed?: LandingPageEmbed;
callout?: LandingPageCallout; callout?: LandingPageCallout;
@@ -7,6 +7,7 @@
import VibrantNav from './nav.svelte'; import VibrantNav from './nav.svelte';
import VibrantHero from './hero.svelte'; import VibrantHero from './hero.svelte';
import VibrantCourseCard from './course-card.svelte'; import VibrantCourseCard from './course-card.svelte';
import OrgLandingPageCoursesEmpty from '../courses-empty.svelte';
import { themeStyle } from '../theme-style'; import { themeStyle } from '../theme-style';
import { Button } from '../../../base/button'; import { Button } from '../../../base/button';
import { getCourseTypeLandingMeta, defaultLessonsLabel, defaultExercisesLabel } from '../landing-page-utils'; import { getCourseTypeLandingMeta, defaultLessonsLabel, defaultExercisesLabel } from '../landing-page-utils';
@@ -19,6 +20,7 @@
hero, hero,
courses, courses,
hasMoreCourses = false, hasMoreCourses = false,
coursesLoaded = true,
disableCourseLinks = false, disableCourseLinks = false,
embed, embed,
callout, callout,
@@ -56,153 +58,161 @@
{/snippet} {/snippet}
</VibrantHero> </VibrantHero>
{#if featured} {#if coursesLoaded && courses.length === 0}
<section <section id="courses" class="ui:px-6 ui:md:px-8 ui:py-24 ui:md:py-28 ui:bg-[var(--landing-bg)]">
class="ui:bg-[var(--landing-accent)] ui:text-[var(--landing-accent-fg)] ui:px-6 ui:md:px-8 ui:py-24 ui:md:py-32"
>
<div class="ui:max-w-[1320px] ui:mx-auto"> <div class="ui:max-w-[1320px] ui:mx-auto">
<div class="ui:grid ui:grid-cols-1 ui:lg:grid-cols-[1.15fr_1fr] ui:gap-12 ui:lg:gap-20 ui:items-center"> <OrgLandingPageCoursesEmpty {labels} />
<div> </div>
{#if featuredTypeMeta} </section>
<span {:else}
class="ui:inline-flex ui:items-center ui:gap-1.5 ui:rounded-full ui:px-3 ui:py-1 ui:text-[13px] ui:font-medium ui:mb-6 ui:text-[var(--landing-accent-fg)]" {#if featured}
style="background: rgba(255,255,255,0.18);" <section
class="ui:bg-[var(--landing-accent)] ui:text-[var(--landing-accent-fg)] ui:px-6 ui:md:px-8 ui:py-24 ui:md:py-32"
>
<div class="ui:max-w-[1320px] ui:mx-auto">
<div class="ui:grid ui:grid-cols-1 ui:lg:grid-cols-[1.15fr_1fr] ui:gap-12 ui:lg:gap-20 ui:items-center">
<div>
{#if featuredTypeMeta}
<span
class="ui:inline-flex ui:items-center ui:gap-1.5 ui:rounded-full ui:px-3 ui:py-1 ui:text-[13px] ui:font-medium ui:mb-6 ui:text-[var(--landing-accent-fg)]"
style="background: rgba(255,255,255,0.18);"
>
{labels?.featuredLabel ?? 'Featured'} · {featuredTypeMeta.label}
</span>
{/if}
<h2
class="ui:text-4xl ui:md:text-5xl ui:lg:text-[56px] ui:font-medium ui:tracking-tight ui:m-0 ui:mb-6 ui:max-w-[520px]"
style="line-height: 1.05; letter-spacing: -0.025em;"
> >
{labels?.featuredLabel ?? 'Featured'} · {featuredTypeMeta.label} {featured.title}
</span> </h2>
{/if} <p class="ui:text-lg ui:m-0 ui:mb-4 ui:max-w-[480px] ui:opacity-90" style="line-height: 1.5;">
<h2 {featured.description}
class="ui:text-4xl ui:md:text-5xl ui:lg:text-[56px] ui:font-medium ui:tracking-tight ui:m-0 ui:mb-6 ui:max-w-[520px]" </p>
style="line-height: 1.05; letter-spacing: -0.025em;" <div
> class="ui:flex ui:flex-wrap ui:items-center ui:gap-x-5 ui:gap-y-2 ui:text-[14.5px] ui:mb-8 ui:opacity-85"
{featured.title}
</h2>
<p class="ui:text-lg ui:m-0 ui:mb-4 ui:max-w-[480px] ui:opacity-90" style="line-height: 1.5;">
{featured.description}
</p>
<div
class="ui:flex ui:flex-wrap ui:items-center ui:gap-x-5 ui:gap-y-2 ui:text-[14.5px] ui:mb-8 ui:opacity-85"
>
{#if featured.lessonCount}
<span>{resolvedLessonsLabel(featured.lessonCount)}</span>
{/if}
{#if featured.exerciseCount}
<span aria-hidden="true">·</span>
<span>{resolvedExercisesLabel(featured.exerciseCount)}</span>
{/if}
{#if featured.duration}
<span aria-hidden="true">·</span>
<span>{featured.duration}</span>
{/if}
</div>
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-3">
<Button
href={courseHref(featured)}
disabled={disableCourseLinks}
class="ui:rounded-md ui:px-6 ui:py-3 ui:text-base ui:font-medium ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:hover:opacity-90"
> >
{labels?.startCourseLabel ?? 'Start this course'} {#if featured.lessonCount}
</Button> <span>{resolvedLessonsLabel(featured.lessonCount)}</span>
<span class="ui:text-base ui:font-medium ui:opacity-95"> {/if}
{featured.price || formatCost(featured.cost, featured.currency)} {#if featured.exerciseCount}
</span> <span aria-hidden="true">·</span>
<span>{resolvedExercisesLabel(featured.exerciseCount)}</span>
{/if}
{#if featured.duration}
<span aria-hidden="true">·</span>
<span>{featured.duration}</span>
{/if}
</div>
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-3">
<Button
href={courseHref(featured)}
disabled={disableCourseLinks}
class="ui:rounded-md ui:px-6 ui:py-3 ui:text-base ui:font-medium ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:hover:opacity-90"
>
{labels?.startCourseLabel ?? 'Start this course'}
</Button>
<span class="ui:text-base ui:font-medium ui:opacity-95">
{featured.price || formatCost(featured.cost, featured.currency)}
</span>
</div>
</div> </div>
</div>
<div <div
class="ui:relative ui:overflow-hidden ui:rounded-3xl ui:h-[440px] ui:md:h-[480px]" class="ui:relative ui:overflow-hidden ui:rounded-3xl ui:h-[440px] ui:md:h-[480px]"
style=" style="
background: background:
radial-gradient(50% 60% at 40% 40%, rgba(255,255,255,0.55) 0%, transparent 70%), radial-gradient(50% 60% at 40% 40%, rgba(255,255,255,0.55) 0%, transparent 70%),
radial-gradient(60% 70% at 70% 65%, rgba(255,255,255,0.32) 0%, transparent 70%), radial-gradient(60% 70% at 70% 65%, rgba(255,255,255,0.32) 0%, transparent 70%),
radial-gradient(40% 50% at 25% 75%, rgba(255,235,210,0.5) 0%, transparent 70%), radial-gradient(40% 50% at 25% 75%, rgba(255,235,210,0.5) 0%, transparent 70%),
linear-gradient(135deg, #b0a8a0 0%, #d4cabd 50%, #c8c0b5 100%); linear-gradient(135deg, #b0a8a0 0%, #d4cabd 50%, #c8c0b5 100%);
" "
> >
<div <div
class="ui:absolute ui:left-1/2 ui:top-1/2 ui:w-[80%] ui:overflow-hidden ui:rounded-2xl ui:p-6" class="ui:absolute ui:left-1/2 ui:top-1/2 ui:w-[80%] ui:overflow-hidden ui:rounded-2xl ui:p-6"
style=" style="
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
background: #0c1320; background: #0c1320;
color: #fff; color: #fff;
box-shadow: 0 30px 60px -20px rgba(0,0,0,0.35); box-shadow: 0 30px 60px -20px rgba(0,0,0,0.35);
" "
> >
<p class="ui:m-0 ui:mb-1 ui:text-[16px] ui:font-semibold">Course outline</p> <p class="ui:m-0 ui:mb-1 ui:text-[16px] ui:font-semibold">Course outline</p>
<p class="ui:m-0 ui:mb-5 ui:text-[13px]" style="color: #8a93a0;"> <p class="ui:m-0 ui:mb-5 ui:text-[13px]" style="color: #8a93a0;">
Pick up where you left off — anytime. Pick up where you left off — anytime.
</p> </p>
<div class="ui:flex ui:flex-col ui:gap-2"> <div class="ui:flex ui:flex-col ui:gap-2">
{#each Array.from({ length: Math.min(featured.lessonCount ?? 4, 4) }) as _, idx (idx)} {#each Array.from({ length: Math.min(featured.lessonCount ?? 4, 4) }) as _, idx (idx)}
<div <div
class="ui:flex ui:items-center ui:gap-3 ui:rounded-lg ui:px-3.5 ui:py-3" class="ui:flex ui:items-center ui:gap-3 ui:rounded-lg ui:px-3.5 ui:py-3"
style="background: #131c2c;" style="background: #131c2c;"
>
<span
class="ui:inline-flex ui:items-center ui:justify-center ui:size-6 ui:rounded ui:text-[11px] ui:font-semibold ui:tabular-nums"
style="background: #2a3548; color: #fff;"
> >
{String(idx + 1).padStart(2, '0')} <span
</span> class="ui:inline-flex ui:items-center ui:justify-center ui:size-6 ui:rounded ui:text-[11px] ui:font-semibold ui:tabular-nums"
<span class="ui:flex-1 ui:text-[13.5px] ui:font-medium" style="color: #fff;"> style="background: #2a3548; color: #fff;"
{idx === 0 >
? 'Foundations & setup' {String(idx + 1).padStart(2, '0')}
: idx === 1 </span>
? 'Core concepts' <span class="ui:flex-1 ui:text-[13.5px] ui:font-medium" style="color: #fff;">
: idx === 2 {idx === 0
? 'Hands-on practice' ? 'Foundations & setup'
: 'Capstone project'} : idx === 1
</span> ? 'Core concepts'
<span class="ui:text-[11.5px] ui:tabular-nums" style="color: #6c7585;"> : idx === 2
{idx === 0 ? '42 min' : idx === 1 ? '1h 12m' : idx === 2 ? '58 min' : '2h 30m'} ? 'Hands-on practice'
</span> : 'Capstone project'}
</div> </span>
{/each} <span class="ui:text-[11.5px] ui:tabular-nums" style="color: #6c7585;">
{idx === 0 ? '42 min' : idx === 1 ? '1h 12m' : idx === 2 ? '58 min' : '2h 30m'}
</span>
</div>
{/each}
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </section>
</section> {/if}
{/if}
{#if remainingCourses.length > 0 || (!featured && courses.length > 0)} {#if remainingCourses.length > 0 || (!featured && courses.length > 0)}
{@const catalogCourses = featured ? remainingCourses : courses} {@const catalogCourses = featured ? remainingCourses : courses}
<section id="courses" class="ui:px-6 ui:md:px-8 ui:py-24 ui:md:py-28 ui:bg-[var(--landing-bg)]"> <section id="courses" class="ui:px-6 ui:md:px-8 ui:py-24 ui:md:py-28 ui:bg-[var(--landing-bg)]">
<div class="ui:max-w-[1320px] ui:mx-auto"> <div class="ui:max-w-[1320px] ui:mx-auto">
<div class="ui:flex ui:flex-col ui:md:flex-row ui:md:items-end ui:justify-between ui:gap-6 ui:mb-12"> <div class="ui:flex ui:flex-col ui:md:flex-row ui:md:items-end ui:justify-between ui:gap-6 ui:mb-12">
<div> <div>
<p <p
class="ui:text-[13px] ui:font-medium ui:tracking-[0.08em] ui:uppercase ui:text-[var(--landing-fg-muted)] ui:m-0 ui:mb-3" class="ui:text-[13px] ui:font-medium ui:tracking-[0.08em] ui:uppercase ui:text-[var(--landing-fg-muted)] ui:m-0 ui:mb-3"
> >
{labels?.catalogEyebrow ?? 'Catalog'} {labels?.catalogEyebrow ?? 'Catalog'}
</p> </p>
<h2 <h2
class="ui:text-4xl ui:md:text-5xl ui:font-medium ui:tracking-tight ui:m-0 ui:text-[var(--landing-fg)] ui:max-w-2xl" class="ui:text-4xl ui:md:text-5xl ui:font-medium ui:tracking-tight ui:m-0 ui:text-[var(--landing-fg)] ui:max-w-2xl"
style="line-height: 1.05; letter-spacing: -0.025em;" style="line-height: 1.05; letter-spacing: -0.025em;"
> >
{labels?.catalogHeading ?? 'More courses to take next.'} {labels?.catalogHeading ?? 'More courses to take next.'}
</h2> </h2>
</div>
{#if hasMoreCourses}
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
class="ui:rounded-md ui:px-5 ui:py-2.5 ui:font-medium ui:border-[var(--landing-border)] ui:text-[var(--landing-fg)] ui:hover:bg-[var(--landing-card-soft)]"
disabled={disableCourseLinks}
>
{labels?.browseCoursesLabel ?? 'Browse all →'}
</Button>
{/if}
</div> </div>
{#if hasMoreCourses}
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
class="ui:rounded-md ui:px-5 ui:py-2.5 ui:font-medium ui:border-[var(--landing-border)] ui:text-[var(--landing-fg)] ui:hover:bg-[var(--landing-card-soft)]"
disabled={disableCourseLinks}
>
{labels?.browseCoursesLabel ?? 'Browse all →'}
</Button>
{/if}
</div>
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-5"> <div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-5">
{#each catalogCourses as course, index (course.id)} {#each catalogCourses as course, index (course.id)}
<VibrantCourseCard {course} {disableCourseLinks} {labels} /> <VibrantCourseCard {course} {disableCourseLinks} {labels} />
{/each} {/each}
</div>
</div> </div>
</div> </section>
</section> {/if}
{/if} {/if}
</main> </main>