diff --git a/src/apps/customer-portal/src/config/routes.config.ts b/src/apps/customer-portal/src/config/routes.config.ts index d9493115a..e27859747 100644 --- a/src/apps/customer-portal/src/config/routes.config.ts +++ b/src/apps/customer-portal/src/config/routes.config.ts @@ -9,4 +9,5 @@ export const rootRoute: string : `/${AppSubdomain.customer}` export const talentSearchRouteId = 'talent-search' +export const showcaseSearchRouteId = 'showcase' export const flexiTalentRouteId = 'flexi-talent' diff --git a/src/apps/customer-portal/src/customer-portal.routes.tsx b/src/apps/customer-portal/src/customer-portal.routes.tsx index 27d744774..badc4f77b 100644 --- a/src/apps/customer-portal/src/customer-portal.routes.tsx +++ b/src/apps/customer-portal/src/customer-portal.routes.tsx @@ -16,6 +16,7 @@ import { } from './config/routes.config' import { customerPortalFlexiTalentRoutes } from './pages/flexi-talent/flexi-talent.routes' import { customerPortalTalentSearchRoutes } from './pages/talent-search/talent-search.routes' +import { customerPortalProjectShowcaseRoutes } from './pages/project-showcase/project-showcase.routes' const CustomerPortalApp: LazyLoadedComponent = lazyLoad(() => import('./CustomerPortalApp')) @@ -32,6 +33,7 @@ export const customerPortalRoutes: ReadonlyArray = [ route: '', }, ...customerPortalTalentSearchRoutes, + ...customerPortalProjectShowcaseRoutes, ...customerPortalFlexiTalentRoutes, ], domain: AppSubdomain.customer, 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/components/NavTabs/config/tabs-config.ts b/src/apps/customer-portal/src/lib/components/NavTabs/config/tabs-config.ts index 033e78776..6a831b4d8 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 @@ -3,6 +3,7 @@ import _ from 'lodash' import { TabsNavItem } from '~/libs/ui' import { flexiTalentRouteId, + showcaseSearchRouteId, talentSearchRouteId, } from '~/apps/customer-portal/src/config/routes.config' @@ -12,6 +13,9 @@ export function getTabsConfig(userRoles: string[], isAnonymous: boolean, isUnpri ...(!isUnprivilegedUser ? [{ id: talentSearchRouteId, title: 'Talent Search', + }, { + id: showcaseSearchRouteId, + title: 'Showcase', }, { id: flexiTalentRouteId, title: 'Flexi-Talent', 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..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,7 +1,8 @@ +/* eslint-disable react/no-array-index-key */ /** * 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 +21,91 @@ 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/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` } 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/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 = () => {
-
+
) } 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..b1f2330c3 --- /dev/null +++ b/src/apps/customer-portal/src/pages/flexi-talent/components/EngagementsView/EngagementsView.spec.tsx @@ -0,0 +1,198 @@ +/* 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 { + 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() + }) + + 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 4d83b0e64..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 @@ -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. @@ -251,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. */ @@ -408,12 +422,9 @@ export const EngagementsView: FC = () => { useEffect(() => { fetchEngagementSummary() .catch(() => undefined) - }, [fetchEngagementSummary]) - - useEffect(() => { refreshEngagementList() .catch(() => undefined) - }, [refreshEngagementList]) + }, [fetchEngagementSummary, refreshEngagementList]) useEffect(() => () => { debouncedApplySearch.cancel() @@ -518,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 (
@@ -549,7 +563,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.

@@ -623,14 +637,14 @@ export const EngagementsView: FC = () => {
- {listData.total} + {listTotalLabel} {' engagements'} {'Page '} - {listData.page} + {listPageLabel} {' of '} - {listData.totalPages} + {listTotalPagesLabel}
@@ -806,16 +820,18 @@ export const EngagementsView: FC = () => {
-
-
Time Left
-
- {formatTimeLeft(assignment)} -
-
+ {isCurrentAssignmentStatus(assignment.status) && ( +
+
Time Left
+
+ {formatTimeLeft(assignment)} +
+
+ )}
Duration
{assignment.durationLabel || 'Not set'}
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..0ad3d2bda --- /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: () => undefined, +})) + +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() 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..d92c8a00b --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.module.scss @@ -0,0 +1,118 @@ +@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: 600; + line-height: 10px; + + background: $tc-white; + color: #0A0A0A; + border: 1px solid #a8a8a8; + + font-family: "Nunito Sans", sans-serif; + + // &: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; + // } + } +} + +.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); +} + +.button { + margin-top: auto; +} + +.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..ae002d240 --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcaseCard/ProjectShowcaseCard.tsx @@ -0,0 +1,52 @@ +import { FC } from 'react' +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' + +interface ProjectShowcaseCardProps { + post: ProjectShowcasePost +} + +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/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 new file mode 100644 index 000000000..7df019483 --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.module.scss @@ -0,0 +1,168 @@ +@import '@libs/ui/styles/includes'; + +.pageContainer { + display: flex; + flex-direction: column; + gap: 20px; +} + +.filters { + display: flex; + flex-direction: column; + gap: 16px; +} + +.searchInput { + display: flex; + flex-direction: column; +} + +.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(2, minmax(0, 1fr)); + gap: 20px; +} + + + +.loadMoreWrapper { + display: flex; + justify-content: center; +} + +.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-100; + + h4 { + margin: 0 0 $sp-2; + color: $black-100; + } + + p { + margin: 0 0 $sp-1; + max-width: 500px; + } +} + +.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)); + } +} + +@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..4cb95d8ad --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePage/ProjectShowcasePage.tsx @@ -0,0 +1,286 @@ +/* eslint-disable complexity */ +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 { + fetchProjectShowcasePosts, + FetchProjectShowcasePostsParams, + ProjectShowcasePostCategory, + ProjectShowcasePostIndustry, + useFetchProjectShowcasePostCategories, + useFetchProjectShowcasePostIndustries, + useFetchProjectShowcasePosts, + UseFetchProjectShowcasePostsResult, +} from '~/apps/work/src/lib' + +import { PageWrapper } from '../../../lib/components' +import { ProjectShowcaseCard } from '../ProjectShowcaseCard' + +import styles from './ProjectShowcasePage.module.scss' + +const PAGE_SIZE = 12 + +const sortOptions = [ + { label: 'Newest', value: 'desc' }, + { label: 'Oldest', value: 'asc' }, +] + +function normalizeTaxonomyOption( + item: ProjectShowcasePostCategory | ProjectShowcasePostIndustry, +): {label: string; value: string} { + return { label: item.name, value: item.id } +} + +function createTaxonomyOptions(items: Array): { + label: string; + value: string; +}[] { + 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 = 'publishedAt' + 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() + + 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, + projectId: '', + sortBy, + sortOrder, + status: 'PUBLISHED', + }), [keyword, selectedIndustries, selectedCategories, page, sortOrder]) + + const postsResult: UseFetchProjectShowcasePostsResult = useFetchProjectShowcasePosts(filters) + const { mutate }: FullConfiguration = 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, sortOrder]) + + 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 handleSortOrderChange = useCallback((event: ChangeEvent) => { + setSortOrder(event.target.value as 'asc' | 'desc') + }, []) + + const handleClearFilters = useCallback(() => { + setKeyword('') + setSelectedIndustries([]) + setSelectedCategories([]) + setPage(1) + }, []) + + 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 || '', + 'PUBLISHED', + 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]) + + return ( + +
+ Search Showcases +
+
+ +
+ + +
+
+ Filter +
+ + + +
+
+ + )} + > +
+
+
+ {postsResult.posts.length > 0 && !postsResult.isLoading && ( + + {hasFiltersApplied ? 'We have found' : ''} + {' '} + + {postsResult.metadata.total} + {' '} + {!hasFiltersApplied && 'total'} + {hasFiltersApplied && ( + postsResult.metadata.total === 1 ? 'showcase' : 'showcases' + )} + + {' '} + + {hasFiltersApplied ? 'that match your search.' : 'showcases'} + + + )} +
+ + +
+ +
+ {!postsResult.posts.length && !postsResult.isLoading && ( +
+ No showcase posts match your search. +
+ )} + + {postsResult.posts.length > 0 && ( +
+ {postsResult.posts.map(post => ( + + ))} +
+ )} + + {postsResult.isLoading && ( +
+ +
+ )} + + {!loadMoreDisabled && postsResult.posts.length > 0 && ( +
+
+ )} +
+
+
+ ) +} + +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/ProjectShowcasePostPage/ProjectShowcasePostPage.module.scss b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.module.scss new file mode 100644 index 000000000..e43ee7057 --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.module.scss @@ -0,0 +1,536 @@ +@import '@libs/ui/styles/includes'; + + +.hero { + position: absolute; + top: 0; + right: 0; + left: 0; + height: 280px; + background-image: url('../../../lib/assets/talent-search-header.png'); + background-position: center; + background-repeat: no-repeat; + background-size: cover; + z-index: -1; +} + +.contentContainer { + padding: $sp-16; + border-radius: $sp-4; + background: $tc-white; + box-shadow: 0 8px 24px 0 rgba(144, 144, 144, 0.15); + + margin-top: $sp-12; +} + +.contentHeader { + border-bottom: 1px solid #E8E8E8; + padding-bottom: 24px; + margin-bottom: 24px; + + .topActions { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + } + + .btns { + :global(.btn-size-lg) { + font-size: 16px; + } + } + + .tags { + display: flex; + gap: 8px; + flex-wrap: wrap; + } + + .title { + color: $black-100; + font-feature-settings: 'liga' off, 'clig' off; + + font-family: "Barlow Condensed"; + font-size: 34px; + font-style: normal; + font-weight: 600; + line-height: 32px; /* 94.118% */ + text-transform: uppercase; + + margin-top: 8px; + } + + .subTitle { + margin-top: $sp-4; + + display: flex; + align-items: center; + flex-wrap: wrap; + + > * + *:before { + content: ""; + border-radius: 50%; + background: $black-80; + width: 3px; + height: 3px; + background: url('data:image/svg+xml,') center no-repeat; + margin-left: 16px; + margin-right: 12px; + } + + &Item { + display: flex; + align-items: center; + gap: 4px; + + color: $turq-160; + + span { + color: $black-100; + font-feature-settings: 'liga' off, 'clig' off; + + font-family: Roboto; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 22px; /* 157.143% */ + } + } + } +} + +.tag { + display: flex; + align-items: center; + gap: 4px; + padding: 3px $sp-1; + border-radius: 2px; + + font-size: 11px; + font-weight: 600; + line-height: 10px; + + font-family: "Nunito Sans", sans-serif; + + background: $tc-white; + color: #0A0A0A; + border: 1px solid #a8a8a8; +} + +.htmlContent { + all: revert-layer; + color: $black-100; + font-feature-settings: 'liga' off, 'clig' off; + font-family: Roboto; + font-size: 16px; + line-height: 24px; + overflow-wrap: anywhere; + + * { + all: revert-layer; + } + + > :first-child { + margin-top: 0; + } + + > :last-child { + margin-bottom: 0; + } + + p, + ul, + ol, + blockquote, + table, + pre { + margin: 0 0 16px; + } + + ul, + ol { + padding-left: 20px; + } + + ul { + list-style: disc; + } + + li { + margin-bottom: 8px; + } + + a { + color: $link-blue-dark; + font-weight: 700; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + + &[target] { + &::after { + content: ""; + -webkit-mask-size: "cover"; + mask-size: "cover"; + -webkit-mask-image: url(../../../lib/assets/external-link.svg); + mask-image: url(../../../lib/assets/external-link.svg); + -webkit-mask-repeat: no-repeat; + mask-repeat: no-repeat; + -webkit-mask-position: center; + mask-position: center; + -webkit-mask-size: 12px 12px; + mask-size: 12px 12px; + width: 12px; + height: 12px; + display: inline-block; + background-color: currentColor; + margin-left: $sp-1; + } + } + } + + h1, + h2, + h3, + h4, + h5, + h6 { + color: $black-100; + margin: 0 0 16px; + font-weight: 600; + text-transform: uppercase; + font-family: $font-barlow; + } + + h1 { + font-size: 28px; + line-height: 36px; + } + + h2 { + font-size: 24px; + line-height: 32px; + } + + h3 { + font-size: 18px; + line-height: 22px; + } + + h4, + h5, + h6 { + font-size: 16px; + line-height: 24px; + } + + table { + display: block; + overflow-x: auto; + max-width: 100%; + border-collapse: collapse; + } + + th, + td { + border: 1px solid $black-20; + padding: 10px; + text-align: left; + vertical-align: top; + } + + pre { + background: $black-10; + border-radius: 8px; + padding: 16px; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-word; + } + + code { + background: $black-10; + border-radius: 4px; + padding: 2px 4px; + } + + blockquote { + border-left: 4px solid $black-20; + color: $black-80; + padding-left: 16px; + margin: 0; + } +} + +.contentSection { + +} + +.sectionTitle { + color: $black-100; + font-feature-settings: 'liga' off, 'clig' off; + + /* TC-Type/Desktop/Titles 2: Products & Apps/H4 */ + font-family: Barlow; + font-size: 18px; + font-style: normal; + font-weight: 600; + line-height: 22px; /* 122.222% */ + text-transform: uppercase; + margin-bottom: 20px; +} + +.contentBodyWrap { + display: flex; + gap: $sp-12; + align-items: flex-start; +} + +.contentBody { + flex: 1; + + > * + * { + margin-top: 32px; + } +} + +.contentBodySidebar { + width: 400px; + max-width: 40%; + + display: flex; + flex-direction: column; + gap: $sp-6; + + .panel { + border-radius: 8px; + border: 1px solid $black-20; + display: flex; + padding: $sp-4; + flex-direction: column; + align-items: flex-start; + gap: $sp-2; + align-self: stretch; + + &Title { + color: $black-100; + font-feature-settings: 'liga' off, 'clig' off; + + /* TC-Type/Desktop/Subtitles/Overline */ + font-family: Roboto; + font-size: 12px; + font-style: normal; + font-weight: 700; + line-height: 16px; /* 133.333% */ + letter-spacing: 1px; + text-transform: uppercase; + } + + a { + display: flex; + gap: 8px; + + color: #0D61BF; + font-feature-settings: 'liga' off, 'clig' off; + + /* TC-Type/Desktop/Body/Body-Small-Medium */ + font-family: Roboto; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 22px; /* 157.143% */ + } + + p { + color: $black-100; + font-feature-settings: 'liga' off, 'clig' off; + + /* TC-Type/Desktop/Body/Body-Small */ + font-family: Roboto; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 22px; /* 157.143% */ + + strong { + font-weight: 700; + } + } + + .url { + color: #888; + font-family: Roboto; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: normal; + } + } +} + +.statsList { + display: flex; + list-style: none; + align-items: flex-start; + gap: 10px; + align-self: stretch; + width: 100%; + + li { + flex: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + color: $black-100; + + strong { + font-feature-settings: 'liga' off, 'clig' off; + + /* TC-Type/Desktop/Titles 1: Corporate & Marketing/H4 */ + font-family: "Barlow Condensed"; + font-size: 32px; + font-style: normal; + font-weight: 500; + line-height: 34px; /* 106.25% */ + text-transform: uppercase; + } + + span { + font-family: Roboto; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: normal; + } + } +} + +.skillsList { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 8px; + + width: 100%; + + li { + display: flex; + padding: 6px 12px; + align-items: center; + gap: 4px; + + flex: 0; + white-space: nowrap; + + border-radius: 24px; + border: 1px solid $black-20; + background: $tc-white; + + color: var(--Dark-grey, #333); + font-feature-settings: 'liga' off, 'clig' off; + font-family: Roboto; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: normal; + 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/ProjectShowcasePostPage.tsx b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.tsx new file mode 100644 index 000000000..b75cb1ed7 --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ProjectShowcasePostPage.tsx @@ -0,0 +1,191 @@ +import { uniqBy } from 'lodash' +import { FC, useMemo } from 'react' +import { Params, useParams } from 'react-router-dom' + +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 { + useFetchProjectShowcasePost, + 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' + +const ProjectShowcasePostPage: FC = () => { + const routeParams: Params = useParams() + const { post }: UseFetchProjectShowcasePostResult + = 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), + ].join('/') + + const skills = useMemo( + () => uniqBy(post?.challengeMetadata?.flatMap(entry => entry.skills), 'id') ?? [], + [post?.challengeMetadata], + ) + const registrantsCount = useMemo( + () => post?.challengeMetadata?.reduce( + (count, entry) => count + entry.numOfRegistrants, + 0, + ) ?? 0, + [post?.challengeMetadata], + ) + const countriesCount = useMemo( + () => { + const countries = post?.challengeMetadata?.flatMap(entry => entry.countries) ?? [] + return new Set(countries).size + }, + [post?.challengeMetadata], + ) + + return ( +
+ {post?.title ?? ''} +
+
+
+
+
+ {post?.categories.map(category => ( + {category.name} + ))} +
+
+ +
+
+

+ {post?.title} +

+
+
+ + {industries} +
+
+ + Published + + {textFormatDateLocaleShortString(new Date(post?.publishedAt ?? post?.createdAt ?? 0))} + +
+
+
+
+
+
+
+
Media assets
+ +
+
+
Challenges
+ +
+
+ +
+
+ +
+ + ) +} + +export default ProjectShowcasePostPage 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 new file mode 100644 index 000000000..12e8f215d --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/ShowcasePostChallengeList.module.scss @@ -0,0 +1,137 @@ +@import '@libs/ui/styles/includes'; + +.wrap { +} + +.message { + margin-bottom: 12px; + color: var(--ui-text-secondary, #6b7280); +} + +.list { +} + +.item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 12px 0; + border-top: 1px solid #e8e8e8; + border-bottom: 1px solid #e8e8e8; +} + +.itemContent { +} + +.itemTitle { + display: block; + color: $black-100; + font-feature-settings: 'liga' off, 'clig' off; + + /* TC-Type/Desktop/Body/Body-Main-Medium */ + font-family: Roboto; + font-size: 16px; + font-style: normal; + font-weight: 500; + line-height: 24px; /* 150% */ + + margin-top: 8px; +} + +.itemMeta { + display: block; + padding: 3px 4px; + border-radius: 2px; + + font-feature-settings: 'liga' off, 'clig' off; + + /* TC-Type/Desktop/Subtitles/Label */ + font-family: "Nunito Sans", sans-serif; + + font-size: 11px; + font-style: normal; + font-weight: 600; + line-height: 10px; /* 90.909% */ + + background: $tc-white; + color: #0A0A0A; + border: 1px solid #a8a8a8; + width: max-content; + + // &: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; + // } +} + +.itemStats { + display: flex; + flex-wrap: wrap; + color: $black-80; + min-width: 220px; + + span { + font-feature-settings: 'liga' off, 'clig' off; + + /* TC-Type/Desktop/Body/Body-Small */ + font-family: Roboto; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 22px; /* 157.143% */ + + display: flex; + align-items: center; + } +} + +.statSeparator::before { + 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 { + display: flex; + 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 new file mode 100644 index 000000000..795220193 --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/ShowcasePostChallengeList.tsx @@ -0,0 +1,86 @@ +/* eslint-disable complexity */ +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' + +interface ShowcasePostChallengeListProps { + challengeIds?: string[] +} + +const ShowcasePostChallengeList: FC = props => { + const challengeIds = useMemo(() => props.challengeIds ?? [], [props.challengeIds]) + + const { + data: challenges = [], + error, + isValidating, + }: UseFetchChallenges = useFetchChallenges(challengeIds) + + const hasChallenges = challenges.length > 0 + + return ( +
+ {isValidating &&
Loading challenges…
} + {error &&
Unable to load challenges.
} + {!isValidating && !error && !hasChallenges && ( +
No challenges found.
+ )} + {hasChallenges && ( +
    + {challenges.map(challenge => ( +
  • +
    + + {getTrackName(challenge)} + + {challenge.name} +
    +
    + {(typeof challenge.numOfRegistrants === 'number' + || typeof challenge.numOfSubmissions === 'number') && ( +
    + {typeof challenge.numOfSubmissions === 'number' && ( + + {challenge.numOfSubmissions} + {' '} + submissions + + )} + {typeof challenge.numOfRegistrants === 'number' && ( + + {challenge.numOfRegistrants} + {' '} + registrants + + )} +
    + )} + +
    + +
    +
    +
  • + ))} +
+ )} +
+ ) +} + +export default ShowcasePostChallengeList diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/index.ts b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/index.ts new file mode 100644 index 000000000..18eb08e77 --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostChallengeList/index.ts @@ -0,0 +1 @@ +export { default as ShowcasePostChallengeList } from './ShowcasePostChallengeList' diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMedia.module.scss b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMedia.module.scss new file mode 100644 index 000000000..bdba1b941 --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMedia.module.scss @@ -0,0 +1,111 @@ +@import '@libs/ui/styles/includes'; + +.wrap { +} + +.mediaList { + margin-top: 20px; + display: flex; + align-items: flex-start; + flex-wrap: wrap; + gap: 16px; + width: 100%; + // justify-content: space-between; +} + +.mediaItem { + display: flex; + align-items: center; + justify-content: center; + + width: 164px; + height: 96px; + border-radius: 4px; + + overflow: hidden; + + position: relative; + + .remainingCount { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + z-index: 1; + + display: flex; + align-items: center; + justify-content: center; + + color: #FFF; + font-family: Roboto; + font-size: 16px; + font-style: normal; + font-weight: 700; + line-height: 24px; /* 150% */ + letter-spacing: 0.128px; + text-transform: uppercase; + + background: #333; + + cursor: pointer; + + &:hover { + background: rgba(#333, 0.95); + } + + &:active { + background: rgba(#333, 0.975); + } + } +} + +.mediaButton { + display: block; + width: 100%; + height: 100%; + border: 0; + padding: 0; + background: transparent; + text-align: left; + cursor: pointer; +} + +.mediaButton:focus-visible { + outline: 2px solid var(--primary-500); + outline-offset: 2px; +} + +.mediaImage { + width: 100%; + height: 100%; + border-radius: 0.5rem; + object-fit: cover; +} + +.placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + border: 1px solid $black-10; + border-radius: 0.5rem; + background-color: $black-5; + color: $black-100; + text-align: center; +} + +.placeholderIcon { + width: 2rem; + height: 2rem; + margin-bottom: 0.5rem; + color: var(--neutral-600); +} + +.placeholderLabel { + font-size: 0.9rem; + font-weight: 600; +} 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 new file mode 100644 index 000000000..0a5828226 --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMedia.tsx @@ -0,0 +1,101 @@ +import { FC, useCallback, useMemo, useState } from 'react' + +import { IconFile } from '~/apps/customer-portal/src/lib/assets' +import { ProjectShowcasePostMedia } from '~/apps/work/src/lib' + +import ShowcasePostMediaGallery, { + getAssetExtension, + getMediaAlt, + getPlaceholderLabel, + isImageAsset, +} from './ShowcasePostMediaGallery' +import styles from './ShowcasePostMedia.module.scss' + +interface ShowcasePostMediaProps { + assets: ProjectShowcasePostMedia[] | undefined +} + +const ShowcasePostMedia: FC = props => { + const [galleryIndex, setGalleryIndex] = useState(undefined) + + const visibleAssets = useMemo(() => props.assets?.slice(0, 4) || [], [props.assets]) + const restAssetCount = Math.max(0, (props.assets?.length ?? 0) - 4) + const galleryAssets = useMemo(() => props.assets || [], [props.assets]) + const isGalleryOpen = galleryIndex !== undefined + + const handleOpenGallery = useCallback((index: number) => { + setGalleryIndex(index) + }, []) + + const handleCloseGallery = useCallback(() => { + setGalleryIndex(undefined) + }, []) + + if (!props.assets || props.assets.length === 0) { + return <> + } + + return ( + <> +
+
    + {visibleAssets.map((mediaAsset, index) => { + const extension = getAssetExtension(mediaAsset) + const isImage = isImageAsset(extension) + + return ( +
  • + + {(index === visibleAssets.length - 1) && (restAssetCount > 0) && ( +
    + + + {restAssetCount + 1} +
    + )} +
  • + ) + })} +
+
+ + {isGalleryOpen && galleryIndex !== null && ( + + )} + + ) +} + +export default ShowcasePostMedia 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..2a06d1a82 --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.module.scss @@ -0,0 +1,165 @@ +@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; + -webkit-overflow-scrolling: touch; + touch-action: pan-x; + user-select: none; + + @include scrollbar; + + &:active { + cursor: grabbing; + } + + .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..571859afd --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/ShowcasePostMediaGallery.tsx @@ -0,0 +1,264 @@ +import { FC, MouseEvent, useCallback, useEffect, useRef, useState } from 'react' +import classNames from 'classnames' + +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' + +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 { + if (asset.alt) { + return asset.alt + } + + 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 <> + } + + return ( +
+
) { + event.stopPropagation() + } + } + > +
+ + +
+ {isImageAsset(getAssetExtension(mediaAsset)) ? ( + {getMediaAlt(mediaAsset)} + ) : ( +
+ + + {getPlaceholderLabel(getAssetExtension(mediaAsset), mediaAsset)} + + + Open file + +
+ )} +
+ +
+
+ +
    + {props.assets.map((thumbAsset, index) => { + const extension = getAssetExtension(thumbAsset) + const isImage = isImageAsset(extension) + + return ( +
  • + {isImage && ( + {getMediaAlt(thumbAsset)} + )} + {!isImage && ( +
    + + + {getPlaceholderLabel(extension, thumbAsset)} + +
    + )} +
  • + ) + })} +
+
+
+
+ ) +} + +export default ShowcasePostMediaGallery diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/index.ts b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/index.ts new file mode 100644 index 000000000..b6aaf36ba --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/ShowcasePostMedia/index.ts @@ -0,0 +1 @@ +export { default as ShowcasePostMedia } from './ShowcasePostMedia' diff --git a/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/index.ts b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/index.ts new file mode 100644 index 000000000..5d274895f --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/ProjectShowcasePostPage/index.ts @@ -0,0 +1 @@ +export { default as ProjectShowcasePostPage } from './ProjectShowcasePostPage' 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..78f327641 --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/project-showcase.routes.tsx @@ -0,0 +1,48 @@ +import { getRoutesContainer, lazyLoad, LazyLoadedComponent } from '~/libs/core' + +import { rootRoute, showcaseSearchRouteId } from '../../config/routes.config' + +const ProjectShowcasePage: LazyLoadedComponent = lazyLoad( + () => import('./ProjectShowcasePage'), + 'ProjectShowcasePage', +) + +const ProjectShowcasePostPage: LazyLoadedComponent = lazyLoad( + () => import('./ProjectShowcasePostPage'), + 'ProjectShowcasePostPage', +) + +export const showcaseRootRoute = `${rootRoute}/${showcaseSearchRouteId}` + +export const getPostRoute = (projectId: string, postId: string): string => ( + `${showcaseRootRoute}/${projectId}/post/${postId}` +) + +export const customerPortalProjectShowcaseRoutes = [ + { + children: [ + { + authRequired: true, + element: , + id: 'project-showcase-page', + route: '', + }, + ], + element: getRoutesContainer([ + { + authRequired: true, + element: , + id: 'project-showcase-page', + route: '', + }, + { + authRequired: true, + element: , + id: 'project-showcase-post-page', + route: ':projectId/post/:postId', + }, + ]), + id: 'project-showcase', + route: 'showcase', + }, +] diff --git a/src/apps/customer-portal/src/pages/project-showcase/utils.ts b/src/apps/customer-portal/src/pages/project-showcase/utils.ts new file mode 100644 index 000000000..5cef5d134 --- /dev/null +++ b/src/apps/customer-portal/src/pages/project-showcase/utils.ts @@ -0,0 +1,16 @@ +import { Challenge } from '~/apps/work/src/lib' + +export const getTrackName = (challenge: Challenge): string => ( + (typeof challenge.track === 'string' + ? challenge.track as string + : challenge.track?.name) + ?? (typeof challenge.type === 'string' + ? challenge.type as string + : challenge.type?.name) + ?? 'Challenge' +) + +export const toClassName = (cls: string): string => ( + cls.toLowerCase() + .replace(/\W+/g, '_') +) 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..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 @@ -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/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 && (