From 78a2b85ad3681b50700ca9eed3a71dce26ff6132 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Wed, 1 Jul 2026 10:36:53 +0100 Subject: [PATCH] feat(courses): add type, search, and pricing filters to org landing page - Add Types section to the Filter sidebar with all 4 course types - Add full-text search input centered in the hero (title + description) - Add Pricing section (Free / Paid) based on course cost - Compact hero prop across all 10 themes to halve vertical padding - Children snippet support in all hero themes for the search input - All filters are URL-driven (?types=, ?search=, ?tags=, ?pricing=) - Full-stack: validation schema, DB queries, service, API route, server load, UI --- .../src/routes/organization/organization.ts | 18 +- apps/api/src/services/organization.ts | 21 +- .../src/lib/utils/translations/da.json | 8 +- .../src/lib/utils/translations/de.json | 8 +- .../src/lib/utils/translations/en.json | 12 +- .../src/lib/utils/translations/es.json | 8 +- .../src/lib/utils/translations/fr.json | 8 +- .../src/lib/utils/translations/hi.json | 8 +- .../src/lib/utils/translations/pl.json | 8 +- .../src/lib/utils/translations/pt.json | 8 +- .../src/lib/utils/translations/ru.json | 8 +- .../src/lib/utils/translations/vi.json | 8 +- .../routes/(org-site)/courses/+page.server.ts | 16 +- .../routes/(org-site)/courses/+page.svelte | 248 ++++++++++++++---- packages/db/src/queries/course/course.ts | 43 ++- .../custom/org-landing-page/bold/hero.svelte | 14 +- .../org-landing-page/classic/hero.svelte | 12 +- .../org-landing-page/corporate/hero.svelte | 11 +- .../org-landing-page/editorial/hero.svelte | 13 +- .../org-landing-page/minimal/hero.svelte | 11 +- .../custom/org-landing-page/saas/hero.svelte | 15 +- .../org-landing-page/studio/hero.svelte | 13 +- .../custom/org-landing-page/tech/hero.svelte | 15 +- .../org-landing-page/terminal/hero.svelte | 11 +- .../org-landing-page/vibrant/hero.svelte | 11 +- packages/ui/src/output.css | 2 +- .../validation/organization/organization.ts | 3 + 27 files changed, 464 insertions(+), 97 deletions(-) diff --git a/apps/api/src/routes/organization/organization.ts b/apps/api/src/routes/organization/organization.ts index 7aac334d..0b1733c4 100644 --- a/apps/api/src/routes/organization/organization.ts +++ b/apps/api/src/routes/organization/organization.ts @@ -383,15 +383,21 @@ export const organizationRouter = new Hono() */ .get('/courses/public', zValidator('query', ZGetCoursesBySiteName), async (c) => { try { - const { siteName, tags, page, limit } = c.req.valid('query'); - const tagSlugs = tags - ?.split(',') - .map((value) => value.trim()) - .filter(Boolean); + const { siteName, tags, types, search, pricing, page, limit } = c.req.valid('query'); + const splitParam = (param: string | undefined) => + param + ?.split(',') + .map((v) => v.trim()) + .filter(Boolean); + const tagSlugs = splitParam(tags); + const courseTypes = splitParam(types); const pagination = page !== undefined || limit !== undefined ? { page, limit } : undefined; const courses = await getPublicCourses( siteName, - tagSlugs && tagSlugs.length > 0 ? tagSlugs : undefined, + tagSlugs?.length ? tagSlugs : undefined, + courseTypes?.length ? courseTypes : undefined, + search || undefined, + pricing || undefined, pagination ); diff --git a/apps/api/src/services/organization.ts b/apps/api/src/services/organization.ts index c64bf5f9..a76074c9 100644 --- a/apps/api/src/services/organization.ts +++ b/apps/api/src/services/organization.ts @@ -256,6 +256,9 @@ export async function removeAudienceMember(orgId: string, memberId: number, remo export async function getPublicCourses( siteName: string, tagSlugs?: string[], + courseTypes?: string[], + search?: string, + pricing?: 'free' | 'paid', pagination?: { page?: number; limit?: number } ) { try { @@ -286,8 +289,22 @@ export async function getPublicCourses( } } - const totalCourses = await countPublishedCoursesBySiteName(siteName, filteredCourseIds); - const courses = await getPublishedCoursesBySiteName(siteName, filteredCourseIds, limit, offset); + const totalCourses = await countPublishedCoursesBySiteName( + siteName, + filteredCourseIds, + courseTypes, + search, + pricing + ); + const courses = await getPublishedCoursesBySiteName( + siteName, + filteredCourseIds, + courseTypes, + search, + pricing, + limit, + offset + ); const tagsByCourseId = await getCourseTagsByCourseIdsForOrganization( org.id, courses.map((course) => course.id) diff --git a/apps/dashboard/src/lib/utils/translations/da.json b/apps/dashboard/src/lib/utils/translations/da.json index 5de4bfff..82eb5285 100644 --- a/apps/dashboard/src/lib/utils/translations/da.json +++ b/apps/dashboard/src/lib/utils/translations/da.json @@ -3741,7 +3741,13 @@ "filters": { "title": "Filtrer efter tags", "help": "Vælg et eller flere tags for at filtrere kurser", - "empty": "Ingen tags tilgængelige endnu" + "empty": "Ingen tags tilgængelige endnu", + "tags_title": "Tags", + "types_title": "Typer", + "search_placeholder": "Søg efter kurser...", + "pricing_title": "Prissætning", + "pricing_free": "Gratis", + "pricing_paid": "Betalt" } }, "tags_admin": { diff --git a/apps/dashboard/src/lib/utils/translations/de.json b/apps/dashboard/src/lib/utils/translations/de.json index b0e7c453..38b50088 100644 --- a/apps/dashboard/src/lib/utils/translations/de.json +++ b/apps/dashboard/src/lib/utils/translations/de.json @@ -3741,7 +3741,13 @@ "filters": { "title": "Nach Tags filtern", "help": "Wählen Sie einen oder mehrere Tags aus, um Kurse zu filtern", - "empty": "Noch keine Tags verfügbar" + "empty": "Noch keine Tags verfügbar", + "tags_title": "Schlagworte", + "types_title": "Typen", + "search_placeholder": "Kurse suchen...", + "pricing_title": "Preise", + "pricing_free": "Kostenlos", + "pricing_paid": "Bezahlt" } }, "tags_admin": { diff --git a/apps/dashboard/src/lib/utils/translations/en.json b/apps/dashboard/src/lib/utils/translations/en.json index 1512892a..4cfdbb12 100644 --- a/apps/dashboard/src/lib/utils/translations/en.json +++ b/apps/dashboard/src/lib/utils/translations/en.json @@ -1218,9 +1218,15 @@ "subtitle": "Browse all published courses", "clear_filters": "Clear Filters", "filters": { - "title": "Filter by Tags", - "help": "Select one or more tags to filter courses", - "empty": "No tags available yet" + "title": "Filter", + "help": "Filter courses by type, tags, or search by name", + "tags_title": "Tags", + "types_title": "Types", + "pricing_title": "Pricing", + "pricing_free": "Free", + "pricing_paid": "Paid", + "empty": "No tags available yet", + "search_placeholder": "Search courses..." }, "empty": { "title": "No courses found", diff --git a/apps/dashboard/src/lib/utils/translations/es.json b/apps/dashboard/src/lib/utils/translations/es.json index 9041ba7f..41222779 100644 --- a/apps/dashboard/src/lib/utils/translations/es.json +++ b/apps/dashboard/src/lib/utils/translations/es.json @@ -3741,7 +3741,13 @@ "filters": { "title": "Filtrar por etiquetas", "help": "Seleccione una o más etiquetas para filtrar cursos", - "empty": "Aún no hay etiquetas disponibles" + "empty": "Aún no hay etiquetas disponibles", + "tags_title": "Etiquetas", + "types_title": "Tipos", + "search_placeholder": "Buscar cursos...", + "pricing_title": "Precios", + "pricing_free": "Gratis", + "pricing_paid": "Pagado" } }, "tags_admin": { diff --git a/apps/dashboard/src/lib/utils/translations/fr.json b/apps/dashboard/src/lib/utils/translations/fr.json index 05dfbf9a..b32a15dd 100644 --- a/apps/dashboard/src/lib/utils/translations/fr.json +++ b/apps/dashboard/src/lib/utils/translations/fr.json @@ -3741,7 +3741,13 @@ "filters": { "title": "Filtrer par balises", "help": "Sélectionnez une ou plusieurs balises pour filtrer les cours", - "empty": "Aucun tag disponible pour l'instant" + "empty": "Aucun tag disponible pour l'instant", + "tags_title": "Balises", + "types_title": "Espèces", + "search_placeholder": "Rechercher des cours...", + "pricing_title": "Tarifs", + "pricing_free": "Gratuit", + "pricing_paid": "Payé" } }, "tags_admin": { diff --git a/apps/dashboard/src/lib/utils/translations/hi.json b/apps/dashboard/src/lib/utils/translations/hi.json index 9a9ecef3..dbe3fc8b 100644 --- a/apps/dashboard/src/lib/utils/translations/hi.json +++ b/apps/dashboard/src/lib/utils/translations/hi.json @@ -3741,7 +3741,13 @@ "filters": { "title": "टैग द्वारा फ़िल्टर करें", "help": "पाठ्यक्रमों को फ़िल्टर करने के लिए एक या अधिक टैग चुनें", - "empty": "अभी तक कोई टैग उपलब्ध नहीं है" + "empty": "अभी तक कोई टैग उपलब्ध नहीं है", + "tags_title": "टैग", + "types_title": "प्रकार", + "search_placeholder": "पाठ्यक्रम खोजें...", + "pricing_title": "मूल्य निर्धारण", + "pricing_free": "निःशुल्क", + "pricing_paid": "भुगतान किया गया" } }, "tags_admin": { diff --git a/apps/dashboard/src/lib/utils/translations/pl.json b/apps/dashboard/src/lib/utils/translations/pl.json index 5a313166..707f2a68 100644 --- a/apps/dashboard/src/lib/utils/translations/pl.json +++ b/apps/dashboard/src/lib/utils/translations/pl.json @@ -3741,7 +3741,13 @@ "filters": { "title": "Filtruj według tagów", "help": "Wybierz jeden lub więcej tagów, aby filtrować kursy", - "empty": "Brak dostępnych tagów" + "empty": "Brak dostępnych tagów", + "tags_title": "Tagi", + "types_title": "Typy", + "search_placeholder": "Wyszukaj kursy...", + "pricing_title": "Ceny", + "pricing_free": "Bezpłatny", + "pricing_paid": "Płatny" } }, "tags_admin": { diff --git a/apps/dashboard/src/lib/utils/translations/pt.json b/apps/dashboard/src/lib/utils/translations/pt.json index da48ef27..c66e8134 100644 --- a/apps/dashboard/src/lib/utils/translations/pt.json +++ b/apps/dashboard/src/lib/utils/translations/pt.json @@ -3741,7 +3741,13 @@ "filters": { "title": "Filtrar por tags", "help": "Selecione uma ou mais tags para filtrar cursos", - "empty": "Nenhuma tag disponível ainda" + "empty": "Nenhuma tag disponível ainda", + "tags_title": "Etiquetas", + "types_title": "Tipos", + "search_placeholder": "Pesquisar cursos...", + "pricing_title": "Preços", + "pricing_free": "Grátis", + "pricing_paid": "Pago" } }, "tags_admin": { diff --git a/apps/dashboard/src/lib/utils/translations/ru.json b/apps/dashboard/src/lib/utils/translations/ru.json index 1cd2780d..0e3b8c17 100644 --- a/apps/dashboard/src/lib/utils/translations/ru.json +++ b/apps/dashboard/src/lib/utils/translations/ru.json @@ -3741,7 +3741,13 @@ "filters": { "title": "Фильтровать по тегам", "help": "Выберите один или несколько тегов, чтобы отфильтровать курсы.", - "empty": "Тегов пока нет" + "empty": "Тегов пока нет", + "tags_title": "Теги", + "types_title": "Типы", + "search_placeholder": "Искать курсы...", + "pricing_title": "Цены", + "pricing_free": "Бесплатно", + "pricing_paid": "Платный" } }, "tags_admin": { diff --git a/apps/dashboard/src/lib/utils/translations/vi.json b/apps/dashboard/src/lib/utils/translations/vi.json index bde6e351..254924e0 100644 --- a/apps/dashboard/src/lib/utils/translations/vi.json +++ b/apps/dashboard/src/lib/utils/translations/vi.json @@ -3741,7 +3741,13 @@ "filters": { "title": "Lọc theo thẻ", "help": "Chọn một hoặc nhiều thẻ để lọc các khóa học", - "empty": "Chưa có thẻ nào" + "empty": "Chưa có thẻ nào", + "tags_title": "Thẻ", + "types_title": "Các loại", + "search_placeholder": "Tìm kiếm khóa học...", + "pricing_title": "Định giá", + "pricing_free": "miễn phí", + "pricing_paid": "Đã trả tiền" } }, "tags_admin": { diff --git a/apps/dashboard/src/routes/(org-site)/courses/+page.server.ts b/apps/dashboard/src/routes/(org-site)/courses/+page.server.ts index 34508674..2966e95f 100644 --- a/apps/dashboard/src/routes/(org-site)/courses/+page.server.ts +++ b/apps/dashboard/src/routes/(org-site)/courses/+page.server.ts @@ -32,6 +32,14 @@ export const load = async ({ parent, url }) => { const normalizedTags = normalizeTagsParam(url.searchParams.get('tags')); const normalizedTagsQuery = normalizedTags.length > 0 ? normalizedTags.join(',') : undefined; + const normalizedTypes = normalizeTagsParam(url.searchParams.get('types')); + const normalizedTypesQuery = normalizedTypes.length > 0 ? normalizedTypes.join(',') : undefined; + + const activeSearch = url.searchParams.get('search')?.trim() ?? ''; + + const rawPricing = url.searchParams.get('pricing'); + const activePricing = rawPricing === 'free' || rawPricing === 'paid' ? rawPricing : undefined; + const requestedPage = Number(url.searchParams.get('page')); const currentPage = Number.isInteger(requestedPage) && requestedPage > 0 ? requestedPage : 1; @@ -41,7 +49,10 @@ export const load = async ({ parent, url }) => { query: { siteName, page: String(currentPage), - ...(normalizedTagsQuery ? { tags: normalizedTagsQuery } : {}) + ...(normalizedTagsQuery ? { tags: normalizedTagsQuery } : {}), + ...(normalizedTypesQuery ? { types: normalizedTypesQuery } : {}), + ...(activeSearch ? { search: activeSearch } : {}), + ...(activePricing ? { pricing: activePricing } : {}) } }) ), @@ -89,6 +100,9 @@ export const load = async ({ parent, url }) => { courses: courseData.courses, tagGroups: tagsResult.ok ? tagsResult.body.data : [], activeTags: normalizedTags, + activeTypes: normalizedTypes, + activeSearch, + activePricing, pagination: { page: courseData.page, perPage: courseData.limit, diff --git a/apps/dashboard/src/routes/(org-site)/courses/+page.svelte b/apps/dashboard/src/routes/(org-site)/courses/+page.svelte index 49ea46d6..fd0b5cb5 100644 --- a/apps/dashboard/src/routes/(org-site)/courses/+page.svelte +++ b/apps/dashboard/src/routes/(org-site)/courses/+page.svelte @@ -10,6 +10,7 @@ import { PoweredBy } from '$features/ui'; import LibraryBigIcon from '@lucide/svelte/icons/library-big'; + import XIcon from '@lucide/svelte/icons/x'; import { normalizeLandingPageSettings, themeHeaderShellClass, @@ -20,6 +21,7 @@ import { LandingButton, OrgLandingPageFooter } from '@cio/ui/custom/org-landing-page'; import { Checkbox } from '@cio/ui/base/checkbox'; + import { Input } from '@cio/ui/base/input'; import { Empty } from '@cio/ui/custom/empty'; import { Separator } from '@cio/ui/base/separator'; import * as Pagination from '@cio/ui/base/pagination'; @@ -27,6 +29,21 @@ let { data } = $props(); let selectedTags = $derived(data.activeTags || []); + let selectedTypes = $derived(data.activeTypes || []); + let activeSearch = $derived(data.activeSearch || ''); + let activePricing = $derived<'free' | 'paid' | undefined>(data.activePricing); + let searchInput = $state(''); + + $effect(() => { + searchInput = activeSearch; + }); + + const COURSE_TYPES = [ + { value: 'SELF_PACED', label: t.get('analytics.popularTypes.types.SELF_PACED') }, + { value: 'LIVE_CLASS', label: t.get('analytics.popularTypes.types.LIVE_CLASS') }, + { value: 'COMPLIANCE', label: t.get('analytics.popularTypes.types.COMPLIANCE') }, + { value: 'PUBLIC', label: t.get('analytics.popularTypes.types.PUBLIC') } + ] as const; const landingSettings = $derived(normalizeLandingPageSettings(data.org.landingpage)); @@ -57,15 +74,22 @@ image: '' }); - async function applyTagFilters(nextTags: string[]) { + async function applyFilters(next: { + tags?: string[]; + types?: string[]; + search?: string; + pricing?: 'free' | 'paid' | null; + }) { const params = new SvelteURLSearchParams(page.url.searchParams); + const nextTags = next.tags ?? selectedTags; + const nextTypes = next.types ?? selectedTypes; + const nextSearch = next.search !== undefined ? next.search : activeSearch; + const nextPricing = 'pricing' in next ? next.pricing : activePricing; - if (nextTags.length > 0) { - params.set('tags', nextTags.join(',')); - } else { - params.delete('tags'); - } - + nextTags.length ? params.set('tags', nextTags.join(',')) : params.delete('tags'); + nextTypes.length ? params.set('types', nextTypes.join(',')) : params.delete('types'); + nextSearch ? params.set('search', nextSearch) : params.delete('search'); + nextPricing ? params.set('pricing', nextPricing) : params.delete('pricing'); params.delete('page'); const query = params.toString(); @@ -105,15 +129,47 @@ next.delete(tagSlug); } - applyTagFilters(Array.from(next)); + applyFilters({ tags: Array.from(next) }); + } + + function toggleType(typeValue: string, checked: boolean) { + const next = new SvelteSet(selectedTypes); + if (checked) { + next.add(typeValue); + } else { + next.delete(typeValue); + } + + applyFilters({ types: Array.from(next) }); } function clearFilters() { - if (selectedTags.length === 0) { + if (selectedTags.length === 0 && selectedTypes.length === 0 && !activeSearch && !activePricing) { return; } - applyTagFilters([]); + searchInput = ''; + applyFilters({ tags: [], types: [], search: '', pricing: null }); + } + + let searchDebounce: ReturnType; + function onSearchInput(value: string) { + searchInput = value; + clearTimeout(searchDebounce); + searchDebounce = setTimeout(() => applyFilters({ search: value }), 300); + } + + function onSearchKeydown(e: KeyboardEvent) { + if (e.key === 'Enter') { + clearTimeout(searchDebounce); + applyFilters({ search: searchInput }); + } + } + + function clearSearch() { + searchInput = ''; + clearTimeout(searchDebounce); + applyFilters({ search: '' }); } function isTagSelected(tagSlug: string) { @@ -141,13 +197,17 @@ tags: tagsByGroupId[group.id] ?? [] })); }); + + const hasActiveFilters = $derived( + selectedTags.length > 0 || selectedTypes.length > 0 || !!activeSearch || !!activePricing + );
{#if navInsideHero} - + {#snippet navigation()} {/snippet} + {#snippet children()} +
+ onSearchInput(e.currentTarget.value)} + onkeydown={onSearchKeydown} + placeholder={$t('public_courses.filters.search_placeholder')} + class="ui:pr-8" + /> + {#if searchInput} + + {/if} +
+ {/snippet}
{:else} - + + {#snippet children()} +
+ onSearchInput(e.currentTarget.value)} + onkeydown={onSearchKeydown} + placeholder={$t('public_courses.filters.search_placeholder')} + class="ui:pr-8" + /> + {#if searchInput} + + {/if} +
+ {/snippet} +
{/if}
- + {$t('public_courses.clear_filters')}
@@ -186,44 +291,87 @@
- {#if displayTagGroups.length === 0} -

{$t('public_courses.filters.empty')}

- {:else} -
- {#each displayTagGroups as group (group.id)} -
-
-

{group.name}

- {#if group.description} -

{group.description}

- {/if} -
- -
- {#each group.tags as tag (tag.id)} - - {/each} -
-
- {/each} +
+
+

{$t('public_courses.filters.types_title')}

+
+ {#each COURSE_TYPES as courseType (courseType.value)} + + {/each} +
- {/if} + + + +
+

{$t('public_courses.filters.pricing_title')}

+
+ {#each [{ value: 'free', label: $t('public_courses.filters.pricing_free') }, { value: 'paid', label: $t('public_courses.filters.pricing_paid') }] as option (option.value)} + + {/each} +
+
+ + {#if displayTagGroups.length > 0} + + +
+

{$t('public_courses.filters.tags_title')}

+
+ +
+ {#each displayTagGroups as group (group.id)} +
+
+

{group.name}

+ {#if group.description} +

{group.description}

+ {/if} +
+ +
+ {#each group.tags as tag (tag.id)} + + {/each} +
+
+ {/each} +
+ {/if} +
diff --git a/packages/db/src/queries/course/course.ts b/packages/db/src/queries/course/course.ts index a26f9d3e..7c3d7642 100644 --- a/packages/db/src/queries/course/course.ts +++ b/packages/db/src/queries/course/course.ts @@ -11,7 +11,7 @@ import { TNewCourseSection, TProfile } from '@db/types'; -import { and, count, desc, eq, ilike, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm'; +import { and, count, desc, eq, gt, ilike, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm'; import { ROLE } from '@cio/utils/constants'; import { db, type DbOrTxClient } from '@db/drizzle'; @@ -59,6 +59,9 @@ export interface TStudentCourse extends TBaseCourse { export const getPublishedCoursesBySiteName = async ( siteName: string, courseIds?: string[], + courseTypes?: string[], + search?: string, + pricing?: 'free' | 'paid', limit?: number, offset?: number ): Promise => { @@ -77,6 +80,21 @@ export const getPublishedCoursesBySiteName = async ( conditions.push(inArray(schema.course.id, courseIds)); } + if (courseTypes && courseTypes.length > 0) { + conditions.push(inArray(schema.course.type, courseTypes as (typeof schema.courseType.enumValues)[number][])); + } + + if (search) { + const pattern = `%${search}%`; + conditions.push(or(ilike(schema.course.title, pattern), ilike(schema.course.description, pattern))!); + } + + if (pricing === 'free') { + conditions.push(eq(schema.course.cost, 0)); + } else if (pricing === 'paid') { + conditions.push(gt(schema.course.cost, 0)); + } + const exerciseCountSql = sql`( SELECT COUNT(*)::bigint FROM ${schema.exercise} as ex @@ -127,7 +145,13 @@ export const getPublishedCoursesBySiteName = async ( } }; -export const countPublishedCoursesBySiteName = async (siteName: string, courseIds?: string[]): Promise => { +export const countPublishedCoursesBySiteName = async ( + siteName: string, + courseIds?: string[], + courseTypes?: string[], + search?: string, + pricing?: 'free' | 'paid' +): Promise => { try { if (courseIds && courseIds.length === 0) { return 0; @@ -143,6 +167,21 @@ export const countPublishedCoursesBySiteName = async (siteName: string, courseId conditions.push(inArray(schema.course.id, courseIds)); } + if (courseTypes && courseTypes.length > 0) { + conditions.push(inArray(schema.course.type, courseTypes as (typeof schema.courseType.enumValues)[number][])); + } + + if (search) { + const pattern = `%${search}%`; + conditions.push(or(ilike(schema.course.title, pattern), ilike(schema.course.description, pattern))!); + } + + if (pricing === 'free') { + conditions.push(eq(schema.course.cost, 0)); + } else if (pricing === 'paid') { + conditions.push(gt(schema.course.cost, 0)); + } + const [result] = await db .select({ total: count(schema.course.id) diff --git a/packages/ui/src/custom/org-landing-page/bold/hero.svelte b/packages/ui/src/custom/org-landing-page/bold/hero.svelte index ee189f9a..4530ede0 100644 --- a/packages/ui/src/custom/org-landing-page/bold/hero.svelte +++ b/packages/ui/src/custom/org-landing-page/bold/hero.svelte @@ -1,5 +1,6 @@ -
+

{hero.heading}

{hero.subheading}

+ {#if children} +
{@render children()}
+ {/if} {#if showActions}
-
+

{hero.heading}

{hero.subheading} + {#if children} +
{@render children()}
+ {/if} {#if showActions}