From 1f9388d419db03d442a8ac81fb6a288ba8a47df8 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 7 Jul 2026 10:21:01 +0300 Subject: [PATCH 01/30] PM-5255 - showcase in customer portal --- .../src/customer-portal.routes.tsx | 2 + .../ProjectShowcasePage.module.scss | 123 ++++++++++ .../ProjectShowcasePage.tsx | 212 ++++++++++++++++++ .../ProjectShowcasePage/index.ts | 1 + .../project-showcase.routes.tsx | 29 +++ .../lib/hooks/useFetchProjectShowcasePosts.ts | 1 + .../lib/models/ProjectShowcasePost.model.ts | 2 +- .../project-showcase-posts.service.ts | 33 ++- 8 files changed, 394 insertions(+), 9 deletions(-) create mode 100644 src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.module.scss create mode 100644 src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.tsx create mode 100644 src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/index.ts create mode 100644 src/apps/customer-portal/src/pages/project-showcase/project-showcase.routes.tsx diff --git a/src/apps/customer-portal/src/customer-portal.routes.tsx b/src/apps/customer-portal/src/customer-portal.routes.tsx index 2ee282745..45f21859a 100644 --- a/src/apps/customer-portal/src/customer-portal.routes.tsx +++ b/src/apps/customer-portal/src/customer-portal.routes.tsx @@ -15,6 +15,7 @@ import { talentSearchRouteId, } from './config/routes.config' import { customerPortalTalentSearchRoutes } from './pages/talent-search/talent-search.routes' +import { customerPortalProjectShowcaseRoutes } from './pages/project-showcase/project-showcase.routes' const CustomerPortalApp: LazyLoadedComponent = lazyLoad(() => import('./CustomerPortalApp')) @@ -31,6 +32,7 @@ export const customerPortalRoutes: ReadonlyArray = [ route: '', }, ...customerPortalTalentSearchRoutes, + ...customerPortalProjectShowcaseRoutes, ], domain: AppSubdomain.customer, element: , diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.module.scss b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.module.scss new file mode 100644 index 000000000..84540bb4a --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.module.scss @@ -0,0 +1,123 @@ +.pageContainer { + display: flex; + flex-direction: column; + gap: 24px; +} + +.filters { + display: flex; + flex-direction: column; + gap: 16px; +} + +.searchInput { + display: flex; + flex-direction: column; +} + +.searchField { + margin-top: 8px; + width: 100%; + min-height: 44px; + padding: 0 16px; + border-radius: 8px; + border: 1px solid #d9d9d9; + font-size: 1rem; +} + +.filterRow { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.results { + display: flex; + flex-direction: column; + gap: 24px; +} + +.grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; +} + +.card { + background-color: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 16px; + padding: 20px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.cardHeader { + display: flex; + justify-content: space-between; + gap: 12px; + align-items: flex-start; +} + +.cardHeader h3 { + margin: 0; + font-size: 1.05rem; +} + +.cardDate { + color: #6b7280; + font-size: 0.9rem; +} + +.cardBody { + display: flex; + flex-direction: column; + gap: 12px; +} + +.taxonomy { + display: flex; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + color: #374151; + font-size: 0.95rem; +} + +.cardFooter { + display: flex; + justify-content: space-between; + color: #6b7280; + font-size: 0.9rem; +} + +.loadMoreWrapper { + display: flex; + justify-content: center; +} + +.loadingRow, +.emptyState { + padding: 32px; + border: 1px solid #e5e7eb; + border-radius: 16px; + text-align: center; + color: #6b7280; +} + +@media (max-width: 1080px) { + .grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + .filterRow { + grid-template-columns: 1fr; + } + + .grid { + grid-template-columns: 1fr; + } +} diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.tsx new file mode 100644 index 000000000..291be8222 --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.tsx @@ -0,0 +1,212 @@ +import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react' +import { useSWRConfig } from 'swr' + +import { Button, InputMultiselect, InputMultiselectOption, LoadingSpinner } from '~/libs/ui' +import { PageWrapper } from '../../../lib/components' +import { useFetchProjectShowcasePostCategories, useFetchProjectShowcasePostIndustries, useFetchProjectShowcasePosts } from '~/apps/work/src/lib/hooks' +import { fetchProjectShowcasePosts } from '~/apps/work/src/lib/services' +import type { + FetchProjectShowcasePostsParams, + ProjectShowcasePost, + ProjectShowcasePostCategory, + ProjectShowcasePostIndustry, +} from '~/apps/work/src/lib/models' + +import styles from './ProjectShowcasePage.module.scss' + +const PAGE_SIZE = 12 + +function normalizeTaxonomyOption(item: ProjectShowcasePostCategory | ProjectShowcasePostIndustry) { + return { label: item.name, value: item.id } +} + +function createTaxonomyOptions(items: Array) { + return items.map(normalizeTaxonomyOption) +} + +const ProjectShowcasePage: FC = () => { + const [keyword, setKeyword] = useState('') + const [selectedIndustries, setSelectedIndustries] = useState([]) + const [selectedCategories, setSelectedCategories] = useState([]) + const [page, setPage] = useState(1) + const [sortBy, setSortBy] = useState('createdAt') + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc') + + const industriesResult = useFetchProjectShowcasePostIndustries() + const categoriesResult = useFetchProjectShowcasePostCategories() + + const filters = useMemo(() => ({ + projectId: '', + page, + perPage: PAGE_SIZE, + keyword: keyword.trim() || undefined, + industryId: selectedIndustries.map(option => String(option.value || '')).filter(Boolean).join(','), + categoryId: selectedCategories.map(option => String(option.value || '')).filter(Boolean).join(','), + sortBy, + sortOrder, + }), [keyword, selectedIndustries, selectedCategories, page, sortBy, sortOrder]) + + const postsResult = useFetchProjectShowcasePosts(filters) + const { mutate } = useSWRConfig() + + const industryOptions = useMemo( + () => createTaxonomyOptions(industriesResult.items), + [industriesResult.items], + ) + + const categoryOptions = useMemo( + () => createTaxonomyOptions(categoriesResult.items), + [categoriesResult.items], + ) + + const loadMoreDisabled = postsResult.posts.length >= (postsResult.metadata.total || 0) + + useEffect(() => { + setPage(1) + }, [keyword, selectedIndustries, selectedCategories]) + + const handleKeywordChange = useCallback((event: ChangeEvent) => { + setKeyword(event.target.value) + }, []) + + const handleIndustriesChange = useCallback((event: ChangeEvent) => { + const value = event.target.value as unknown + setSelectedIndustries(Array.isArray(value) ? value as InputMultiselectOption[] : []) + }, []) + + const handleCategoriesChange = useCallback((event: ChangeEvent) => { + const value = event.target.value as unknown + setSelectedCategories(Array.isArray(value) ? value as InputMultiselectOption[] : []) + }, []) + + const handleLoadMore = useCallback(async () => { + if (loadMoreDisabled) { + return + } + + const nextPage = page + 1 + setPage(nextPage) + + const response = await fetchProjectShowcasePosts({ ...filters, page: nextPage }) + await mutate([ + 'work/project-showcase-posts', + filters.projectId || '', + filters.keyword || '', + '', + filters.industryId || '', + filters.categoryId || '', + String(nextPage), + String(filters.perPage || PAGE_SIZE), + filters.sortBy || '', + filters.sortOrder || '', + ], { + metadata: response.metadata, + posts: [...postsResult.posts, ...response.posts], + }, false) + }, [filters, loadMoreDisabled, mutate, page, postsResult.posts]) + + useEffect(() => { + console.log('here', postsResult.posts) + }, [postsResult.posts]); + + const content = useMemo(() => { + if (!postsResult.posts.length && !postsResult.isLoading) { + return ( +
+ No showcase posts match your search. +
+ ) + } + + return ( + <> +
+ {postsResult.posts.map(post => ( +
+
+

{post.title || 'Untitled'}

+ {new Date(post.createdAt).toLocaleDateString()} +
+
+
+ Industry: + {post.industries.map(item => item.name).join(', ') || '—'} +
+
+ Category: + {post.categories.map(item => item.name).join(', ') || '—'} +
+
+
+ {post.createdByHandle || 'Unknown author'} +
+
+ ))} +
+ {postsResult.isLoading && ( +
+ +
+ )} + + ) + }, [postsResult.isLoading, postsResult.posts]) + + return ( + +
+
+
+ + +
+ +
+ + +
+
+ +
+ {content} + +
+
+
+
+
+ ) +} + +export default ProjectShowcasePage diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/index.ts b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/index.ts new file mode 100644 index 000000000..f942228cf --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/index.ts @@ -0,0 +1 @@ +export { default as ProjectShowcasePage } from './ProjectShowcasePage' diff --git a/src/apps/customer-portal/src/pages/project-showcase/project-showcase.routes.tsx b/src/apps/customer-portal/src/pages/project-showcase/project-showcase.routes.tsx new file mode 100644 index 000000000..f94854798 --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/project-showcase.routes.tsx @@ -0,0 +1,29 @@ +import { getRoutesContainer, lazyLoad, LazyLoadedComponent } from '~/libs/core' + +const ProjectShowcasePage: LazyLoadedComponent = lazyLoad( + () => import('./ProjectShowcasePage'), + 'ProjectShowcasePage', +) + +export const customerPortalProjectShowcaseRoutes = [ + { + children: [ + { + authRequired: true, + element: , + id: 'project-showcase-page', + route: '', + }, + ], + element: getRoutesContainer([ + { + authRequired: true, + element: , + id: 'project-showcase-page', + route: '', + }, + ]), + id: 'project-showcase', + route: 'showcase', + }, +] diff --git a/src/apps/work/src/lib/hooks/useFetchProjectShowcasePosts.ts b/src/apps/work/src/lib/hooks/useFetchProjectShowcasePosts.ts index a6b7cc0c7..bbc7c80d9 100644 --- a/src/apps/work/src/lib/hooks/useFetchProjectShowcasePosts.ts +++ b/src/apps/work/src/lib/hooks/useFetchProjectShowcasePosts.ts @@ -58,6 +58,7 @@ export function useFetchProjectShowcasePosts( shouldRetryOnError: true, }, ) +console.log('here2', data?.posts); return { error, diff --git a/src/apps/work/src/lib/models/ProjectShowcasePost.model.ts b/src/apps/work/src/lib/models/ProjectShowcasePost.model.ts index 03ce098c6..08c4c6182 100644 --- a/src/apps/work/src/lib/models/ProjectShowcasePost.model.ts +++ b/src/apps/work/src/lib/models/ProjectShowcasePost.model.ts @@ -36,7 +36,7 @@ export interface ProjectShowcasePostFilters { } export interface FetchProjectShowcasePostsParams extends ProjectShowcasePostFilters { - projectId: string + projectId?: string page?: number perPage?: number sortBy?: string diff --git a/src/apps/work/src/lib/services/project-showcase-posts.service.ts b/src/apps/work/src/lib/services/project-showcase-posts.service.ts index b496e035c..213fe73f7 100644 --- a/src/apps/work/src/lib/services/project-showcase-posts.service.ts +++ b/src/apps/work/src/lib/services/project-showcase-posts.service.ts @@ -48,6 +48,24 @@ function buildProjectShowcasePostsSortValue( return `${normalizedSortBy} ${safeSortOrder}` } +function appendArrayQueryParam(query: URLSearchParams, name: string, value: string | undefined): void { + if (!value) { + return + } + + const normalized = value.trim() + if (!normalized) { + return + } + + const values = normalized + .split(',') + .map(item => item.trim()) + .filter(Boolean) + + values.forEach(item => query.append(name, item)) +} + function buildProjectShowcasePostsUrl( params: FetchProjectShowcasePostsParams, ): string { @@ -66,13 +84,8 @@ function buildProjectShowcasePostsUrl( query.set('status', params.status.trim()) } - if (params.industryId?.trim()) { - query.set('industryId', params.industryId.trim()) - } - - if (params.categoryId?.trim()) { - query.set('categoryId', params.categoryId.trim()) - } + appendArrayQueryParam(query, 'industryId', params.industryId) + appendArrayQueryParam(query, 'categoryId', params.categoryId) if (params.sortBy?.trim() && params.sortOrder) { query.set( @@ -81,7 +94,11 @@ function buildProjectShowcasePostsUrl( ) } - return `${PROJECTS_API_URL}/${encodeURIComponent(params.projectId)}/posts?${query.toString()}` + const urlBase = params.projectId?.trim() + ? `${PROJECTS_API_URL}/${encodeURIComponent(params.projectId)}/posts` + : `${PROJECTS_API_URL}/posts` + + return `${urlBase}?${query.toString()}` } export async function fetchProjectShowcasePosts( From 58bdc0f1841f7a9808dc02baba1d4b17294fc911 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 8 Jul 2026 09:50:25 +0300 Subject: [PATCH 02/30] PM-5255 - showcase in customer portal --- .../src/config/routes.config.ts | 1 + .../components/NavTabs/config/tabs-config.ts | 4 + .../PageWrapper/PageWrapper.module.scss | 138 ++++++ .../components/PageWrapper/PageWrapper.tsx | 116 +++-- .../ProjectShowcasePage.module.scss | 102 ++++- .../ProjectShowcasePage.tsx | 172 +++++-- .../TalentSearchPage.module.scss | 177 +------- .../TalentSearchPage/TalentSearchPage.tsx | 419 +++++++++--------- .../lib/hooks/useFetchProjectShowcasePosts.ts | 17 +- .../input-select/InputSelect.module.scss | 7 +- .../form-input/input-wrapper/InputWrapper.tsx | 2 +- 11 files changed, 674 insertions(+), 481 deletions(-) diff --git a/src/apps/customer-portal/src/config/routes.config.ts b/src/apps/customer-portal/src/config/routes.config.ts index 316357915..f209c71a6 100644 --- a/src/apps/customer-portal/src/config/routes.config.ts +++ b/src/apps/customer-portal/src/config/routes.config.ts @@ -9,3 +9,4 @@ export const rootRoute: string : `/${AppSubdomain.customer}` export const talentSearchRouteId = 'talent-search' +export const showcaseSearchRouteId = 'showcase' diff --git a/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts b/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts index c76f23ed4..dd0891779 100644 --- a/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts +++ b/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts @@ -2,6 +2,7 @@ import _ from 'lodash' import { TabsNavItem } from '~/libs/ui' import { + showcaseSearchRouteId, talentSearchRouteId, } from '~/apps/customer-portal/src/config/routes.config' @@ -11,6 +12,9 @@ export function getTabsConfig(userRoles: string[], isAnonymous: boolean, isUnpri ...(!isUnprivilegedUser ? [{ id: talentSearchRouteId, title: 'Talent Search', + }, { + id: showcaseSearchRouteId, + title: 'Showcase', }] : []), ] diff --git a/src/apps/customer-portal/src/lib/components/PageWrapper/PageWrapper.module.scss b/src/apps/customer-portal/src/lib/components/PageWrapper/PageWrapper.module.scss index 20f85d04f..56c0fc6cc 100644 --- a/src/apps/customer-portal/src/lib/components/PageWrapper/PageWrapper.module.scss +++ b/src/apps/customer-portal/src/lib/components/PageWrapper/PageWrapper.module.scss @@ -5,6 +5,18 @@ flex-direction: column; } +:global([class*='ContentLayout-module_content-outer']) { + margin: 0 auto 0 !important; +} + +:global([class*='ContentLayout-module_content__']) { + padding-bottom: 0 !important; +} + +:global([class*='BreadCrumb-module_breadcrumb']) { + display: none; +} + .blockHeader { display: flex; align-items: center; @@ -39,3 +51,129 @@ margin-left: $sp-2; margin-bottom: 6px; } + +.pageArea { + position: relative; + @include substractPagePaddings; + background: $black-5; +} + +.pageHero { + width: 100%; + height: 280px; + background-image: url('../../../lib/assets/talent-search-header.png'); + background-position: center; + background-repeat: no-repeat; + background-size: cover; +} + +.pageBody { + display: grid; + grid-template-columns: 443px 1fr; + gap: 24px; + margin-top: -232px; + padding: $sp-2 $sp-8 $sp-10 $sp-8; + position: relative; + z-index: 1; + font-family: $font-roboto; + + @include ltemd { + grid-template-columns: 1fr; + margin-top: -232px; + padding: $sp-3 $sp-3 0; + } +} + +.sidebar { + display: flex; + flex-direction: column; + gap: 0; + + @include ltemd { + margin-left: 0; + position: relative; + z-index: 2; + } +} + +.panel { + background: $tc-white; + border: 0; + border-radius: 16px; + padding: 20px; + display: flex; + flex-direction: column; + gap: $sp-2; +} + +.sidebar .panel + .panel { + border-top-left-radius: 0; + border-top-right-radius: 0; + border-top: 0; + padding-top: 14px; +} + +.panelTitle { + color: $black-100; + font-size: 16px; + line-height: 24px; + font-weight: 500; + margin: 0; +} + +.resultsPanel { + background: transparent; + border: 0; + border-radius: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0; + overflow: visible; + min-height: 620px; +} + +.resultsPanelEmpty { + background: transparent; + border: 0; + border-radius: 0; +} + + +.emptyState { + min-height: 470px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: $sp-6; + margin-top: 20vh; + text-align: center; + color: $black-60; + + h4 { + margin: 0 0 $sp-2; + color: $black-100; + } + + p { + margin: 0 0 $sp-1; + max-width: 500px; + } +} + +.emptyStateTitle { + color: $black-100; + font-size: 24px; + line-height: 32px; + font-weight: 700; + margin: 0 0 $sp-2; +} + +.emptyStateDescription { + color: $black-100; + font-size: 16px; + line-height: 24px; + font-weight: 400; + margin: 0; +} diff --git a/src/apps/customer-portal/src/lib/components/PageWrapper/PageWrapper.tsx b/src/apps/customer-portal/src/lib/components/PageWrapper/PageWrapper.tsx index d039f5caf..43c3347e3 100644 --- a/src/apps/customer-portal/src/lib/components/PageWrapper/PageWrapper.tsx +++ b/src/apps/customer-portal/src/lib/components/PageWrapper/PageWrapper.tsx @@ -1,7 +1,7 @@ /** * Page Wrapper. */ -import { FC, PropsWithChildren, ReactNode } from 'react' +import { FC, PropsWithChildren, ReactNode, useMemo } from 'react' import { Link } from 'react-router-dom' import classNames from 'classnames' @@ -20,55 +20,89 @@ interface Props { backUrl?: string backAction?: () => void titleUrl?: string | 'emptyLink' - rightHeader?: ReactNode, + rightHeader?: ReactNode breadCrumb: BreadCrumbData[] + introText: string + shouldShowIntroState?: boolean + sidebar: ReactNode | ReactNode[] } -export const PageWrapper: FC> = props => ( -
- - {props.pageTitle} -
-
- {props.backUrl && ( - - - - )} - {props.backAction && ( - - )} -
- -

- {props.pageTitle} -

-
- {props.titleUrl && props.titleUrl !== 'emptyLink' && ( - - - +export const PageWrapper: FC> = props => { + const sidebarPanels: ReactNode[] = useMemo(() => props.sidebar && !Array.isArray(props.sidebar) ? [props.sidebar] : props.sidebar as ReactNode[], [props.sidebar]) + + return ( +
+ + {props.pageTitle} +
+
+ {props.backUrl && ( + + + )} - {props.titleUrl && props.titleUrl === 'emptyLink' && ( - )} +
+ +

+ {props.pageTitle} +

+
+ {props.titleUrl && props.titleUrl !== 'emptyLink' && ( + + + + )} + {props.titleUrl && props.titleUrl === 'emptyLink' && ( + + )} +
+ + {props.rightHeader}
- {props.rightHeader} -
+
+
+
+ +
+ {props.shouldShowIntroState && ( +
+

+ {props.introText} +

+
+ )} - {props.children} -
-) + {!props.shouldShowIntroState && ( +
{props.children}
+ )} + +
+
+
+ ) +} export default PageWrapper diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.module.scss b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.module.scss index 84540bb4a..f4465aacc 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.module.scss +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.module.scss @@ -1,7 +1,9 @@ +@import '@libs/ui/styles/includes'; + .pageContainer { display: flex; flex-direction: column; - gap: 24px; + gap: 20px; } .filters { @@ -15,16 +17,6 @@ flex-direction: column; } -.searchField { - margin-top: 8px; - width: 100%; - min-height: 44px; - padding: 0 16px; - border-radius: 8px; - border: 1px solid #d9d9d9; - font-size: 1rem; -} - .filterRow { display: grid; grid-template-columns: 1fr 1fr; @@ -97,7 +89,6 @@ justify-content: center; } -.loadingRow, .emptyState { padding: 32px; border: 1px solid #e5e7eb; @@ -106,6 +97,93 @@ color: #6b7280; } +.loadingRow { + border-radius: 16px; + text-align: center; + color: #6b7280; + position: relative; +} + +.sidebarTitle { + color: $black-100; + font-feature-settings: 'liga' off, 'clig' off; + + font-family: $font-roboto; + font-size: 16px; + font-style: normal; + font-weight: 500; + line-height: 24px; /* 150% */ + + padding: $sp-4 0; + + + hr { + border: 0 none; + margin-top: -8px; + border-bottom: $black-20 1px solid; + margin-bottom: 18px; + } + + &.filterTitle { + padding-top: 24px; + padding-bottom: 8px; + + ~ :global(.input-wrapper) { + margin-bottom: 8px; + } + } +} + +.input { + margin: 0; +} + +.searchInputWrapper { + position: relative; + width: 100%; + + :global(.input-el) { + margin-bottom: 0; + } +} + +.searchIcon { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + color: $turq-160; + width: 16px; + height: 16px; + pointer-events: none; +} + +.topbarContainer { + display: flex; + align-items: center; + color: $tc-white; +} + +.resultsMeta { + strong { + font-weight: 700; + } +} + +.sorting { + display: flex; + gap: 12px; + align-items: center; + margin-left: auto; + + :global(.input-el) { + margin: 0; + } +} + +.sortSelect { + min-width: 180px; +} + @media (max-width: 1080px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.tsx index 291be8222..1830c8072 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.tsx +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.tsx @@ -1,7 +1,7 @@ import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react' import { useSWRConfig } from 'swr' -import { Button, InputMultiselect, InputMultiselectOption, LoadingSpinner } from '~/libs/ui' +import { Button, IconOutline, InputMultiselect, InputMultiselectOption, InputSelect, InputText, LoadingSpinner } from '~/libs/ui' import { PageWrapper } from '../../../lib/components' import { useFetchProjectShowcasePostCategories, useFetchProjectShowcasePostIndustries, useFetchProjectShowcasePosts } from '~/apps/work/src/lib/hooks' import { fetchProjectShowcasePosts } from '~/apps/work/src/lib/services' @@ -13,9 +13,15 @@ import type { } from '~/apps/work/src/lib/models' import styles from './ProjectShowcasePage.module.scss' +import classNames from 'classnames' const PAGE_SIZE = 12 +const sortOptions = [ + { label: 'Newest', value: 'desc' }, + { label: 'Oldest', value: 'asc' }, +] + function normalizeTaxonomyOption(item: ProjectShowcasePostCategory | ProjectShowcasePostIndustry) { return { label: item.name, value: item.id } } @@ -29,7 +35,7 @@ const ProjectShowcasePage: FC = () => { const [selectedIndustries, setSelectedIndustries] = useState([]) const [selectedCategories, setSelectedCategories] = useState([]) const [page, setPage] = useState(1) - const [sortBy, setSortBy] = useState('createdAt') + const sortBy = 'createdAt' const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc') const industriesResult = useFetchProjectShowcasePostIndustries() @@ -44,7 +50,7 @@ const ProjectShowcasePage: FC = () => { categoryId: selectedCategories.map(option => String(option.value || '')).filter(Boolean).join(','), sortBy, sortOrder, - }), [keyword, selectedIndustries, selectedCategories, page, sortBy, sortOrder]) + }), [keyword, selectedIndustries, selectedCategories, page, sortOrder]) const postsResult = useFetchProjectShowcasePosts(filters) const { mutate } = useSWRConfig() @@ -63,7 +69,7 @@ const ProjectShowcasePage: FC = () => { useEffect(() => { setPage(1) - }, [keyword, selectedIndustries, selectedCategories]) + }, [keyword, selectedIndustries, selectedCategories, sortOrder]) const handleKeywordChange = useCallback((event: ChangeEvent) => { setKeyword(event.target.value) @@ -79,6 +85,10 @@ const ProjectShowcasePage: FC = () => { setSelectedCategories(Array.isArray(value) ? value as InputMultiselectOption[] : []) }, []) + const handleSortOrderChange = useCallback((event: ChangeEvent) => { + setSortOrder(event.target.value as 'asc' | 'desc') + }, []) + const handleLoadMore = useCallback(async () => { if (loadMoreDisabled) { return @@ -105,10 +115,6 @@ const ProjectShowcasePage: FC = () => { }, false) }, [filters, loadMoreDisabled, mutate, page, postsResult.posts]) - useEffect(() => { - console.log('here', postsResult.posts) - }, [postsResult.posts]); - const content = useMemo(() => { if (!postsResult.posts.length && !postsResult.isLoading) { return ( @@ -154,55 +160,127 @@ const ProjectShowcasePage: FC = () => { return ( -
-
-
- - +
+ Search Showcases +
+
+ +
+ + +
+
+ Filter +
+ + + + )} + > +
+
+
+ {postsResult.posts.length > 0 && !postsResult.isLoading && ( + + {postsResult.metadata.total} total + {' '} + showcases + + )}
-
- + Sort by: + - -
-
+ +
- {content} - -
-
+ {!postsResult.posts.length && !postsResult.isLoading && ( +
+ No showcase posts match your search. +
+ )} + + {postsResult.posts.length > 0 && ( +
+ {postsResult.posts.map(post => ( +
+
+

{post.title || 'Untitled'}

+ {new Date(post.createdAt).toLocaleDateString()} +
+
+
+ Industry: + {post.industries.map(item => item.name).join(', ') || '—'} +
+
+ Category: + {post.categories.map(item => item.name).join(', ') || '—'} +
+
+
+ {post.createdByHandle || 'Unknown author'} +
+
+ ))} +
+ )} + + {postsResult.isLoading && ( +
+ +
+ )} + + {!loadMoreDisabled && postsResult.posts.length > 0 && ( +
+
+ )}
diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.module.scss b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.module.scss index 22116c969..b56a3d531 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.module.scss +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.module.scss @@ -5,109 +5,6 @@ flex-direction: column; } -:global([class*='ContentLayout-module_content-outer']) { - margin: 0 auto 0 !important; -} - -:global([class*='ContentLayout-module_content__']) { - padding-bottom: 0 !important; -} - -:global([class*='BreadCrumb-module_breadcrumb']) { - display: none; -} - -.pageArea { - position: relative; - @include substractPagePaddings; - background: $black-5; -} - -.pageHero { - width: 100%; - height: 280px; - background-image: url('../../../lib/assets/talent-search-header.png'); - background-position: center; - background-repeat: no-repeat; - background-size: cover; -} - -.pageBody { - display: grid; - grid-template-columns: 443px 1fr; - gap: 24px; - margin-top: -232px; - padding: $sp-2 $sp-8 $sp-10 $sp-8; - position: relative; - z-index: 1; - font-family: $font-roboto; - - @include ltemd { - grid-template-columns: 1fr; - margin-top: -232px; - padding: $sp-3 $sp-3 0; - } -} - -.sidebar { - display: flex; - flex-direction: column; - gap: 0; - - @include ltemd { - margin-left: 0; - position: relative; - z-index: 2; - } -} - -.panel { - background: $tc-white; - border: 0; - border-radius: 16px; - padding: 20px; - display: flex; - flex-direction: column; - gap: $sp-2; -} - -.sidebar .panel + .panel { - border-top-left-radius: 0; - border-top-right-radius: 0; - border-top: 0; - padding-top: 14px; -} - -.panelTitle { - color: $black-100; - font-size: 16px; - line-height: 24px; - font-weight: 500; - margin: 0; -} - -.searchTabs { - display: flex; - gap: 40px; - border-bottom: 0; - padding-bottom: 0; - border-bottom: 1px solid $black-20; -} - -.tabButton { - border: 0; - background: transparent; - padding: 12px 0; - color: $black-60; - font-family: $font-roboto; - font-size: 16px; - line-height: 24px; - font-weight: 500; -} - -.activeTab { - color: $black-100; -} .jobDescriptionField { margin-top: 0; @@ -256,22 +153,34 @@ } } -.resultsPanel { - background: transparent; - border: 0; - border-radius: 0; - padding: 0; +.emptyState { + min-height: 470px; display: flex; flex-direction: column; - gap: 0; - overflow: visible; - min-height: 620px; + justify-content: center; + align-items: center; + padding: $sp-6; + margin-top: 20vh; + text-align: center; + color: $black-60; + + h4 { + margin: 0 0 $sp-2; + color: $black-100; + } + + p { + margin: 0 0 $sp-1; + max-width: 500px; + } } -.resultsPanelEmpty { - background: transparent; - border: 0; - border-radius: 0; +.emptyStateTitle { + color: $black-100; + font-size: 24px; + line-height: 32px; + font-weight: 700; + margin: 0 0 $sp-2; } .resultsContent { @@ -344,44 +253,6 @@ font-weight: 700; } -.emptyState { - min-height: 470px; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - padding: $sp-6; - margin-top: 20vh; - text-align: center; - color: $black-60; - - h4 { - margin: 0 0 $sp-2; - color: $black-100; - } - - p { - margin: 0 0 $sp-1; - max-width: 500px; - } -} - -.emptyStateTitle { - color: $black-100; - font-size: 24px; - line-height: 32px; - font-weight: 700; - margin: 0 0 $sp-2; -} - -.emptyStateDescription { - color: $black-100; - font-size: 16px; - line-height: 24px; - font-weight: 400; - margin: 0; -} - .emptyStateSearchText { font-weight: 700; } diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index 0fb8ed9a2..ca0acd4b9 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -394,234 +394,211 @@ export const TalentSearchPage: FC = () => { pageTitle='' className={classNames(styles.container)} breadCrumb={[]} - > -
-
-
-
- {errorMessage && ( -

{errorMessage}

- )} - - -
-
- ) => { - const value = (event.target.value || []) as InputMultiselectOption[] - setSelectedSkills(value) - if (value.length === 0) { - setLastSearchedDescription('') - } - }} - /> -
-
- ) => { - const value = (event.target.value || []) as InputMultiselectOption[] - setSelectedCountries(value) - }} - placeholder='Select country' - /> -
-
- ) => { - const value = (event.target.value || []) as InputMultiselectOption[] - setSelectedPreferredRoles(value) - }} - placeholder='Select preferred roles' - /> -
- - - + +
+ + +
+ + ]} + introText='Paste a job description to AI-extract skills, or enter skills manually to find talents' + shouldShowIntroState={shouldShowIntroState} + > +
+ {!isSearchingMembers && ( +
+

+ We have found  + + {`${foundMembersCount} members`} + +  that match your search. +

+
+ )} + {isSearchingMembers && ( +
+

Searching talent...

+
+ )} + {!isSearchingMembers && displayedResults.length === 0 && ( +
+

No matching talent found

+

Try changing filters or using a different job description.

+
+ )} + {!isSearchingMembers && displayedResults.length > 0 && ( + <> +
+ {displayedResultsWithCountryName.map(talent => ( + - - 100% Profile complete - -
- + ))} +
+ {hasMoreResults && ( +
-
- - -
- {shouldShowIntroState && ( -
-

- Paste a job description to AI-extract skills, or enter skills manually - to find talents -

-
- )} - - {!shouldShowIntroState && ( -
- {!isSearchingMembers && ( -
-

- We have found  - - {`${foundMembersCount} members`} - -  that match your search. -

-
- )} - {isSearchingMembers && ( -
-

Searching talent...

-
- )} - {!isSearchingMembers && displayedResults.length === 0 && ( -
-

No matching talent found

-

Try changing filters or using a different job description.

-
- )} - {!isSearchingMembers && displayedResults.length > 0 && ( - <> -
- {displayedResultsWithCountryName.map(talent => ( - - ))} -
- {hasMoreResults && ( -
- -
- )} - - )} -
)} -
-
+ + )}
) diff --git a/src/apps/work/src/lib/hooks/useFetchProjectShowcasePosts.ts b/src/apps/work/src/lib/hooks/useFetchProjectShowcasePosts.ts index bbc7c80d9..770543066 100644 --- a/src/apps/work/src/lib/hooks/useFetchProjectShowcasePosts.ts +++ b/src/apps/work/src/lib/hooks/useFetchProjectShowcasePosts.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react' +import { useEffect, useMemo, useState } from 'react' import useSWR, { SWRResponse } from 'swr' import { @@ -45,6 +45,8 @@ export function useFetchProjectShowcasePosts( [requestParams], ) + const [previousResponse, setPreviousResponse] = useState(undefined) + const { data, error, @@ -58,20 +60,27 @@ export function useFetchProjectShowcasePosts( shouldRetryOnError: true, }, ) -console.log('here2', data?.posts); + + useEffect(() => { + if (data) { + setPreviousResponse(data) + } + }, [data]) + + const response = data ?? previousResponse return { error, isLoading: !data && !error, isValidating, - metadata: data?.metadata ?? { + metadata: response?.metadata ?? { page: requestParams.page || 1, perPage: requestParams.perPage || 10, total: 0, totalPages: 0, }, mutate, - posts: data?.posts || [], + posts: response?.posts || [], } } diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-select/InputSelect.module.scss b/src/libs/ui/lib/components/form/form-groups/form-input/input-select/InputSelect.module.scss index 16505e78b..e84d6d7c7 100644 --- a/src/libs/ui/lib/components/form/form-groups/form-input/input-select/InputSelect.module.scss +++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-select/InputSelect.module.scss @@ -3,10 +3,13 @@ .selected { display: flex; align-items: center; - margin-top: $sp-1; cursor: pointer; color: $black-100; + * + & { + margin-top: $sp-1; + } + &-icon { margin-left: auto; padding: $border-xs 0; @@ -64,4 +67,4 @@ white-space: break-spaces; word-break: break-all; text-align: left; -} \ No newline at end of file +} diff --git a/src/libs/ui/lib/components/form/form-groups/form-input/input-wrapper/InputWrapper.tsx b/src/libs/ui/lib/components/form/form-groups/form-input/input-wrapper/InputWrapper.tsx index 41f260bde..5b570fcee 100644 --- a/src/libs/ui/lib/components/form/form-groups/form-input/input-wrapper/InputWrapper.tsx +++ b/src/libs/ui/lib/components/form/form-groups/form-input/input-wrapper/InputWrapper.tsx @@ -82,7 +82,7 @@ const InputWrapper = forwardRef((props: Input role='presentation' > { - props.type !== 'checkbox' && ( + props.type !== 'checkbox' && (props.label || props.hint) && (
{props.label} From e7f8c14a621b4cc65abe50e5bd38406cc4c4f0c9 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 8 Jul 2026 18:10:27 +0300 Subject: [PATCH 03/30] PM-5255 - showcase customer portal search --- .../ProjectShowcaseCard.module.scss | 96 +++++++++++++++++++ .../ProjectShowcaseCard.tsx | 49 ++++++++++ .../ProjectShowcaseCard/index.ts | 1 + .../ProjectShowcasePage.module.scss | 75 ++++----------- .../ProjectShowcasePage.tsx | 39 ++++---- .../project-showcase.routes.tsx | 8 ++ .../project-showcase-posts.service.ts | 1 + 7 files changed, 196 insertions(+), 73 deletions(-) create mode 100644 src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.module.scss create mode 100644 src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.tsx create mode 100644 src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/index.ts diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.module.scss b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.module.scss new file mode 100644 index 000000000..0a70488af --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.module.scss @@ -0,0 +1,96 @@ +@import '@libs/ui/styles/includes'; + +.wrap { + background-color: $tc-white; + border: 1px solid $black-10; + border-radius: 16px; + padding: $sp-6; + display: flex; + flex-direction: column; + gap: 12px; + color: $black-100; +} + +.tags { + display: flex; + gap: 8px; + flex-wrap: wrap; + + .tag { + display: flex; + align-items: center; + gap: 4px; + padding: 3px $sp-1; + border-radius: 2px; + + font-size: 11px; + font-weight: 500; + line-height: 10px; + + background: $black-5; + color: $black-80; + + font-family: $font-roboto; + } +} + +.title { + color: $black-100; + font-feature-settings: 'liga' off, 'clig' off; + font-size: 22px; + font-style: normal; + font-weight: 800; + line-height: 28px; /* 127.273% */ + text-transform: none; + + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + height: 100%; + max-height: calc(28px * 2); +} + +.cardBody { + display: flex; + flex-direction: column; + gap: 12px; +} + +.taxonomy { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; + font-size: 14px; + font-weight: 500; + line-height: 22px; + + .industryIcon { + color: $turq-160; + } +} + +.content { + font-feature-settings: 'liga' off, 'clig' off; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + height: 100%; + max-height: calc(22px * 3); + + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 22px; /* 157.143% */ +} + +.cardFooter { + display: flex; + justify-content: space-between; + color: #6b7280; + font-size: 0.9rem; +} diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.tsx new file mode 100644 index 000000000..55061d683 --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.tsx @@ -0,0 +1,49 @@ +import { FC } from 'react' +import classNames from 'classnames' + +import { ProjectShowcasePost } from '~/apps/work/src/lib' +import { IconOutline, LinkButton } from '~/libs/ui' + +import styles from './ProjectShowcaseCard.module.scss' +import { getPostRoute } from '../project-showcase.routes' + +interface ProjectShowcaseCardProps { + post: ProjectShowcasePost +} + +const ProjectShowcaseCard: FC = props => { + return ( +
+
+ {props.post.categories.map(category => ( + + {category.name} + + ))} +
+ +

+ {props.post.title || 'Untitled'} +

+
+ + {props.post.industries.map(item => item.name).join(', ') || '—'} +
+ +
+ {props.post.content} +
+ +
+ +
+
+ ) +} + +export default ProjectShowcaseCard diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/index.ts b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/index.ts new file mode 100644 index 000000000..99a10288a --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/index.ts @@ -0,0 +1 @@ +export { default as ProjectShowcaseCard } from './ProjectShowcaseCard' diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.module.scss b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.module.scss index f4465aacc..7df019483 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.module.scss +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.module.scss @@ -31,58 +31,11 @@ .grid { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 16px; -} - -.card { - background-color: #ffffff; - border: 1px solid #e5e7eb; - border-radius: 16px; - padding: 20px; - display: flex; - flex-direction: column; - gap: 12px; -} - -.cardHeader { - display: flex; - justify-content: space-between; - gap: 12px; - align-items: flex-start; -} - -.cardHeader h3 { - margin: 0; - font-size: 1.05rem; -} - -.cardDate { - color: #6b7280; - font-size: 0.9rem; -} - -.cardBody { - display: flex; - flex-direction: column; - gap: 12px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 20px; } -.taxonomy { - display: flex; - justify-content: space-between; - gap: 16px; - flex-wrap: wrap; - color: #374151; - font-size: 0.95rem; -} -.cardFooter { - display: flex; - justify-content: space-between; - color: #6b7280; - font-size: 0.9rem; -} .loadMoreWrapper { display: flex; @@ -90,11 +43,25 @@ } .emptyState { - padding: 32px; - border: 1px solid #e5e7eb; - border-radius: 16px; - text-align: center; - color: #6b7280; + min-height: 470px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: $sp-6; + margin-top: 20vh; + text-align: center; + color: $black-100; + + h4 { + margin: 0 0 $sp-2; + color: $black-100; + } + + p { + margin: 0 0 $sp-1; + max-width: 500px; + } } .loadingRow { diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.tsx index 1830c8072..7ef1f1c3b 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.tsx +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.tsx @@ -14,6 +14,7 @@ import type { import styles from './ProjectShowcasePage.module.scss' import classNames from 'classnames' +import { ProjectShowcaseCard } from '../ProjectShowcaseCard' const PAGE_SIZE = 12 @@ -89,6 +90,13 @@ const ProjectShowcasePage: FC = () => { setSortOrder(event.target.value as 'asc' | 'desc') }, []) + const handleClearFilters = useCallback(() => { + setKeyword('') + setSelectedIndustries([]) + setSelectedCategories([]) + setPage(1) + }, []) + const handleLoadMore = useCallback(async () => { if (loadMoreDisabled) { return @@ -194,6 +202,7 @@ const ProjectShowcasePage: FC = () => { onChange={handleIndustriesChange} placeholder='All Industries' className={styles.input} + openMenuOnClick /> { onChange={handleCategoriesChange} placeholder='All Categories' className={styles.input} + openMenuOnClick /> + +
+
)} > @@ -242,25 +261,7 @@ const ProjectShowcasePage: FC = () => { {postsResult.posts.length > 0 && (
{postsResult.posts.map(post => ( -
-
-

{post.title || 'Untitled'}

- {new Date(post.createdAt).toLocaleDateString()} -
-
-
- Industry: - {post.industries.map(item => item.name).join(', ') || '—'} -
-
- Category: - {post.categories.map(item => item.name).join(', ') || '—'} -
-
-
- {post.createdByHandle || 'Unknown author'} -
-
+ ))}
)} diff --git a/src/apps/customer-portal/src/pages/project-showcase/project-showcase.routes.tsx b/src/apps/customer-portal/src/pages/project-showcase/project-showcase.routes.tsx index f94854798..d528d2ae0 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/project-showcase.routes.tsx +++ b/src/apps/customer-portal/src/pages/project-showcase/project-showcase.routes.tsx @@ -1,10 +1,18 @@ import { getRoutesContainer, lazyLoad, LazyLoadedComponent } from '~/libs/core' +import { rootRoute, showcaseSearchRouteId } from '../../config/routes.config' const ProjectShowcasePage: LazyLoadedComponent = lazyLoad( () => import('./ProjectShowcasePage'), 'ProjectShowcasePage', ) + +export const showcaseRootRoute = `${rootRoute}/${showcaseSearchRouteId}` + +export const getPostRoute = (postId: string) => ( + `${showcaseRootRoute}/${postId}` +) + export const customerPortalProjectShowcaseRoutes = [ { children: [ diff --git a/src/apps/work/src/lib/services/project-showcase-posts.service.ts b/src/apps/work/src/lib/services/project-showcase-posts.service.ts index 213fe73f7..99639bb36 100644 --- a/src/apps/work/src/lib/services/project-showcase-posts.service.ts +++ b/src/apps/work/src/lib/services/project-showcase-posts.service.ts @@ -116,6 +116,7 @@ export async function fetchProjectShowcasePosts( name: String(category.name || ''), })) : [], + content: String(post.content || ''), createdAt: String(post.createdAt || ''), createdByHandle: post.createdByHandle !== undefined && post.createdByHandle !== null ? String(post.createdByHandle) From 90d4b4043c2a5ad9dcbb45d3c077c9ec4bc70a5e Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 9 Jul 2026 07:53:46 +0300 Subject: [PATCH 04/30] lint --- .../components/PageWrapper/PageWrapper.tsx | 35 +++--- .../ProjectShowcaseCard.tsx | 72 ++++++------ .../ProjectShowcasePage.tsx | 106 ++++++++---------- .../project-showcase.routes.tsx | 4 +- .../TalentSearchPage/TalentSearchPage.tsx | 2 +- 5 files changed, 103 insertions(+), 116 deletions(-) diff --git a/src/apps/customer-portal/src/lib/components/PageWrapper/PageWrapper.tsx b/src/apps/customer-portal/src/lib/components/PageWrapper/PageWrapper.tsx index 43c3347e3..e6aba14cf 100644 --- a/src/apps/customer-portal/src/lib/components/PageWrapper/PageWrapper.tsx +++ b/src/apps/customer-portal/src/lib/components/PageWrapper/PageWrapper.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react/no-array-index-key */ /** * Page Wrapper. */ @@ -28,7 +29,9 @@ interface Props { } export const PageWrapper: FC> = props => { - const sidebarPanels: ReactNode[] = useMemo(() => props.sidebar && !Array.isArray(props.sidebar) ? [props.sidebar] : props.sidebar as ReactNode[], [props.sidebar]) + const sidebarPanels: ReactNode[] = useMemo(() => ( + props.sidebar && !Array.isArray(props.sidebar) ? [props.sidebar] : props.sidebar as ReactNode[] + ), [props.sidebar]) return (
@@ -82,22 +85,22 @@ export const PageWrapper: FC> = props => { ))}
- {props.shouldShowIntroState && ( -
-

- {props.introText} -

-
- )} + className={classNames( + styles.resultsPanel, + props.shouldShowIntroState && styles.resultsPanelEmpty, + )} + > + {props.shouldShowIntroState && ( +
+

+ {props.introText} +

+
+ )} - {!props.shouldShowIntroState && ( -
{props.children}
- )} + {!props.shouldShowIntroState && ( +
{props.children}
+ )}
diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.tsx index 55061d683..3f6b0dd02 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.tsx +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.tsx @@ -4,46 +4,48 @@ import classNames from 'classnames' import { ProjectShowcasePost } from '~/apps/work/src/lib' import { IconOutline, LinkButton } from '~/libs/ui' -import styles from './ProjectShowcaseCard.module.scss' import { getPostRoute } from '../project-showcase.routes' +import styles from './ProjectShowcaseCard.module.scss' + interface ProjectShowcaseCardProps { post: ProjectShowcasePost } -const ProjectShowcaseCard: FC = props => { - return ( -
-
- {props.post.categories.map(category => ( - - {category.name} - - ))} -
- -

- {props.post.title || 'Untitled'} -

-
- - {props.post.industries.map(item => item.name).join(', ') || '—'} -
- -
- {props.post.content} -
- -
- -
-
- ) -} +const ProjectShowcaseCard: FC = props => ( +
+
+ {props.post.categories.map(category => ( + + {category.name} + + ))} +
+ +

+ {props.post.title || 'Untitled'} +

+
+ + + {props.post.industries.map(item => item.name) + .join(', ') || '—'} + +
+ +
+ {props.post.content} +
+ +
+ +
+
+) export default ProjectShowcaseCard diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.tsx index 7ef1f1c3b..e081cbcab 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.tsx +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.tsx @@ -1,21 +1,33 @@ import { ChangeEvent, FC, useCallback, useEffect, useMemo, useState } from 'react' import { useSWRConfig } from 'swr' +import type { FullConfiguration } from 'swr/dist/types' +import classNames from 'classnames' -import { Button, IconOutline, InputMultiselect, InputMultiselectOption, InputSelect, InputText, LoadingSpinner } from '~/libs/ui' -import { PageWrapper } from '../../../lib/components' -import { useFetchProjectShowcasePostCategories, useFetchProjectShowcasePostIndustries, useFetchProjectShowcasePosts } from '~/apps/work/src/lib/hooks' -import { fetchProjectShowcasePosts } from '~/apps/work/src/lib/services' -import type { +import { + Button, + IconOutline, + InputMultiselect, + InputMultiselectOption, + InputSelect, + InputText, + LoadingSpinner, +} from '~/libs/ui' +import { + fetchProjectShowcasePosts, FetchProjectShowcasePostsParams, - ProjectShowcasePost, ProjectShowcasePostCategory, ProjectShowcasePostIndustry, -} from '~/apps/work/src/lib/models' + useFetchProjectShowcasePostCategories, + useFetchProjectShowcasePostIndustries, + useFetchProjectShowcasePosts, + UseFetchProjectShowcasePostsResult, +} from '~/apps/work/src/lib' -import styles from './ProjectShowcasePage.module.scss' -import classNames from 'classnames' +import { PageWrapper } from '../../../lib/components' import { ProjectShowcaseCard } from '../ProjectShowcaseCard' +import styles from './ProjectShowcasePage.module.scss' + const PAGE_SIZE = 12 const sortOptions = [ @@ -23,11 +35,16 @@ const sortOptions = [ { label: 'Oldest', value: 'asc' }, ] -function normalizeTaxonomyOption(item: ProjectShowcasePostCategory | ProjectShowcasePostIndustry) { +function normalizeTaxonomyOption( + item: ProjectShowcasePostCategory | ProjectShowcasePostIndustry, +): {label: string; value: string} { return { label: item.name, value: item.id } } -function createTaxonomyOptions(items: Array) { +function createTaxonomyOptions(items: Array): { + label: string; + value: string; +}[] { return items.map(normalizeTaxonomyOption) } @@ -42,19 +59,23 @@ const ProjectShowcasePage: FC = () => { const industriesResult = useFetchProjectShowcasePostIndustries() const categoriesResult = useFetchProjectShowcasePostCategories() - const filters = useMemo(() => ({ - projectId: '', + const filters: FetchProjectShowcasePostsParams = useMemo(() => ({ + categoryId: selectedCategories.map(option => String(option.value || '')) + .filter(Boolean) + .join(','), + industryId: selectedIndustries.map(option => String(option.value || '')) + .filter(Boolean) + .join(','), + keyword: keyword.trim() || undefined, page, perPage: PAGE_SIZE, - keyword: keyword.trim() || undefined, - industryId: selectedIndustries.map(option => String(option.value || '')).filter(Boolean).join(','), - categoryId: selectedCategories.map(option => String(option.value || '')).filter(Boolean).join(','), + projectId: '', sortBy, sortOrder, }), [keyword, selectedIndustries, selectedCategories, page, sortOrder]) - const postsResult = useFetchProjectShowcasePosts(filters) - const { mutate } = useSWRConfig() + const postsResult: UseFetchProjectShowcasePostsResult = useFetchProjectShowcasePosts(filters) + const { mutate }: FullConfiguration = useSWRConfig() const industryOptions = useMemo( () => createTaxonomyOptions(industriesResult.items), @@ -123,49 +144,6 @@ const ProjectShowcasePage: FC = () => { }, false) }, [filters, loadMoreDisabled, mutate, page, postsResult.posts]) - const content = useMemo(() => { - if (!postsResult.posts.length && !postsResult.isLoading) { - return ( -
- No showcase posts match your search. -
- ) - } - - return ( - <> -
- {postsResult.posts.map(post => ( -
-
-

{post.title || 'Untitled'}

- {new Date(post.createdAt).toLocaleDateString()} -
-
-
- Industry: - {post.industries.map(item => item.name).join(', ') || '—'} -
-
- Category: - {post.categories.map(item => item.name).join(', ') || '—'} -
-
-
- {post.createdByHandle || 'Unknown author'} -
-
- ))} -
- {postsResult.isLoading && ( -
- -
- )} - - ) - }, [postsResult.isLoading, postsResult.posts]) - return ( {
{postsResult.posts.length > 0 && !postsResult.isLoading && ( - {postsResult.metadata.total} total + + {postsResult.metadata.total} + {' '} + total + {' '} showcases diff --git a/src/apps/customer-portal/src/pages/project-showcase/project-showcase.routes.tsx b/src/apps/customer-portal/src/pages/project-showcase/project-showcase.routes.tsx index d528d2ae0..d01328c03 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/project-showcase.routes.tsx +++ b/src/apps/customer-portal/src/pages/project-showcase/project-showcase.routes.tsx @@ -1,4 +1,5 @@ import { getRoutesContainer, lazyLoad, LazyLoadedComponent } from '~/libs/core' + import { rootRoute, showcaseSearchRouteId } from '../../config/routes.config' const ProjectShowcasePage: LazyLoadedComponent = lazyLoad( @@ -6,10 +7,9 @@ const ProjectShowcasePage: LazyLoadedComponent = lazyLoad( 'ProjectShowcasePage', ) - export const showcaseRootRoute = `${rootRoute}/${showcaseSearchRouteId}` -export const getPostRoute = (postId: string) => ( +export const getPostRoute = (postId: string): string => ( `${showcaseRootRoute}/${postId}` ) diff --git a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx index ca0acd4b9..e47499a3f 100644 --- a/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx +++ b/src/apps/customer-portal/src/pages/talent-search/TalentSearchPage/TalentSearchPage.tsx @@ -547,7 +547,7 @@ export const TalentSearchPage: FC = () => { Search
- + , ]} introText='Paste a job description to AI-extract skills, or enter skills manually to find talents' shouldShowIntroState={shouldShowIntroState} From 91906ed9b387853650dc0cbf4f51bb87b6d89507 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 9 Jul 2026 08:04:43 +0300 Subject: [PATCH 05/30] Remove unused comp --- .../FlexiTalentPage.module.scss | 16 ++++++++++++++++ .../FlexiTalentPage/FlexiTalentPage.tsx | 18 ++++++++++-------- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/FlexiTalentPage.module.scss b/src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/FlexiTalentPage.module.scss index 928c5fe75..8a7701ce1 100644 --- a/src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/FlexiTalentPage.module.scss +++ b/src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/FlexiTalentPage.module.scss @@ -18,6 +18,22 @@ display: none; } +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + flex-wrap: wrap; + .titleText { + color: $black-100; + font-family: Inter, sans-serif; + font-size: 32px; + font-weight: 700; + line-height: 36px; + text-transform: none; + } +} + .subtitle { color: $black-80; font-size: 15px; diff --git a/src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/FlexiTalentPage.tsx b/src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/FlexiTalentPage.tsx index 57a5c9851..f28b0e12c 100644 --- a/src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/FlexiTalentPage.tsx +++ b/src/apps/customer-portal/src/pages/flexi-talent/FlexiTalentPage/FlexiTalentPage.tsx @@ -1,7 +1,6 @@ import { FC, useCallback, useState } from 'react' import classNames from 'classnames' -import { PageWrapper } from '../../../lib' import { EngagementsView } from '../components/EngagementsView' import { MembersView } from '../components/MembersView' @@ -57,12 +56,15 @@ export const FlexiTalentPage: FC = () => { ) return ( - +
+
+
+

Flexi-Talent

+
+ {rightHeader} + {/*
+
*/} +

Monitor Flexi-Talent engagement coverage, assignment status, and Work links in one place.

@@ -86,7 +88,7 @@ export const FlexiTalentPage: FC = () => {
-
+
) } From 8c46f80e444b342f688069766bea5a2f8464e59d Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 10 Jul 2026 13:21:52 +1000 Subject: [PATCH 06/30] Fix for PM-5592 --- .../challenges/ChallengeEditorPage/README.md | 2 +- ...hallengeScheduleSection.component.spec.tsx | 62 ++++++++++++++ .../ChallengeScheduleSection.spec.ts | 21 +++++ .../ChallengeScheduleSection.tsx | 82 ++++++++++++++++++- .../ChallengeScheduleSection.utils.ts | 2 +- 5 files changed, 164 insertions(+), 5 deletions(-) diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index debe0b203..56667c73a 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -67,7 +67,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha Engineering, and other active API-only/internal challenge types stay hidden from the create dropdown, and any now-invalid preselection is cleared when the track changes. Deployments can override the allowlist with `REACT_APP_WORK_CREATE_CHALLENGE_TYPES_BY_TRACK` JSON. -- `ChallengeScheduleSection`: schedule editor for challenge start and phase dates. It keeps the detected timezone above the controls, renders the `Start Date` label with the `Scheduled` and `Immediately` start-mode radios aligned to the end of that header row above the input with a green selected state, persists the selected start mode in challenge metadata so saved `/edit` and `/view` routes reopen with the correct radio state, recalculates root phase dates when the challenge start changes, honors completed phases' actual dates when deriving and displaying schedule rows, lets incomplete active Design phases be shortened no earlier than the current date/time, prevents incomplete active non-Design phases from being shortened, and keeps completed phases' end-date and duration controls locked to match legacy work-manager behavior. `Task` challenges hide this editable section across create, edit, and read-only view routes to match legacy work-manager behavior. +- `ChallengeScheduleSection`: schedule editor for challenge start and phase dates. It keeps the detected timezone above the controls, renders the `Start Date` label with the `Scheduled` and `Immediately` start-mode radios aligned to the end of that header row above the input with a green selected state, persists the selected start mode in challenge metadata so saved `/edit` and `/view` routes reopen with the correct radio state, initializes missing challenge start dates from existing phase starts or the current date before calculating blank phase rows, recalculates root phase dates when the challenge start changes, honors completed phases' actual dates when deriving and displaying schedule rows, lets incomplete active Design phases be shortened no earlier than the current date/time, prevents incomplete active non-Design phases from being shortened, and keeps completed phases' end-date and duration controls locked to match legacy work-manager behavior. `Task` challenges hide this editable section across create, edit, and read-only view routes to match legacy work-manager behavior. - `DesignWorkTypeField`: shown for Design + Challenge, with the legacy work-type options (`Application Front-End Design`, `Print/Presentation`, `Web Design`, `Widget or Mobile Screen Design`, `Wireframes`). The selected value is stored in challenge tags. - `FunChallengeField`: shown for `Marathon Match` type and remains editable after creation so the form can switch between fun-challenge and standard marathon-match fields. - `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. On the human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. Design challenge manual reviewers always keep the public review opportunity checkbox disabled and unchecked. diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.component.spec.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.component.spec.tsx index b424cbc3a..af3713053 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.component.spec.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.component.spec.tsx @@ -372,6 +372,68 @@ describe('ChallengeScheduleSection component', () => { .toBeChecked() }) + it('seeds blank schedule rows from the current date so their dates are editable', async () => { + render( + , + ) + + await waitFor(() => { + expect(screen.getByTestId('start-date-value')) + .toHaveTextContent('2026-03-31T12:34:00.000Z') + }) + + const renderedPhaseRows = mockPhaseEditorRow.mock.calls + .map(([props]) => props as { + endDate?: string + isEndDateEditable?: boolean + isStartDateEditable?: boolean + phase?: { + name?: string + } + startDate?: string + }) + const registrationRow = [...renderedPhaseRows] + .reverse() + .find(props => props.phase?.name === 'Registration') + const submissionRow = [...renderedPhaseRows] + .reverse() + .find(props => props.phase?.name === 'Submission') + + expect(registrationRow) + .toEqual(expect.objectContaining({ + endDate: '2026-03-31T14:34:00.000Z', + isEndDateEditable: true, + isStartDateEditable: true, + startDate: '2026-03-31T12:34:00.000Z', + })) + expect(submissionRow) + .toEqual(expect.objectContaining({ + endDate: '2026-03-31T13:34:00.000Z', + isEndDateEditable: true, + isStartDateEditable: true, + startDate: '2026-03-31T12:34:00.000Z', + })) + }) + it('restores immediate mode from saved metadata even when a start date exists', () => { render( { .toBe((120 * 60) + 59) }) + it('preserves root phase dates when the challenge start date is missing', () => { + const startDate = '2026-07-10T03:05:50.166Z' + const existingEndDate = '2026-07-15T03:05:50.166Z' + const phases: ChallengePhase[] = [ + buildPhase({ + duration: 5 * 24 * 60, + name: 'Registration', + phaseId: 'registration', + scheduledEndDate: existingEndDate, + scheduledStartDate: startDate, + }), + ] + + const result = recalculatePhases(phases) + + expect(result.phases[0]?.scheduledStartDate) + .toBe(startDate) + expect(result.phases[0]?.scheduledEndDate) + .toBe(existingEndDate) + }) + it('aligns successor phases to a predecessor actual end date when the predecessor closes early', () => { const checkpointReviewActualEnd = '2026-04-09T13:14:00.000Z' const phases: ChallengePhase[] = [ diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.tsx b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.tsx index e5e1b60d7..de930c03a 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.tsx +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.tsx @@ -121,6 +121,28 @@ function getLatestDate(dates: Array): Date | undefined { }, undefined) } +/** + * Returns the earliest valid scheduled phase start from the current schedule. + * + * @param phases phase rows currently stored in the challenge form. + * @returns earliest scheduled phase start date, or `undefined` when none exist. + */ +function getEarliestPhaseStartDate(phases: ChallengePhase[]): Date | undefined { + return phases.reduce((earliestDate, phase) => { + const phaseStartDate = toDate(phase.scheduledStartDate) + + if (!phaseStartDate) { + return earliestDate + } + + if (!earliestDate || phaseStartDate.getTime() < earliestDate.getTime()) { + return phaseStartDate + } + + return earliestDate + }, undefined) +} + /** * Resolves the minimum allowed phase end date for schedule edits. * @@ -438,7 +460,46 @@ export const ChallengeScheduleSection: FC = ( return } - const recalculationResult = recalculatePhases(phases, startDate, { + const parsedStartDate = toDate(startDate) + const earliestPhaseStartDate = getEarliestPhaseStartDate(phases) + const seededStartDate = parsedStartDate + || earliestPhaseStartDate + || minScheduleDate + const recalculationStartDate = parsedStartDate + || ( + earliestPhaseStartDate + ? undefined + : seededStartDate + ) + const persistedStartDateMode = getMetadataValue( + metadata, + START_DATE_MODE_METADATA_NAME, + ) + + if (!parsedStartDate) { + lastInternalStartDateValueRef.current = seededStartDate.getTime() + setValue('startDate', seededStartDate, { + shouldDirty: false, + shouldValidate: true, + }) + + if (persistedStartDateMode !== startDateMode) { + setValue( + 'metadata', + setMetadataValue( + metadata, + START_DATE_MODE_METADATA_NAME, + startDateMode, + ), + { + shouldDirty: false, + shouldValidate: true, + }, + ) + } + } + + const recalculationResult = recalculatePhases(phases, recalculationStartDate, { phaseStartOverrides: phaseStartOverridesRef.current, }) const error = recalculationResult.error @@ -452,7 +513,14 @@ export const ChallengeScheduleSection: FC = ( setCalculationError(error) initializedRef.current = true - }, [phases, setValue, startDate]) + }, [ + metadata, + minScheduleDate, + phases, + setValue, + startDate, + startDateMode, + ]) const handleStartDateChange = useCallback( ( @@ -709,6 +777,14 @@ export const ChallengeScheduleSection: FC = ( return } + if (!toDate(startDate)) { + handleStartDateChange( + new Date(), + START_DATE_MODE.SCHEDULED, + ) + return + } + setStartDateMode(START_DATE_MODE.SCHEDULED) setValue( 'metadata', @@ -723,7 +799,7 @@ export const ChallengeScheduleSection: FC = ( }, ) }, - [handleStartDateChange, metadata, setValue], + [handleStartDateChange, metadata, setValue, startDate], ) const handleSetScheduledStartDateMode = useCallback( (): void => { diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.utils.ts b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.utils.ts index 10ae97b46..16658bbc5 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.utils.ts +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/components/ChallengeScheduleSection/ChallengeScheduleSection.utils.ts @@ -214,7 +214,7 @@ export function recalculatePhases( let duration = normalizeDuration(phase.duration) const existingPhaseStartDate = toDate(phase.scheduledStartDate) const existingPhaseEndDate = toDate(phase.scheduledEndDate) - let phaseStartDate = shouldScheduleDates || index === 0 + let phaseStartDate = shouldScheduleDates || (index === 0 && !!baseStartDate) ? baseStartDate : existingPhaseStartDate || baseStartDate From 2364d0af1b95e76b8293a230706c7bf25467b4be Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 10 Jul 2026 14:12:08 +1000 Subject: [PATCH 07/30] PM-5585: Update Flexi Talent bucket note What was broken The Flexi Talent engagement summary note still said total included On Hold engagements, which no longer matches the required Active and Closed-only bucket behavior. Root cause The note was static UI copy and still reflected the previous bucket semantics. What was changed Updated the Engagement Buckets note to state that Total includes only Active and Closed engagements while preserving the Active default bucket guidance. Any added/updated tests No UI tests were added for this copy-only change. A related-test lookup for the touched Flexi Talent view found no matching tests. --- .../flexi-talent/components/EngagementsView/EngagementsView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.tsx b/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.tsx index 4d83b0e64..ea6948ded 100644 --- a/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.tsx +++ b/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.tsx @@ -549,7 +549,7 @@ export const EngagementsView: FC = () => {

- Total includes On Hold engagements. Active is the default bucket. + Total includes only Active and Closed engagements. Active is the default bucket.

From 72fdf7cfacb221b5bf4acb9f99aeebbb5f3543b5 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Fri, 10 Jul 2026 14:23:01 +1000 Subject: [PATCH 08/30] PM-5584: Hide time left for inactive assignments What was broken Completed, offer rejected, and terminated assignment rows in the Flexi-Talent engagement detail panel still showed Time Left and overdue metadata even though those assignments were no longer active. Root cause The engagement detail assignment renderer always displayed the time-left field whenever backend timing data existed and did not check the assignment status before treating the row as current work. What was changed Added a current-assignment status check for selected and assigned rows, and render the Time Left metadata only for those active statuses. Any added/updated tests Added an EngagementsView test covering completed, offer rejected, and terminated rows with overdue timing data to ensure their Time Left/overdue metadata is hidden. --- .../EngagementsView/EngagementsView.spec.tsx | 151 ++++++++++++++++++ .../EngagementsView/EngagementsView.tsx | 36 +++-- 2 files changed, 176 insertions(+), 11 deletions(-) create mode 100644 src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.spec.tsx diff --git a/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.spec.tsx b/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.spec.tsx new file mode 100644 index 000000000..92e82c9dd --- /dev/null +++ b/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.spec.tsx @@ -0,0 +1,151 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, sort-keys */ +import '@testing-library/jest-dom' + +import React from 'react' +import { + render, + screen, +} from '@testing-library/react' + +import { + getFlexiEngagementDetail, + getFlexiEngagementList, + getFlexiEngagementSummary, +} from '../../../../lib' + +import { EngagementsView } from './EngagementsView' + +const mockGetFlexiEngagementSummary = getFlexiEngagementSummary as jest.Mock +const mockGetFlexiEngagementList = getFlexiEngagementList as jest.Mock +const mockGetFlexiEngagementDetail = getFlexiEngagementDetail as jest.Mock + +jest.mock('~/apps/admin/src/lib/components/common/Pagination', () => ({ + Pagination: () =>
pagination
, +}), { virtual: true }) + +jest.mock('~/libs/shared/lib/utils/rich-text', () => ({ + renderRichTextToHtml: jest.fn(() => ''), +}), { virtual: true }) + +jest.mock('~/libs/ui', () => ({ + IconOutline: { + ArrowDownIcon: () => arrow-down-icon, + ArrowUpIcon: () => arrow-up-icon, + DocumentSearchIcon: () => document-search-icon, + ExclamationCircleIcon: () => error-icon, + ExternalLinkIcon: () => external-link-icon, + InboxIcon: () => inbox-icon, + SearchIcon: () => search-icon, + XIcon: () => x-icon, + }, +}), { virtual: true }) + +jest.mock('../../../../lib', () => ({ + getFlexiEngagementDetail: jest.fn(), + getFlexiEngagementList: jest.fn(), + getFlexiEngagementSummary: jest.fn(), +})) + +const inactiveAssignments = [ + { + assignmentId: 'assignment-completed', + displayStatusLabel: 'Completed', + durationLabel: '1 month', + engagementId: 'engagement-1', + isOverdue: true, + memberHandle: 'completed_member', + memberId: 'member-completed', + projectId: 'project-1', + resolvedEndDate: '2026-04-24T00:00:00.000Z', + startDate: '2026-05-02T00:00:00.000Z', + status: 'completed', + timeLeftDays: -76, + }, + { + assignmentId: 'assignment-offer-rejected', + displayStatusLabel: 'Offer Rejected', + durationLabel: '1 month', + engagementId: 'engagement-1', + isOverdue: true, + memberHandle: 'offer_rejected_member', + memberId: 'member-offer-rejected', + projectId: 'project-1', + resolvedEndDate: '2026-05-25T00:00:00.000Z', + startDate: '2026-04-25T00:00:00.000Z', + status: 'offer_rejected', + timeLeftDays: -45, + }, + { + assignmentId: 'assignment-terminated', + displayStatusLabel: 'Terminated', + durationLabel: '1 month', + engagementId: 'engagement-1', + isOverdue: true, + memberHandle: 'terminated_member', + memberId: 'member-terminated', + projectId: 'project-1', + resolvedEndDate: '2026-06-01T00:00:00.000Z', + startDate: '2026-05-01T00:00:00.000Z', + status: 'terminated', + timeLeftDays: -38, + }, +] + +describe('EngagementsView', () => { + beforeEach(() => { + jest.clearAllMocks() + + mockGetFlexiEngagementSummary.mockResolvedValue({ + active: 1, + closed: 0, + total: 1, + }) + mockGetFlexiEngagementList.mockResolvedValue({ + data: [{ + assignedMemberCount: 0, + engagementId: 'engagement-1', + engagementTitle: 'Flexi Talent Engagement', + projectId: 'project-1', + projectName: 'Flexi Project', + requiredMemberCount: 3, + status: 'active', + }], + page: 1, + perPage: 10, + total: 1, + totalPages: 1, + }) + mockGetFlexiEngagementDetail.mockResolvedValue({ + assignedMemberCount: 0, + assignments: inactiveAssignments, + description: '', + engagementId: 'engagement-1', + engagementTitle: 'Flexi Talent Engagement', + projectId: 'project-1', + projectName: 'Flexi Project', + requiredMemberCount: 3, + skills: [], + status: 'active', + workLinks: {}, + }) + }) + + it('hides time-left metadata for inactive assignment statuses', async () => { + render() + + expect(await screen.findByText('completed_member')) + .toBeInTheDocument() + expect(screen.getByText('Offer Rejected')) + .toBeInTheDocument() + expect(screen.getByText('terminated_member')) + .toBeInTheDocument() + expect(screen.queryByText('Time Left')) + .not.toBeInTheDocument() + expect(screen.queryByText('76 days overdue')) + .not.toBeInTheDocument() + expect(screen.queryByText('45 days overdue')) + .not.toBeInTheDocument() + expect(screen.queryByText('38 days overdue')) + .not.toBeInTheDocument() + }) +}) diff --git a/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.tsx b/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.tsx index 4d83b0e64..7729c0562 100644 --- a/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.tsx +++ b/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.tsx @@ -34,6 +34,7 @@ const ENGAGEMENTS_PER_PAGE = 10 const SEARCH_DEBOUNCE_MS = 300 const DESCRIPTION_COLLAPSED_HEIGHT_PX = 126 const DESCRIPTION_OVERFLOW_TOLERANCE_PX = 1 +const CURRENT_ASSIGNMENT_STATUSES = new Set(['assigned', 'selected']) type DetailState = 'loading' | 'empty' | 'error' | 'ready' @@ -87,7 +88,18 @@ function formatStatusLabel(status?: string): string { } /** - * Formats backend timing fields without hiding overdue or negative values. + * Detects whether an assignment row is still active in the engagement. + * + * @param status Raw assignment status returned by engagements-api-v6. + * @returns True when the row represents current assignment work that should show time-left metadata. + */ +function isCurrentAssignmentStatus(status?: string): boolean { + return CURRENT_ASSIGNMENT_STATUSES.has(String(status || '') + .toLowerCase()) +} + +/** + * Formats backend timing fields for current assignments without hiding overdue or negative values. * * @param assignment Assignment row returned by the detail endpoint. * @returns Human-readable timing text for the assignment row. @@ -806,16 +818,18 @@ export const EngagementsView: FC = () => {
-
-
Time Left
-
- {formatTimeLeft(assignment)} -
-
+ {isCurrentAssignmentStatus(assignment.status) && ( +
+
Time Left
+
+ {formatTimeLeft(assignment)} +
+
+ )}
Duration
{assignment.durationLabel || 'Not set'}
From 00d9c71ee7ffaa264ac772d95a11194f39ff3a2c Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 10 Jul 2026 08:02:25 +0300 Subject: [PATCH 09/30] PM-5255 - shwocase in customer portal - post --- .../lib/components/Layout/Layout.module.scss | 2 +- .../customer-portal/src/lib/services/index.ts | 1 + .../src/lib/services/showcasePost.service.ts | 38 +++ .../ProjectShowcaseCard.module.scss | 35 ++- .../ProjectShowcaseCard.tsx | 7 +- .../ProjectShowcasePage.tsx | 17 +- .../ProjectShowcasePostPage.module.scss | 288 ++++++++++++++++++ .../ProjectShowcasePostPage.tsx | 173 +++++++++++ .../ShowcasePostChallengeList.module.scss | 107 +++++++ .../ShowcasePostChallengeList.tsx | 76 +++++ .../ShowcasePostChallengeList/index.ts | 1 + .../ShowcasePostMedia.module.scss | 67 ++++ .../ShowcasePostMedia/ShowcasePostMedia.tsx | 138 +++++++++ .../ShowcasePostMedia/index.ts | 1 + .../ProjectShowcasePostPage/index.ts | 1 + .../project-showcase.routes.tsx | 15 +- .../src/pages/project-showcase/utils.ts | 16 + .../lib/hooks/useFetchProjectShowcasePosts.ts | 45 +++ .../lib/models/ProjectShowcasePost.model.ts | 19 ++ .../project-showcase-posts.service.ts | 8 +- .../work/src/lib/utils/navigation.utils.ts | 5 +- .../work/src/lib/utils/permissions.utils.ts | 2 +- .../ProjectInvitationsPage.tsx | 2 - 23 files changed, 1042 insertions(+), 22 deletions(-) create mode 100644 src/apps/customer-portal/src/lib/services/showcasePost.service.ts create mode 100644 src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.module.scss create mode 100644 src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.tsx create mode 100644 src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/ShowcasePostChallengeList.module.scss create mode 100644 src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/ShowcasePostChallengeList.tsx create mode 100644 src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/index.ts create mode 100644 src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMedia.module.scss create mode 100644 src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMedia.tsx create mode 100644 src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/index.ts create mode 100644 src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/index.ts create mode 100644 src/apps/customer-portal/src/pages/project-showcase/utils.ts diff --git a/src/apps/customer-portal/src/lib/components/Layout/Layout.module.scss b/src/apps/customer-portal/src/lib/components/Layout/Layout.module.scss index d1b0d383d..c387637f0 100644 --- a/src/apps/customer-portal/src/lib/components/Layout/Layout.module.scss +++ b/src/apps/customer-portal/src/lib/components/Layout/Layout.module.scss @@ -1,7 +1,7 @@ @import '@libs/ui/styles/includes'; .layout { - position: relative; + // position: relative; font-family: $font-roboto; color: var(--Primary); diff --git a/src/apps/customer-portal/src/lib/services/index.ts b/src/apps/customer-portal/src/lib/services/index.ts index 78d032d2b..932881090 100644 --- a/src/apps/customer-portal/src/lib/services/index.ts +++ b/src/apps/customer-portal/src/lib/services/index.ts @@ -1,2 +1,3 @@ export * from './talentSearch.service' export * from './flexiTalent.service' +export * from './showcasePost.service' diff --git a/src/apps/customer-portal/src/lib/services/showcasePost.service.ts b/src/apps/customer-portal/src/lib/services/showcasePost.service.ts new file mode 100644 index 000000000..400cdb562 --- /dev/null +++ b/src/apps/customer-portal/src/lib/services/showcasePost.service.ts @@ -0,0 +1,38 @@ +import useSWR, { SWRResponse } from 'swr' + +import { Challenge, fetchChallenge } from '~/apps/work/src/lib' + +export type UseFetchChallenges = SWRResponse + +export const useFetchChallenges = (challengeIds: string[]): UseFetchChallenges => { + const shouldFetchChallenges = challengeIds.length > 0 + + return useSWR( + shouldFetchChallenges ? ['work/challenge-list', challengeIds.join(',')] : undefined, + async () => { + const settledResults = await Promise.allSettled( + challengeIds.map(id => fetchChallenge(id)), + ) + + const loadedChallenges = settledResults + .filter( + (result): result is PromiseFulfilledResult => result.status === 'fulfilled', + ) + .map(result => result.value) + + if (loadedChallenges.length === 0 && settledResults.length > 0) { + const rejection = settledResults.find( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ) + throw rejection?.reason ?? new Error('Failed to load challenges') + } + + return loadedChallenges + }, + { + dedupingInterval: 0, + errorRetryCount: 2, + shouldRetryOnError: true, + }, + ) +} diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.module.scss b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.module.scss index 0a70488af..389e042ae 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.module.scss +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.module.scss @@ -31,6 +31,23 @@ color: $black-80; font-family: $font-roboto; + + &:global(.development) { + color: #35AC35; + background: #D8FDD8; + } + &:global(.qa), &:global(.quality_assurance) { + color: $red-120; + background: $red-25; + } + &:global(.design) { + color: $blue-120; + background: $blue-15; + } + &:global(.data_science) { + background-color: #FFE3CF; + color: #F46500; + } } } @@ -43,13 +60,17 @@ line-height: 28px; /* 127.273% */ text-transform: none; - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; - text-overflow: ellipsis; - height: 100%; - max-height: calc(28px * 2); + // display: -webkit-box; + // -webkit-line-clamp: 3; + // -webkit-box-orient: vertical; + // overflow: hidden; + // text-overflow: ellipsis; + // height: 100%; + // max-height: calc(28px * 2); +} + +.button { + margin-top: auto; } .cardBody { diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.tsx index 3f6b0dd02..ae002d240 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.tsx +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames' import { ProjectShowcasePost } from '~/apps/work/src/lib' import { IconOutline, LinkButton } from '~/libs/ui' +import { toClassName } from '../utils' import { getPostRoute } from '../project-showcase.routes' import styles from './ProjectShowcaseCard.module.scss' @@ -16,7 +17,7 @@ const ProjectShowcaseCard: FC = props => (
{props.post.categories.map(category => ( - + {category.name} ))} @@ -37,12 +38,12 @@ const ProjectShowcaseCard: FC = props => ( {props.post.content}
-
+
diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.tsx index e081cbcab..baf7e95d1 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.tsx +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.tsx @@ -55,6 +55,9 @@ const ProjectShowcasePage: FC = () => { const [page, setPage] = useState(1) const sortBy = 'createdAt' const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc') + const hasFiltersApplied = useMemo(() => ( + !!keyword.trim() || selectedIndustries.length > 0 || selectedCategories.length > 0 + ), [keyword, selectedIndustries, selectedCategories]) const industriesResult = useFetchProjectShowcasePostIndustries() const categoriesResult = useFetchProjectShowcasePostCategories() @@ -159,6 +162,7 @@ const ProjectShowcasePage: FC = () => {
{
{postsResult.posts.length > 0 && !postsResult.isLoading && ( + {hasFiltersApplied ? 'We have found' : ''} + {' '} {postsResult.metadata.total} {' '} - total + {!hasFiltersApplied && 'total'} + {hasFiltersApplied && ( + postsResult.metadata.total === 1 ? 'showcase' : 'showcases' + )} {' '} - showcases + + {hasFiltersApplied ? 'that match your search.' : 'showcases'} + )}
@@ -257,7 +268,7 @@ const ProjectShowcasePage: FC = () => { {!loadMoreDisabled && postsResult.posts.length > 0 && (
- )} - - ) - })} - -
+ {isImage && ( + {getMediaAlt(mediaAsset)} + )} + {!isImage && ( +
+ + + {getPlaceholderLabel(extension, mediaAsset)} + +
+ )} + + + ) + })} + +
+ + {isGalleryOpen && galleryIndex !== null && ( + + )} + ) } diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.module.scss b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.module.scss new file mode 100644 index 000000000..3c50f2b9d --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.module.scss @@ -0,0 +1,156 @@ +@import '@libs/ui/styles/includes'; + +.overlay { + position: fixed; + inset: 0; + z-index: 1200; + display: flex; + justify-content: center; + background: rgba(0, 0, 0, 0.9); +} + +.mainFrame { + position: relative; + width: min(100%, 1000px); + max-height: min(100%, 90vh); + display: flex; + flex-direction: column; + padding: 120px 0 0; +} + +.close { + position: absolute; + bottom: calc(100% + 15px); + left: calc(100% + 40px); + border: 0; + background: transparent; + color: $tc-white; + cursor: pointer; +} + +.mainContent { + display: flex; + flex-direction: column; + gap: 24px; + flex: 1; + min-height: 0; + position: relative; +} + +.galleryMedia { + display: flex; + align-items: center; + justify-content: center; + min-height: 340px; + padding: 1rem; + border-radius: 1rem; + background-color: var(--neutral-100); + overflow: hidden; +} + +.galleryMedia img, +.galleryMedia video { + max-width: 100%; + max-height: 74vh; + object-fit: contain; +} + +.galleryImage { + width: auto; + height: auto; +} + +.galleryPlaceholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + width: 100%; + min-height: 280px; + padding: 1.5rem; + border: 1px dashed var(--neutral-300); + border-radius: 1rem; + background-color: var(--neutral-0); + color: var(--neutral-700); +} + +.galleryPlaceholderIcon { + width: 3rem; + height: 3rem; + color: var(--neutral-600); +} + +.galleryPlaceholderLabel { + font-size: 1rem; + font-weight: 700; +} + +.galleryPlaceholderLink { + color: var(--primary-600); + text-decoration: underline; +} + +.galleryControls { + display: flex; + color: $tc-white; + position: absolute; + top: 50%; + left: -72px; + right: -72px; + transform: translateY(-50%); + justify-content: space-between; + + > .navControl { + background: rgba($tc-white, 0.1); + border-radius: 50%; + width: 48px; + height: 48px; + color: inherit; + transition: all 0.15s ease; + + &:hover { + background: rgba($tc-white, 0.2); + } + &:active { + background: rgba($tc-white, 0.15); + } + } +} + + +.galleryThumbnails { + display: flex; + align-items: flex-start; + gap: $sp-4; + height: 160px; + width: 100%; + overflow-x: auto; + + .mediaItem { + display: flex; + align-items: center; + justify-content: center; + + width: 164px; + height: 96px; + border-radius: 4px; + + cursor: pointer; + + &.active { + border: 2px solid var(--TC-Colors-Global-Turquoise-turq-120, #0AB88A); + } + + overflow: hidden; + flex-shrink: 0; + transition: 0.15ms ease; + + &:hover { + opacity: 0.9; + } + &:active { + opacity: 0.8; + } + } +} diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.tsx new file mode 100644 index 000000000..76a4ad3b0 --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.tsx @@ -0,0 +1,251 @@ +import { FC, useCallback, useEffect, useRef, useState } from 'react' + +import { IconFile } from '~/apps/customer-portal/src/lib/assets' +import { Button, IconOutline } from '~/libs/ui' +import { ProjectShowcasePostMedia } from '~/apps/work/src/lib' + +import styles from './ShowcasePostMediaGallery.module.scss' +import classNames from 'classnames' + +interface ShowcasePostMediaGalleryProps { + assets: ProjectShowcasePostMedia[] + startingIndex: number + onClose: () => void +} + +export function getFileExtension(value: string | undefined): string | undefined { + if (!value) { + return undefined + } + + const normalized = value.trim().toLowerCase() + if (!normalized) { + return undefined + } + + const mimeMapped: Record = { + 'application/pdf': '.pdf', + 'image/bmp': '.bmp', + 'image/gif': '.gif', + 'image/jpeg': '.jpeg', + 'image/jpg': '.jpg', + 'image/png': '.png', + 'video/mp4': '.mp4', + 'video/quicktime': '.mov', + 'video/webm': '.webm', + 'video/x-msvideo': '.avi', + } + + const mimeExtension = mimeMapped[normalized] + if (mimeExtension) { + return mimeExtension + } + + const extensionMatch = /\.(bmp|gif|jpe?g|png|pdf|webm|mp4|mov|avi)(?:[?#].*)?$/.exec(normalized) + if (!extensionMatch) { + return undefined + } + + return `.${extensionMatch[1]}` +} + +const IMAGE_EXTENSIONS = new Set(['.bmp', '.gif', '.jpg', '.jpeg', '.png']) +const VIDEO_EXTENSIONS = new Set(['.webm', '.mp4', '.mov', '.avi']) +const PDF_EXTENSIONS = new Set(['.pdf']) + +export function getAssetExtension(asset: ProjectShowcasePostMedia): string { + return getFileExtension(asset.type) || getFileExtension(asset.url) || '' +} + +export function isImageAsset(extension: string): boolean { + return IMAGE_EXTENSIONS.has(extension) +} + +export function getPlaceholderLabel(extension: string, asset: ProjectShowcasePostMedia): string { + if (PDF_EXTENSIONS.has(extension)) { + return 'PDF' + } + + if (VIDEO_EXTENSIONS.has(extension)) { + return 'Video' + } + + if (extension) { + return extension.replace('.', '').toUpperCase() + } + + return asset.type || 'File' +} + +export function getMediaAlt(asset: ProjectShowcasePostMedia): string { + const extension = getAssetExtension(asset) + + if (isImageAsset(extension)) { + return `Project showcase image (${extension.replace('.', '')})` + } + + return `Project showcase attachment (${getPlaceholderLabel(extension, asset)})` +} + +const ShowcasePostMediaGallery: FC = (props) => { + const thumbsContainerRef = useRef(null) + const [currentIndex, setCurrentIndex] = useState(props.startingIndex) + const mediaAsset = props.assets[currentIndex] + + useEffect(() => { + setCurrentIndex(props.startingIndex) + }, [props.startingIndex]) + + const handlePrevious = useCallback(() => { + setCurrentIndex(prevIndex => (prevIndex + props.assets.length - 1) % props.assets.length) + }, [props.assets.length]) + + const handleNext = useCallback(() => { + setCurrentIndex(prevIndex => (prevIndex + 1) % props.assets.length) + }, [props.assets.length]) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + props.onClose() + } + + if (event.key === 'ArrowLeft') { + handlePrevious() + } + + if (event.key === 'ArrowRight') { + handleNext() + } + } + + const originalDocumentOverflow = document.documentElement.style.overflow + const originalBodyOverflow = document.body.style.overflow + + document.documentElement.style.overflow = 'hidden' + document.body.style.overflow = 'hidden' + + window.addEventListener('keydown', handleKeyDown) + + return () => { + window.removeEventListener('keydown', handleKeyDown) + document.documentElement.style.overflow = originalDocumentOverflow + document.body.style.overflow = originalBodyOverflow + } + }, [handleNext, handlePrevious, props.onClose]) + + useEffect(() => { + if (!thumbsContainerRef.current) { + return + } + + thumbsContainerRef.current.children[currentIndex].scrollIntoView(); + }, [currentIndex]); + + if (!mediaAsset) { + return null + } + + return ( +
+
event.stopPropagation()}> +
+ + + + +
+
+ +
    + {props.assets.map((mediaAsset, index) => { + const extension = getAssetExtension(mediaAsset) + const isImage = isImageAsset(extension) + + return ( +
  • + {isImage && ( + {getMediaAlt(mediaAsset)} + )} + {!isImage && ( +
    + + + {getPlaceholderLabel(extension, mediaAsset)} + +
    + )} +
  • + ) + })} +
+
+
+
+ ) +} + +export default ShowcasePostMediaGallery From eb1ea6ff00d0ce5d47e582b63ced4bd1e5ea492f Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 13 Jul 2026 08:46:46 +0300 Subject: [PATCH 14/30] PM-5255 - media gallery --- .../ShowcasePostMediaGallery.module.scss | 9 +++++++++ .../ShowcasePostMedia/ShowcasePostMediaGallery.tsx | 1 - 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.module.scss b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.module.scss index 3c50f2b9d..2a06d1a82 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.module.scss +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.module.scss @@ -126,6 +126,15 @@ height: 160px; width: 100%; overflow-x: auto; + -webkit-overflow-scrolling: touch; + touch-action: pan-x; + user-select: none; + + @include scrollbar; + + &:active { + cursor: grabbing; + } .mediaItem { display: flex; diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.tsx index 76a4ad3b0..e484fcbb6 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.tsx +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.tsx @@ -103,7 +103,6 @@ const ShowcasePostMediaGallery: FC = (props) => { const handleNext = useCallback(() => { setCurrentIndex(prevIndex => (prevIndex + 1) % props.assets.length) }, [props.assets.length]) - useEffect(() => { const handleKeyDown = (event: KeyboardEvent): void => { if (event.key === 'Escape') { From 80a4a1fd8fbd599d52a3f45516ae595167f58259 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 13 Jul 2026 09:06:41 +0300 Subject: [PATCH 15/30] lint --- .../ShowcasePostChallengeList.tsx | 6 +-- .../ShowcasePostMedia/ShowcasePostMedia.tsx | 8 +-- .../ShowcasePostMediaGallery.tsx | 49 +++++++++++-------- 3 files changed, 36 insertions(+), 27 deletions(-) diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/ShowcasePostChallengeList.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/ShowcasePostChallengeList.tsx index c7ade9989..a8c54c9f0 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/ShowcasePostChallengeList.tsx +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/ShowcasePostChallengeList.tsx @@ -2,13 +2,13 @@ import { FC, useMemo } from 'react' import classNames from 'classnames' +import { EnvironmentConfig } from '~/config' import { IconOutline, LinkButton } from '~/libs/ui' import { UseFetchChallenges, useFetchChallenges } from '~/apps/customer-portal/src/lib' import { getTrackName, toClassName } from '../../utils' import styles from './ShowcasePostChallengeList.module.scss' -import { EnvironmentConfig } from '~/config' interface ShowcasePostChallengeListProps { challengeIds?: string[] @@ -70,8 +70,8 @@ const ShowcasePostChallengeList: FC = props => { label='View' iconToRight icon={IconOutline.ArrowRightIcon} - target="_blank" - rel="noreferrer, noopener" + target='_blank' + rel='noreferrer, noopener' /> diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMedia.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMedia.tsx index 38eeafa4b..5a550f46a 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMedia.tsx +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMedia.tsx @@ -16,18 +16,18 @@ interface ShowcasePostMediaProps { } const ShowcasePostMedia: FC = props => { - const [galleryIndex, setGalleryIndex] = useState(null) + const [galleryIndex, setGalleryIndex] = useState(undefined) const visibleAssets = useMemo(() => props.assets?.slice(0, 4) || [], [props.assets]) const galleryAssets = useMemo(() => props.assets || [], [props.assets]) - const isGalleryOpen = galleryIndex !== null + const isGalleryOpen = galleryIndex !== undefined const handleOpenGallery = useCallback((index: number) => { setGalleryIndex(index) }, []) const handleCloseGallery = useCallback(() => { - setGalleryIndex(null) + setGalleryIndex(undefined) }, []) if (!props.assets || props.assets.length === 0) { @@ -47,7 +47,7 @@ const ShowcasePostMedia: FC = props => { + {(index === visibleAssets.length - 1) && (restAssetCount > 0) && ( +
+{restAssetCount+1}
+ )} ) })} diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.tsx index d851a8741..059c7cff8 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.tsx +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.tsx @@ -80,6 +80,10 @@ export function getPlaceholderLabel(extension: string, asset: ProjectShowcasePos } export function getMediaAlt(asset: ProjectShowcasePostMedia): string { + if (asset.alt) { + return asset.alt; + } + const extension = getAssetExtension(asset) if (isImageAsset(extension)) { diff --git a/src/apps/work/src/lib/models/ProjectShowcasePost.model.ts b/src/apps/work/src/lib/models/ProjectShowcasePost.model.ts index 15225c766..9b49cd787 100644 --- a/src/apps/work/src/lib/models/ProjectShowcasePost.model.ts +++ b/src/apps/work/src/lib/models/ProjectShowcasePost.model.ts @@ -11,6 +11,7 @@ export interface ProjectShowcasePostMedia { id: string type: string url: string + alt?: string } export interface ProjectShowcasePostChallengeMetadataSkill { @@ -37,6 +38,8 @@ export interface ProjectShowcasePost { createdAt: string createdById: number createdByHandle?: string + publishedAt?: number + publishedBy?: string industries: ProjectShowcasePostTaxonomyItem[] categories: ProjectShowcasePostTaxonomyItem[] media?: ProjectShowcasePostMedia[] diff --git a/src/apps/work/src/lib/services/project-showcase-posts.service.ts b/src/apps/work/src/lib/services/project-showcase-posts.service.ts index 66545be18..8d16fda14 100644 --- a/src/apps/work/src/lib/services/project-showcase-posts.service.ts +++ b/src/apps/work/src/lib/services/project-showcase-posts.service.ts @@ -219,6 +219,7 @@ function normalizeProjectShowcasePostMediaArray(value: unknown): ProjectShowcase id: normalizeString(item.id), type: normalizeString(item.type), url: url || '', + alt: normalizeStringOrUndefined(item.alt), } }) .filter(item => item.url) @@ -308,7 +309,7 @@ export async function updateProjectShowcasePost( industryIds?: string[] categoryIds?: string[] challengeIds?: string[] - media?: Array<{ type: string; url: string }> + media?: Array<{ type: string; url: string; alt?: string }> status?: string }, ): Promise { diff --git a/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.tsx b/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.tsx index e122e9feb..933f7859a 100644 --- a/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.tsx +++ b/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.tsx @@ -741,7 +741,7 @@ export const ProjectShowcasePage: FC = () => { return } - const uploadedMedia: Array<{ type: string; url: string }> = [] + const uploadedMedia: Array<{ type: string; url: string; alt?: string }> = [] const mediaStorePath = `project-showcase/${projectId}/` const pickerOptions: PickerOptions = { @@ -784,9 +784,15 @@ export const ProjectShowcasePage: FC = () => { return } + const altText = + typeof file.alt === 'string' && file.alt.trim() + ? file.alt.trim() + : undefined + uploadedMedia.push({ type: String(file.mimetype || 'application/octet-stream'), url: mediaUrl, + ...(altText ? { alt: altText } : {}), }) }, storeTo: { From 29ba7d13f1c77566eb90b94f1234eafc6e2033fb Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 13 Jul 2026 22:38:38 +0300 Subject: [PATCH 19/30] lint --- .../ShowcasePostMedia/ShowcasePostMedia.tsx | 5 ++++- .../ShowcasePostMedia/ShowcasePostMediaGallery.tsx | 2 +- .../work/src/lib/services/project-showcase-posts.service.ts | 2 +- .../showcase/ProjectShowcasePage/ProjectShowcasePage.tsx | 4 ++-- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMedia.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMedia.tsx index 4941e0a3c..b8d4a331a 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMedia.tsx +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMedia.tsx @@ -75,7 +75,10 @@ const ShowcasePostMedia: FC = props => { )} {(index === visibleAssets.length - 1) && (restAssetCount > 0) && ( -
+{restAssetCount+1}
+
+ + + {restAssetCount + 1} +
)} ) diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.tsx index 059c7cff8..827794ac8 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.tsx +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.tsx @@ -81,7 +81,7 @@ export function getPlaceholderLabel(extension: string, asset: ProjectShowcasePos export function getMediaAlt(asset: ProjectShowcasePostMedia): string { if (asset.alt) { - return asset.alt; + return asset.alt } const extension = getAssetExtension(asset) diff --git a/src/apps/work/src/lib/services/project-showcase-posts.service.ts b/src/apps/work/src/lib/services/project-showcase-posts.service.ts index 8d16fda14..84f5f6c2d 100644 --- a/src/apps/work/src/lib/services/project-showcase-posts.service.ts +++ b/src/apps/work/src/lib/services/project-showcase-posts.service.ts @@ -216,10 +216,10 @@ function normalizeProjectShowcasePostMediaArray(value: unknown): ProjectShowcase .map(item => { const url = normalizeStringOrUndefined(item.url) return { + alt: normalizeStringOrUndefined(item.alt), id: normalizeString(item.id), type: normalizeString(item.type), url: url || '', - alt: normalizeStringOrUndefined(item.alt), } }) .filter(item => item.url) diff --git a/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.tsx b/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.tsx index 933f7859a..02d18166c 100644 --- a/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.tsx +++ b/src/apps/work/src/pages/showcase/ProjectShowcasePage/ProjectShowcasePage.tsx @@ -784,8 +784,8 @@ export const ProjectShowcasePage: FC = () => { return } - const altText = - typeof file.alt === 'string' && file.alt.trim() + const altText + = typeof file.alt === 'string' && file.alt.trim() ? file.alt.trim() : undefined From ddf4a8091a9907842e7b9d8ee62a352db2c4d6be Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Mon, 13 Jul 2026 23:14:07 +0300 Subject: [PATCH 20/30] unique skills --- .../ProjectShowcasePostPage/ProjectShowcasePostPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.tsx index 83dd63514..42464058e 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.tsx +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.tsx @@ -1,3 +1,4 @@ +import { uniqBy } from 'lodash' import { FC, useMemo } from 'react' import { Params, useParams } from 'react-router-dom' @@ -25,7 +26,7 @@ const ProjectShowcasePostPage: FC = () => { const projectUrl = `${window.location.origin}${buildProjectUrl(routeParams.projectId as string)}` const skills = useMemo( - () => post?.challengeMetadata?.flatMap(entry => entry.skills) ?? [], + () => uniqBy(post?.challengeMetadata?.flatMap(entry => entry.skills), 'id') ?? [], [post?.challengeMetadata], ) const registrantsCount = useMemo( From 12e80a8bf29a0ccfca180e32f59646a245751436 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 14 Jul 2026 00:07:23 +0300 Subject: [PATCH 21/30] Mobile Ui --- .../ProjectShowcasePostPage.module.scss | 100 +++++++++++++++++- .../ShowcasePostChallengeList.module.scss | 36 ++++++- .../ShowcasePostChallengeList.tsx | 12 +-- 3 files changed, 135 insertions(+), 13 deletions(-) diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.module.scss b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.module.scss index 684e93bf7..8dda6222b 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.module.scss +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.module.scss @@ -66,17 +66,17 @@ display: flex; align-items: center; - gap: $sp-4; flex-wrap: wrap; > * + *:before { content: ""; border-radius: 50%; background: $black-80; - width: 12px; - height: 22px; + width: 3px; height: 3px; background: url('data:image/svg+xml,') center no-repeat; + margin-left: 16px; + margin-right: 12px; } &Item { @@ -412,3 +412,97 @@ letter-spacing: -0.2px; } } + +@media (max-width: 900px) { + .contentContainer { + padding: $sp-12; + } + + .contentHeader { + .topActions { + gap: $sp-3; + } + + .title { + font-size: 28px; + line-height: 36px; + } + + .subTitle { + flex-direction: column; + align-items: flex-start; + gap: 10px; + + > * + *:before { + display: none; + } + } + + .subTitleItem { + gap: 6px; + } + } + + .contentBodyWrap { + flex-direction: column; + gap: $sp-8; + } + + .contentBody { + order: 1; + width: 100%; + } + + .contentBodySidebar { + width: 100%; + max-width: 100%; + order: 0; + } + + .panel { + padding: $sp-3; + } + + .statsList { + flex-wrap: wrap; + gap: 12px; + } + + .skillsList { + gap: 6px; + } + + .skillsList li { + white-space: normal; + flex: 1; + } +} + +@media (max-width: 560px) { + .contentContainer { + padding: $sp-6; + } + + .contentHeader { + .title { + font-size: 22px; + line-height: 28px; + } + + .subTitleItem span { + font-size: 13px; + } + } + + .btns { + width: 100%; + + :global(.btn-size-lg) { + display: none; + } + } + + .panel { + padding: $sp-3; + } +} diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/ShowcasePostChallengeList.module.scss b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/ShowcasePostChallengeList.module.scss index 3ad0fe5d2..e22c595d6 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/ShowcasePostChallengeList.module.scss +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/ShowcasePostChallengeList.module.scss @@ -92,14 +92,22 @@ font-style: normal; font-weight: 400; line-height: 22px; /* 157.143% */ + + display: flex; + align-items: center; } } .statSeparator::before { - content: '·'; - display: inline-block; - margin: 0 8px; - color: var(--ui-text-secondary, #6b7280); + display: block; + + content: ""; + border-radius: 50%; + width: 3px; + height: 3px; + background: url('data:image/svg+xml,') center no-repeat; + margin-left: 8px; + margin-right: 8px; } .toRight { @@ -107,3 +115,23 @@ align-items: center; gap: 60px; } + +@media (max-width: 900px) { + .list .item { + flex-direction: column; + align-items: flex-start; + gap: $sp-2; + + .toRight { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } + + .action { + :global(.btn-size-lg) { + padding-left: 0; + } + } + } +} diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/ShowcasePostChallengeList.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/ShowcasePostChallengeList.tsx index a8c54c9f0..795220193 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/ShowcasePostChallengeList.tsx +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/ShowcasePostChallengeList.tsx @@ -46,18 +46,18 @@ const ShowcasePostChallengeList: FC = props => { {(typeof challenge.numOfRegistrants === 'number' || typeof challenge.numOfSubmissions === 'number') && (
- {typeof challenge.numOfRegistrants === 'number' && ( + {typeof challenge.numOfSubmissions === 'number' && ( - {challenge.numOfRegistrants} + {challenge.numOfSubmissions} {' '} - registrants + submissions )} - {typeof challenge.numOfSubmissions === 'number' && ( + {typeof challenge.numOfRegistrants === 'number' && ( - {challenge.numOfSubmissions} + {challenge.numOfRegistrants} {' '} - submissions + registrants )}
From 531b85bb757401f31193f21e18150ab78c0baf45 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 14 Jul 2026 14:29:53 +1000 Subject: [PATCH 22/30] Fix for flexi-talent engagements link (PM-5580) --- .../customer-portal/src/lib/services/flexiTalent.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/customer-portal/src/lib/services/flexiTalent.service.ts b/src/apps/customer-portal/src/lib/services/flexiTalent.service.ts index 1d8f703ad..f96eba19c 100644 --- a/src/apps/customer-portal/src/lib/services/flexiTalent.service.ts +++ b/src/apps/customer-portal/src/lib/services/flexiTalent.service.ts @@ -282,7 +282,7 @@ function buildFlexiEngagementWorkLinks( } if (normalizedEngagementId) { - links.engagementUrl = `${baseUrl}/projects/${normalizedProjectId}/engagements/${normalizedEngagementId}/view` + links.engagementUrl = `${baseUrl}/projects/${normalizedProjectId}/engagements/${normalizedEngagementId}` links.assigneeDetailsUrl = `${baseUrl}/projects/${normalizedProjectId}/engagements/${normalizedEngagementId}/assignments` } From 4147822272a162cb966510ba4dc34ac07fb58e9d Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 14 Jul 2026 14:50:31 +1000 Subject: [PATCH 23/30] Fixes for PM-5585 and PM-5604 --- .../EngagementsView/EngagementsView.spec.tsx | 47 +++++++ .../EngagementsView/EngagementsView.tsx | 18 +-- .../MembersView/MembersView.spec.tsx | 119 ++++++++++++++++++ .../components/MembersView/MembersView.tsx | 13 +- 4 files changed, 180 insertions(+), 17 deletions(-) create mode 100644 src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/MembersView.spec.tsx diff --git a/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.spec.tsx b/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.spec.tsx index 92e82c9dd..b1f2330c3 100644 --- a/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.spec.tsx +++ b/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.spec.tsx @@ -3,8 +3,10 @@ import '@testing-library/jest-dom' import React from 'react' import { + fireEvent, render, screen, + waitFor, } from '@testing-library/react' import { @@ -148,4 +150,49 @@ describe('EngagementsView', () => { expect(screen.queryByText('38 days overdue')) .not.toBeInTheDocument() }) + + it('refreshes engagement bucket counts whenever the list refreshes', async () => { + mockGetFlexiEngagementSummary + .mockResolvedValueOnce({ + active: 1, + closed: 0, + total: 1, + }) + .mockResolvedValueOnce({ + active: 2, + closed: 0, + total: 2, + }) + mockGetFlexiEngagementList + .mockResolvedValueOnce({ + data: [], + page: 1, + perPage: 10, + total: 1, + totalPages: 1, + }) + .mockResolvedValueOnce({ + data: [], + page: 1, + perPage: 10, + total: 2, + totalPages: 1, + }) + + render() + + expect(await screen.findByRole('button', { name: 'Active 1' })) + .toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /Name/ })) + + await waitFor(() => { + expect(mockGetFlexiEngagementSummary) + .toHaveBeenCalledTimes(2) + }) + expect(await screen.findByRole('button', { name: 'Active 2' })) + .toBeInTheDocument() + expect(screen.getByText('2 engagements')) + .toBeInTheDocument() + }) }) diff --git a/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.tsx b/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.tsx index 87621345e..edb15a276 100644 --- a/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.tsx +++ b/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.tsx @@ -263,7 +263,9 @@ export const EngagementsView: FC = () => { }, []) /** - * Loads bucket counts once for the left rail, independent of list filters. + * Loads bucket counts for the left rail whenever the engagement list refreshes. + * The endpoint remains independent of list filters, but refreshing both data + * sources together keeps their displayed totals current and consistent. * * @returns A promise that resolves after summary state is updated. */ @@ -420,12 +422,9 @@ export const EngagementsView: FC = () => { useEffect(() => { fetchEngagementSummary() .catch(() => undefined) - }, [fetchEngagementSummary]) - - useEffect(() => { refreshEngagementList() .catch(() => undefined) - }, [refreshEngagementList]) + }, [fetchEngagementSummary, refreshEngagementList]) useEffect(() => () => { debouncedApplySearch.cancel() @@ -530,6 +529,9 @@ export const EngagementsView: FC = () => { const selectedDetailTitle = selectedEngagementRow ? selectedEngagementRow.engagementTitle : 'Selected engagement' + const listTotalLabel = isListLoading ? '--' : String(listData.total) + const listPageLabel = isListLoading ? '--' : String(listData.page) + const listTotalPagesLabel = isListLoading ? '--' : String(listData.totalPages) return (
@@ -635,14 +637,14 @@ export const EngagementsView: FC = () => {
- {listData.total} + {listTotalLabel} {' engagements'} {'Page '} - {listData.page} + {listPageLabel} {' of '} - {listData.totalPages} + {listTotalPagesLabel}
diff --git a/src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/MembersView.spec.tsx b/src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/MembersView.spec.tsx new file mode 100644 index 000000000..87e47d1fc --- /dev/null +++ b/src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/MembersView.spec.tsx @@ -0,0 +1,119 @@ +/* eslint-disable import/no-extraneous-dependencies, ordered-imports/ordered-imports, sort-keys */ +import '@testing-library/jest-dom' + +import React from 'react' +import { + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' + +import { + getFlexiMemberDetail, + getFlexiMemberList, + getFlexiMemberSummary, +} from '../../../../lib' + +import { MembersView } from './MembersView' + +const mockGetFlexiMemberSummary = getFlexiMemberSummary as jest.Mock +const mockGetFlexiMemberList = getFlexiMemberList as jest.Mock +const mockGetFlexiMemberDetail = getFlexiMemberDetail as jest.Mock + +jest.mock('~/apps/admin/src/lib/components/common/Pagination', () => ({ + Pagination: () =>
pagination
, +}), { virtual: true }) + +jest.mock('~/libs/shared/lib/utils/rich-text', () => ({ + renderRichTextToHtml: jest.fn(() => ''), +}), { virtual: true }) + +jest.mock('~/libs/ui', () => ({ + IconOutline: { + ArrowDownIcon: () => arrow-down-icon, + ArrowUpIcon: () => arrow-up-icon, + ClockIcon: () => clock-icon, + DocumentSearchIcon: () => document-search-icon, + ExclamationCircleIcon: () => error-icon, + ExternalLinkIcon: () => external-link-icon, + InboxIcon: () => inbox-icon, + SearchIcon: () => search-icon, + XIcon: () => x-icon, + }, +}), { virtual: true }) + +jest.mock('../../../../lib', () => ({ + getFlexiMemberDetail: jest.fn(), + getFlexiMemberList: jest.fn(), + getFlexiMemberSummary: jest.fn(), +})) + +jest.mock('../MemberHistoryModal', () => ({ + MemberHistoryModal: () => null, +})) + +describe('MembersView', () => { + beforeEach(() => { + jest.clearAllMocks() + + mockGetFlexiMemberSummary.mockResolvedValue({ + assignedMembers: 1, + completedMembers: 0, + totalUniqueMembers: 1, + }) + mockGetFlexiMemberList.mockResolvedValue({ + data: [], + page: 1, + perPage: 10, + total: 1, + totalPages: 1, + }) + mockGetFlexiMemberDetail.mockResolvedValue({}) + }) + + it('refreshes member bucket counts whenever the list refreshes', async () => { + mockGetFlexiMemberSummary + .mockResolvedValueOnce({ + assignedMembers: 1, + completedMembers: 0, + totalUniqueMembers: 1, + }) + .mockResolvedValueOnce({ + assignedMembers: 2, + completedMembers: 0, + totalUniqueMembers: 2, + }) + mockGetFlexiMemberList + .mockResolvedValueOnce({ + data: [], + page: 1, + perPage: 10, + total: 1, + totalPages: 1, + }) + .mockResolvedValueOnce({ + data: [], + page: 1, + perPage: 10, + total: 2, + totalPages: 1, + }) + + render() + + expect(await screen.findByRole('button', { name: 'Total Unique Members 1' })) + .toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /Handle/ })) + + await waitFor(() => { + expect(mockGetFlexiMemberSummary) + .toHaveBeenCalledTimes(2) + }) + expect(await screen.findByRole('button', { name: 'Total Unique Members 2' })) + .toBeInTheDocument() + expect(screen.getByText('2 members')) + .toBeInTheDocument() + }) +}) diff --git a/src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/MembersView.tsx b/src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/MembersView.tsx index 5d60cb144..067db8e96 100644 --- a/src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/MembersView.tsx +++ b/src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/MembersView.tsx @@ -374,7 +374,9 @@ export const MembersView: FC = props => { ], [summaryData]) /** - * Loads member bucket counts once for the left rail, independent of list filters. + * Loads member bucket counts for the left rail whenever the member list refreshes. + * The endpoint remains independent of list filters, but refreshing both data + * sources together keeps their displayed totals current and consistent. * * @returns A promise that resolves after summary state is updated. */ @@ -533,16 +535,9 @@ export const MembersView: FC = props => { fetchMemberSummary() .catch(() => undefined) - }, [fetchMemberSummary, hasActivated]) - - useEffect(() => { - if (!hasActivated) { - return - } - refreshMemberList() .catch(() => undefined) - }, [hasActivated, refreshMemberList]) + }, [fetchMemberSummary, hasActivated, refreshMemberList]) useEffect(() => () => { debouncedApplySearch.cancel() From c78381f5425e95cc3b7f0f5ecbc432594966b4de Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 14 Jul 2026 15:04:14 +1000 Subject: [PATCH 24/30] Lint --- .../flexi-talent/components/MembersView/MembersView.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/MembersView.spec.tsx b/src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/MembersView.spec.tsx index 87e47d1fc..0ad3d2bda 100644 --- a/src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/MembersView.spec.tsx +++ b/src/apps/customer-portal/src/pages/flexi-talent/components/MembersView/MembersView.spec.tsx @@ -50,7 +50,7 @@ jest.mock('../../../../lib', () => ({ })) jest.mock('../MemberHistoryModal', () => ({ - MemberHistoryModal: () => null, + MemberHistoryModal: () => undefined, })) describe('MembersView', () => { From e7949dd837404aeb37d1d5e8a15120e7fcbc7717 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 14 Jul 2026 15:30:15 +1000 Subject: [PATCH 25/30] Fix for timeline issue noted --- .../form/StartDateTimeInput/StartDateTimeInput.module.scss | 4 ---- .../work/src/pages/challenges/ChallengeEditorPage/README.md | 2 +- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/apps/work/src/lib/components/form/StartDateTimeInput/StartDateTimeInput.module.scss b/src/apps/work/src/lib/components/form/StartDateTimeInput/StartDateTimeInput.module.scss index 6e05df155..48f77f942 100644 --- a/src/apps/work/src/lib/components/form/StartDateTimeInput/StartDateTimeInput.module.scss +++ b/src/apps/work/src/lib/components/form/StartDateTimeInput/StartDateTimeInput.module.scss @@ -13,10 +13,6 @@ } .externalLabelDatePicker { - :global(.input-el > label > div:first-child) { - display: none; - } - :global(.input-el) { align-items: center; border-color: $black-20; diff --git a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md index 56667c73a..fd993d139 100644 --- a/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md +++ b/src/apps/work/src/pages/challenges/ChallengeEditorPage/README.md @@ -67,7 +67,7 @@ The form uses `challengeBasicInfoSchema` from `src/apps/work/src/lib/schemas/cha Engineering, and other active API-only/internal challenge types stay hidden from the create dropdown, and any now-invalid preselection is cleared when the track changes. Deployments can override the allowlist with `REACT_APP_WORK_CREATE_CHALLENGE_TYPES_BY_TRACK` JSON. -- `ChallengeScheduleSection`: schedule editor for challenge start and phase dates. It keeps the detected timezone above the controls, renders the `Start Date` label with the `Scheduled` and `Immediately` start-mode radios aligned to the end of that header row above the input with a green selected state, persists the selected start mode in challenge metadata so saved `/edit` and `/view` routes reopen with the correct radio state, initializes missing challenge start dates from existing phase starts or the current date before calculating blank phase rows, recalculates root phase dates when the challenge start changes, honors completed phases' actual dates when deriving and displaying schedule rows, lets incomplete active Design phases be shortened no earlier than the current date/time, prevents incomplete active non-Design phases from being shortened, and keeps completed phases' end-date and duration controls locked to match legacy work-manager behavior. `Task` challenges hide this editable section across create, edit, and read-only view routes to match legacy work-manager behavior. +- `ChallengeScheduleSection`: schedule editor for challenge start and phase dates. It keeps the detected timezone above the controls, renders the `Start Date` label with the `Scheduled` and `Immediately` start-mode radios aligned to the end of that header row above the input with a green selected state, keeps outside-label date-picker controls visible and interactive when the shared input wrapper omits an empty internal label, persists the selected start mode in challenge metadata so saved `/edit` and `/view` routes reopen with the correct radio state, initializes missing challenge start dates from existing phase starts or the current date before calculating blank phase rows, recalculates root phase dates when the challenge start changes, honors completed phases' actual dates when deriving and displaying schedule rows, lets incomplete active Design phases be shortened no earlier than the current date/time, prevents incomplete active non-Design phases from being shortened, and keeps completed phases' end-date and duration controls locked to match legacy work-manager behavior. `Task` challenges hide this editable section across create, edit, and read-only view routes to match legacy work-manager behavior. - `DesignWorkTypeField`: shown for Design + Challenge, with the legacy work-type options (`Application Front-End Design`, `Print/Presentation`, `Web Design`, `Widget or Mobile Screen Design`, `Wireframes`). The selected value is stored in challenge tags. - `FunChallengeField`: shown for `Marathon Match` type and remains editable after creation so the form can switch between fun-challenge and standard marathon-match fields. - `ReviewersField`: hidden for `Task` and `Marathon Match` challenges because manual reviewer assignment is handled elsewhere. On the human-review tab, each manual reviewer card keeps the legacy review-type dropdown, backfills missing legacy review-type values from the matching default reviewer or iterative-review phase fallback, and each manual reviewer phase selector hides registration/submission phases and any phase already assigned on another manual reviewer card while preserving the card's current selection. When default reviewer metadata is missing, stale, or already covered by existing rows, `Add reviewer` starts from the next unassigned selectable reviewer phase, preferring review phases before approval or screening phases, so single-round Design schedules add the Approver row instead of a registration/submission or duplicate reviewer row. Manual reviewer counts are capped before rendering member assignment controls so closed public opportunities cannot create an unbounded number of member selectors. Design challenge manual reviewers always keep the public review opportunity checkbox disabled and unchecked. From 53dee706011ae02743fc490dfc922d82e4a10a41 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 14 Jul 2026 08:34:08 +0300 Subject: [PATCH 26/30] PM-5255 - customer portal qa fixes --- .../ProjectShowcaseCard.module.scss | 4 +-- .../ProjectShowcasePostPage.module.scss | 20 ++++++++---- .../ProjectShowcasePostPage.tsx | 5 +-- .../ShowcasePostChallengeList.module.scss | 4 +-- src/config/environments/default.env.ts | 1 + .../environments/global-config.model.ts | 1 + src/libs/shared/lib/utils/rich-text.spec.ts | 7 ++++ src/libs/shared/lib/utils/rich-text.ts | 32 ++++++++++++++++++- 8 files changed, 60 insertions(+), 14 deletions(-) diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.module.scss b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.module.scss index 4654c6857..d92c8a00b 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.module.scss +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.module.scss @@ -24,11 +24,11 @@ border-radius: 2px; font-size: 11px; - font-weight: 500; + font-weight: 600; line-height: 10px; background: $tc-white; - color: #161616; + color: #0A0A0A; border: 1px solid #a8a8a8; font-family: "Nunito Sans", sans-serif; diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.module.scss b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.module.scss index 8dda6222b..70b8589d7 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.module.scss +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.module.scss @@ -108,24 +108,29 @@ border-radius: 2px; font-size: 11px; - font-weight: 500; + font-weight: 600; line-height: 10px; font-family: "Nunito Sans", sans-serif; background: $tc-white; - color: #161616; + color: #0A0A0A; border: 1px solid #a8a8a8; } .htmlContent { - color: $black-80; + all: revert-layer; + color: $black-100; font-feature-settings: 'liga' off, 'clig' off; font-family: Roboto; - font-size: 14px; - line-height: 22px; + font-size: 16px; + line-height: 24px; overflow-wrap: anywhere; + * { + all: revert-layer; + } + > :first-child { margin-top: 0; } @@ -175,6 +180,7 @@ color: $black-100; margin: 0 0 16px; font-weight: 700; + text-transform: uppercase; } h1 { @@ -188,8 +194,8 @@ } h3 { - font-size: 20px; - line-height: 28px; + font-size: 18px; + line-height: 22px; } h4, diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.tsx index 42464058e..d2ec7090c 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.tsx +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.tsx @@ -6,16 +6,17 @@ import { IconOutline, LinkButton, PageTitle } from '~/libs/ui' import { renderRichTextToHtml } from '~/libs/shared/lib/utils/rich-text' import { textFormatDateLocaleShortString } from '~/libs/shared/lib/utils/text-format' import { - buildProjectUrl, useFetchProjectShowcasePost, UseFetchProjectShowcasePostResult, } from '~/apps/work/src/lib' +import { EnvironmentConfig } from '~/config' import { showcaseRootRoute } from '../project-showcase.routes' import { ShowcasePostMedia } from './ShowcasePostMedia' import { ShowcasePostChallengeList } from './ShowcasePostChallengeList' import styles from './ProjectShowcasePostPage.module.scss' +import { projectsRouteId } from '~/apps/work/src/config/routes.config' const ProjectShowcasePostPage: FC = () => { const routeParams: Params = useParams() @@ -23,7 +24,7 @@ const ProjectShowcasePostPage: FC = () => { = useFetchProjectShowcasePost(routeParams.projectId, routeParams.postId) const industries = useMemo(() => post?.industries.map(ind => ind.name) .join(', '), [post?.industries]) - const projectUrl = `${window.location.origin}${buildProjectUrl(routeParams.projectId as string)}` + const projectUrl = `${EnvironmentConfig.URLS.WORK_APP}/${projectsRouteId}/${encodeURIComponent(routeParams.projectId as string)}` const skills = useMemo( () => uniqBy(post?.challengeMetadata?.flatMap(entry => entry.skills), 'id') ?? [], diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/ShowcasePostChallengeList.module.scss b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/ShowcasePostChallengeList.module.scss index e22c595d6..12e8f215d 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/ShowcasePostChallengeList.module.scss +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/ShowcasePostChallengeList.module.scss @@ -51,11 +51,11 @@ font-size: 11px; font-style: normal; - font-weight: 500; + font-weight: 600; line-height: 10px; /* 90.909% */ background: $tc-white; - color: #161616; + color: #0A0A0A; border: 1px solid #a8a8a8; width: max-content; diff --git a/src/config/environments/default.env.ts b/src/config/environments/default.env.ts index 19395eb96..a30ba376f 100644 --- a/src/config/environments/default.env.ts +++ b/src/config/environments/default.env.ts @@ -210,6 +210,7 @@ export const URLS = { CHALLENGES_PAGE: `${TOPCODER_URL}/challenges`, UNIVERSAL_NAV: `https://uni-nav.${TC_DOMAIN}/v1/tc-universal-nav.js`, USER_PROFILE: `https://profiles.${TC_DOMAIN}`, + WORK_APP: `https://work.${TC_DOMAIN}`, } export const MEMBER_VERIFY_LOOKER = getReactEnv( diff --git a/src/config/environments/global-config.model.ts b/src/config/environments/global-config.model.ts index 71eeabd53..512e9aa6e 100644 --- a/src/config/environments/global-config.model.ts +++ b/src/config/environments/global-config.model.ts @@ -57,6 +57,7 @@ export interface GlobalConfig { ACCOUNT_SETTINGS: string UNIVERSAL_NAV: string CHALLENGES_PAGE: string + WORK_APP: string } TERMS_URL?: string NDA_TERMS_URL?: string diff --git a/src/libs/shared/lib/utils/rich-text.spec.ts b/src/libs/shared/lib/utils/rich-text.spec.ts index ee27bc406..a0896305f 100644 --- a/src/libs/shared/lib/utils/rich-text.spec.ts +++ b/src/libs/shared/lib/utils/rich-text.spec.ts @@ -34,4 +34,11 @@ describe('rich-text utils', () => { expect(plainText) .toContain('- Second') }) + + it('renders links with target blank and noopener noreferrer rel', () => { + const rendered = renderRichTextToHtml('[Visit example](https://example.com)') + + expect(rendered).toContain('target="_blank"') + expect(rendered).toContain('rel="noopener noreferrer"') + }) }) diff --git a/src/libs/shared/lib/utils/rich-text.ts b/src/libs/shared/lib/utils/rich-text.ts index 3364a2559..c2719d596 100644 --- a/src/libs/shared/lib/utils/rich-text.ts +++ b/src/libs/shared/lib/utils/rich-text.ts @@ -72,6 +72,34 @@ export function sanitizeRichTextSource(value: string): string { return String(DOMPurify.sanitize(normalizedValue, RICH_TEXT_SANITIZE_OPTIONS as any)) } +function enforceLinkTargetBlank(html: string): string { + return html.replace(/]*)>/gi, (match, attrs) => { + let updatedAttrs = attrs + + if (/target\s*=\s*/i.test(updatedAttrs)) { + updatedAttrs = updatedAttrs.replace(/target\s*=\s*(['"]?)[^'"\s>]*\1/i, 'target="_blank"') + } else { + updatedAttrs += ' target="_blank"' + } + + if (/rel\s*=\s*/i.test(updatedAttrs)) { + updatedAttrs = updatedAttrs + .replace(/rel\s*=\s*(['"])(.*?)\1/i, (_match: string, quote: string, value: string) => { + const relValues = value.split(/\s+/) + .filter(Boolean) + const required = ['noopener', 'noreferrer'] + const finalRel = Array.from(new Set([...relValues, ...required])) + .join(' ') + return `rel=${quote}${finalRel}${quote}` + }) + } else { + updatedAttrs += ' rel="noopener noreferrer"' + } + + return `` + }) +} + /** * Converts markdown or HTML content into sanitized HTML suitable for rich text editors and * rendered detail views. @@ -91,7 +119,9 @@ export function renderRichTextToHtml(value: string): string { gfm: true, }) as string - return String(DOMPurify.sanitize(renderedHtml, RICH_TEXT_SANITIZE_OPTIONS as any)) + const sanitizedRenderedHtml = String(DOMPurify.sanitize(renderedHtml, RICH_TEXT_SANITIZE_OPTIONS as any)) + + return enforceLinkTargetBlank(sanitizedRenderedHtml) .trim() } From 18d7aa45b10f022d5419718a92e918c009fb77a9 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Tue, 14 Jul 2026 08:38:00 +0300 Subject: [PATCH 27/30] lint --- .../ProjectShowcasePostPage.module.scss | 2 +- .../ProjectShowcasePostPage/ProjectShowcasePostPage.tsx | 8 ++++++-- src/libs/shared/lib/utils/rich-text.spec.ts | 6 ++++-- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.module.scss b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.module.scss index 70b8589d7..19b55a736 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.module.scss +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.module.scss @@ -162,7 +162,7 @@ } a { - color: $turq-160; + color: $link-blue-dark; font-weight: 700; text-decoration: none; diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.tsx index d2ec7090c..b75cb1ed7 100644 --- a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.tsx +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.tsx @@ -10,13 +10,13 @@ import { UseFetchProjectShowcasePostResult, } from '~/apps/work/src/lib' import { EnvironmentConfig } from '~/config' +import { projectsRouteId } from '~/apps/work/src/config/routes.config' import { showcaseRootRoute } from '../project-showcase.routes' import { ShowcasePostMedia } from './ShowcasePostMedia' import { ShowcasePostChallengeList } from './ShowcasePostChallengeList' import styles from './ProjectShowcasePostPage.module.scss' -import { projectsRouteId } from '~/apps/work/src/config/routes.config' const ProjectShowcasePostPage: FC = () => { const routeParams: Params = useParams() @@ -24,7 +24,11 @@ const ProjectShowcasePostPage: FC = () => { = useFetchProjectShowcasePost(routeParams.projectId, routeParams.postId) const industries = useMemo(() => post?.industries.map(ind => ind.name) .join(', '), [post?.industries]) - const projectUrl = `${EnvironmentConfig.URLS.WORK_APP}/${projectsRouteId}/${encodeURIComponent(routeParams.projectId as string)}` + const projectUrl = [ + EnvironmentConfig.URLS.WORK_APP, + projectsRouteId, + encodeURIComponent(routeParams.projectId as string), + ].join('/') const skills = useMemo( () => uniqBy(post?.challengeMetadata?.flatMap(entry => entry.skills), 'id') ?? [], diff --git a/src/libs/shared/lib/utils/rich-text.spec.ts b/src/libs/shared/lib/utils/rich-text.spec.ts index a0896305f..63857d5d0 100644 --- a/src/libs/shared/lib/utils/rich-text.spec.ts +++ b/src/libs/shared/lib/utils/rich-text.spec.ts @@ -38,7 +38,9 @@ describe('rich-text utils', () => { it('renders links with target blank and noopener noreferrer rel', () => { const rendered = renderRichTextToHtml('[Visit example](https://example.com)') - expect(rendered).toContain('target="_blank"') - expect(rendered).toContain('rel="noopener noreferrer"') + expect(rendered) + .toContain('target="_blank"') + expect(rendered) + .toContain('rel="noopener noreferrer"') }) }) From 130268b9b4677e45558bd5f50540905bc90193d0 Mon Sep 17 00:00:00 2001 From: Justin Gasper Date: Tue, 14 Jul 2026 15:44:27 +1000 Subject: [PATCH 28/30] Additional fix for PM-5586 --- .../about-me/AboutMe.module.scss | 4 + .../src/member-profile/about-me/AboutMe.tsx | 66 +++++++++++++--- .../about-me/AboutMe.utils.spec.ts | 79 ++++--------------- .../member-profile/about-me/AboutMe.utils.ts | 70 ++-------------- 4 files changed, 84 insertions(+), 135 deletions(-) diff --git a/src/apps/profiles/src/member-profile/about-me/AboutMe.module.scss b/src/apps/profiles/src/member-profile/about-me/AboutMe.module.scss index 34ff88d75..6e54baa58 100644 --- a/src/apps/profiles/src/member-profile/about-me/AboutMe.module.scss +++ b/src/apps/profiles/src/member-profile/about-me/AboutMe.module.scss @@ -25,6 +25,10 @@ } } +.bioCollapsed { + @include text-clamp(5); +} + .bioToggle { appearance: none; background: transparent; diff --git a/src/apps/profiles/src/member-profile/about-me/AboutMe.tsx b/src/apps/profiles/src/member-profile/about-me/AboutMe.tsx index 10196bf77..9d0394aae 100644 --- a/src/apps/profiles/src/member-profile/about-me/AboutMe.tsx +++ b/src/apps/profiles/src/member-profile/about-me/AboutMe.tsx @@ -1,4 +1,14 @@ -import { Dispatch, FC, SetStateAction, useEffect, useMemo, useState } from 'react' +import { + Dispatch, + FC, + SetStateAction, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' import { useSearchParams } from 'react-router-dom' import { KeyedMutator } from 'swr' import classNames from 'classnames' @@ -11,7 +21,7 @@ import { canSeePhones, getFirstProfileSelfTitle } from '../../lib/helpers' import { CommunityAwards } from '../community-awards' import { Phones } from '../phones' -import { getTruncatedBio, TruncatedBio } from './AboutMe.utils' +import { isBioOverflowing } from './AboutMe.utils' import { ModifyAboutMeModal } from './ModifyAboutMeModal' import MemberRatingCard from './MemberRatingCard/MemberRatingCard' import styles from './AboutMe.module.scss' @@ -40,13 +50,11 @@ const AboutMe: FC = (props: AboutMeProps) => { [memberPersonalizationTraits], ) - const truncatedBio: TruncatedBio = useMemo( - () => getTruncatedBio(props.profile.description), - [props.profile.description], - ) - const [isBioExpanded, setIsBioExpanded]: [boolean, Dispatch>] = useState(false) + const [isBioTruncated, setIsBioTruncated]: [boolean, Dispatch>] + = useState(false) + const bioRef = useRef(null) const hasEmptyDescription = useMemo(() => ( props.profile && !props.profile.description @@ -63,6 +71,41 @@ const AboutMe: FC = (props: AboutMeProps) => { setIsBioExpanded(false) }, [props.profile.description]) + /** + * Re-evaluates whether the collapsed bio has content hidden by its line clamp. + * Used after rendering and whenever the bio element is resized. + * + * @returns {void} Updates the bio truncation state when the collapsed element is available. + * @throws This callback does not raise exceptions. + */ + const updateBioTruncation = useCallback((): void => { + if (!bioRef.current || isBioExpanded) { + return + } + + setIsBioTruncated(isBioOverflowing(bioRef.current)) + }, [isBioExpanded]) + + useLayoutEffect(() => { + updateBioTruncation() + + if (!bioRef.current || isBioExpanded) { + return undefined + } + + const resizeObserver = typeof ResizeObserver === 'undefined' + ? undefined + : new ResizeObserver(updateBioTruncation) + + resizeObserver?.observe(bioRef.current) + window.addEventListener('resize', updateBioTruncation) + + return () => { + resizeObserver?.disconnect() + window.removeEventListener('resize', updateBioTruncation) + } + }, [isBioExpanded, props.profile.description, updateBioTruncation]) + const canEdit: boolean = props.authProfile?.handle === props.profile.handle function handleEditClick(): void { @@ -134,8 +177,13 @@ const AboutMe: FC = (props: AboutMeProps) => { )} {!hasEmptyDescription && (
-

{isBioExpanded ? props.profile.description : truncatedBio.text}

- {truncatedBio.isTruncated && ( +

+ {props.profile.description} +

+ {isBioTruncated && (