diff --git a/docs/work-showcase-win.md b/docs/work-showcase-win.md index 964be2ed1..816b21650 100644 --- a/docs/work-showcase-win.md +++ b/docs/work-showcase-win.md @@ -31,6 +31,14 @@ The Challenge, The Solution and Business Impact Realised use the same Markdown editor and sanitized rich-text preview. Key Win and Owner are single-line strings of up to 255 characters. Existing title, taxonomy and solution requirements remain. +The Work preview modal and the customer portal showcase (`/showcase` cards and +post pages) share `ShowcasePostDetails`. Type, Customer, SMU (the custom value +for Others), Deal Close Date, Key Win, Current Status and Owner appear in a +labelled summary, followed by The Challenge, The Solution and Business Impact +Realised. Anything left blank is hidden, including empty media, challenge, +stats and skills sections. Showcase cards show Type, Customer and Current Status +when they are set. + **Send to WIN** is unchecked for new posts and retains the saved choice on edits. Saving with the checkbox selected makes the record available to authorized callers of `GET /v6/reports/WIN`. This is a pull integration: the success message confirms @@ -45,5 +53,5 @@ deploy the reports API change for WIN callers. No new UI environment variables a Use `.nvmrc`, then `yarn lint`, `yarn run build`, and the tests for `ProjectShowcasePage`, `ProjectEditorForm`, `project-editor.schema`, and -`showcase-post.schema`. Manually check that editing metadata in either form is +`showcase-post.schema` and `ShowcasePostDetails`. Manually check that editing metadata in either form is reflected when reopening the other form, and that opting in/out changes the WIN report. 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 d92c8a00b..50dcb2a2d 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 @@ -80,6 +80,14 @@ gap: 12px; } +.highlights { + color: $black-80; + font-size: 14px; + font-weight: 600; + line-height: 22px; + overflow-wrap: anywhere; +} + .taxonomy { display: flex; align-items: center; 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 b2cbd6cf0..c8c5c4820 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 @@ -14,40 +14,52 @@ 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(', ') || '—'} - -
- -
- {renderRichTextToPlainText(props.post.content || '')} -
- -
- -
-
-) +const ProjectShowcaseCard: FC = props => { + const industries = props.post.industries.map(item => item.name) + .join(', ') + const highlights = [props.post.type, props.post.customer, props.post.currentStatus] + .map(value => value?.trim()) + .filter(Boolean) + .join(' · ') + const summary = props.post.content || props.post.challenge || props.post.businessImpact || '' + + return ( +
+
+ {props.post.categories.map(category => ( + + {category.name} + + ))} +
+ +

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

+ {highlights &&
{highlights}
} + {industries && ( +
+ + {industries} +
+ )} + + {summary && ( +
+ {renderRichTextToPlainText(summary)} +
+ )} + +
+ +
+
+ ) +} export default ProjectShowcaseCard 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 b75cb1ed7..e7d6eddcd 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,6 +6,8 @@ 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 { + getShowcaseStorySections, + ShowcasePostDetails, useFetchProjectShowcasePost, UseFetchProjectShowcasePostResult, } from '~/apps/work/src/lib' @@ -30,6 +32,12 @@ const ProjectShowcasePostPage: FC = () => { encodeURIComponent(routeParams.projectId as string), ].join('/') + const storySections = useMemo( + () => (post ? getShowcaseStorySections(post) : []), + [post], + ) + const challengeCount = post?.challengeMetadata?.length ?? 0 + const skills = useMemo( () => uniqBy(post?.challengeMetadata?.flatMap(entry => entry.skills), 'id') ?? [], [post?.challengeMetadata], @@ -75,10 +83,12 @@ const ProjectShowcasePostPage: FC = () => { {post?.title}
-
- - {industries} -
+ {industries && ( +
+ + {industries} +
+ )}
Published @@ -90,24 +100,34 @@ const ProjectShowcasePostPage: FC = () => {
-
-
-
Media assets
- -
-
-
Challenges
- -
+ {post && } + {storySections.map(section => ( +
+
{section.label}
+
+
+ ))} + {!!post?.media?.length && ( +
+
Media assets
+ +
+ )} + {!!post?.challengeIds?.length && ( +
+
Topcoder Challenge Launched
+ +
+ )}
+ )}
diff --git a/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.module.scss b/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.module.scss new file mode 100644 index 000000000..90e938d39 --- /dev/null +++ b/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.module.scss @@ -0,0 +1,39 @@ +@import '@libs/ui/styles/includes'; + +.details { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: $sp-4 $sp-6; + margin: 0; + padding: $sp-4; + border-radius: 8px; + border: 1px solid $black-20; + background: $black-5; +} + +.item { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.label { + color: $black-80; + font-family: Roboto; + font-size: 12px; + font-weight: 700; + line-height: 16px; + letter-spacing: 1px; + text-transform: uppercase; +} + +.value { + margin: 0; + color: $black-100; + font-family: Roboto; + font-size: 16px; + font-weight: 500; + line-height: 24px; + overflow-wrap: anywhere; +} diff --git a/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.spec.tsx b/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.spec.tsx new file mode 100644 index 000000000..582b1a5ad --- /dev/null +++ b/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.spec.tsx @@ -0,0 +1,55 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { render, screen } from '@testing-library/react' +import '@testing-library/jest-dom' + +import ShowcasePostDetails, { getShowcaseDetailItems, getShowcaseStorySections } from './ShowcasePostDetails' + +jest.mock('~/libs/shared/lib/utils/text-format', () => ({ + textFormatDateLocaleShortString: (date: Date) => date.toISOString() + .slice(0, 10), +}), { virtual: true }) + +const expectedDate = new Date(2026, 7, 15) + .toISOString() + .slice(0, 10) + +describe('Showcase post details', () => { + it('lists populated summary fields in order and hides missing ones', () => { + expect(getShowcaseDetailItems({ + currentStatus: ' ', + customer: 'Wipro', + dealCloseDate: '2026-08-15', + keyWin: 'Win-win', + smu: 'Others', + smuOther: 'LATAM', + type: 'Private POD Delivery', + })) + .toEqual([ + { label: 'Type', value: 'Private POD Delivery' }, + { label: 'Customer', value: 'Wipro' }, + { label: 'SMU', value: 'LATAM' }, + { label: 'Deal Close Date', value: expectedDate }, + { label: 'Key Win', value: 'Win-win' }, + ]) + }) + + it('returns only populated story sections', () => { + expect(getShowcaseStorySections({ businessImpact: 'Impact', challenge: '', content: 'Solution' }) + .map(section => section.label)) + .toEqual(['The Solution', 'Business Impact Realised']) + }) + + it('renders labelled values and nothing when no fields are populated', () => { + const view = render() + expect(view.container) + .toBeEmptyDOMElement() + view.rerender() + expect(screen.getByText('SMU')) + .toBeInTheDocument() + expect(screen.getByText('Americas1')) + .toBeInTheDocument() + expect(screen.getByText('Owner')) + .toBeInTheDocument() + expect(screen.queryByText('Customer')).not.toBeInTheDocument() + }) +}) diff --git a/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.tsx b/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.tsx new file mode 100644 index 000000000..bd2e999cf --- /dev/null +++ b/src/apps/work/src/lib/components/ShowcasePostDetails/ShowcasePostDetails.tsx @@ -0,0 +1,114 @@ +import { FC } from 'react' + +import { textFormatDateLocaleShortString } from '~/libs/shared/lib/utils/text-format' + +import { ShowcaseMetadata } from '../../models/ProjectShowcasePost.model' + +import styles from './ShowcasePostDetails.module.scss' + +export interface ShowcasePostDetailItem { + label: string + value: string +} + +export interface ShowcasePostStorySection { + label: string + value: string +} + +/** + * Formats a stored YYYY-MM-DD date without shifting it across time zones. + * @param value Deal close date from the showcase or project metadata. + * @returns A localized short date, the trimmed input when it is not a calendar date, or undefined when empty. + * @throws Does not throw. + */ +function formatDealCloseDate(value: string | undefined): string | undefined { + const trimmed = value?.trim() + if (!trimmed) { + return undefined + } + + const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(trimmed) + const date = match + ? new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])) + : new Date(trimmed) + + return Number.isNaN(date.getTime()) + ? trimmed + : textFormatDateLocaleShortString(date) +} + +/** + * Collects the populated single-value showcase fields in display order. + * @param data Showcase and shared project metadata. + * @returns Label/value pairs with blank values removed; SMU uses the custom value for Others. + * @throws Does not throw. + */ +export function getShowcaseDetailItems(data: ShowcaseMetadata): ShowcasePostDetailItem[] { + const smu = data.smu === 'Others' + ? data.smuOther?.trim() || data.smu + : data.smu + + return [ + { label: 'Type', value: data.type }, + { label: 'Customer', value: data.customer }, + { label: 'SMU', value: smu }, + { label: 'Deal Close Date', value: formatDealCloseDate(data.dealCloseDate) }, + { label: 'Key Win', value: data.keyWin }, + { label: 'Current Status', value: data.currentStatus }, + { label: 'Owner', value: data.owner }, + ] + .map(item => ({ label: item.label, value: item.value?.trim() ?? '' })) + .filter(item => !!item.value) +} + +/** + * Collects the populated long-form showcase sections in display order. + * @param data Showcase rich text fields; content is The Solution. + * @returns Section labels and rich text values with blank sections removed. + * @throws Does not throw. + */ +export function getShowcaseStorySections( + data: Pick & { content?: string }, +): ShowcasePostStorySection[] { + return [ + { label: 'The Challenge', value: data.challenge }, + { label: 'The Solution', value: data.content }, + { label: 'Business Impact Realised', value: data.businessImpact }, + ] + .filter((item): item is ShowcasePostStorySection => !!item.value?.trim()) +} + +export interface ShowcasePostDetailsProps { + data: ShowcaseMetadata + className?: string +} + +/** + * Shows the populated showcase summary fields shared by the Work preview and the customer showcase. + * @param props Showcase metadata and an optional wrapper class. + * @returns A labelled detail grid, or nothing when no summary field is populated. + * @throws Does not throw. + */ +const ShowcasePostDetails: FC = props => { + const items = getShowcaseDetailItems(props.data) + + if (!items.length) { + return <> + } + + return ( +
+ {items.map(item => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ ) +} + +export default ShowcasePostDetails diff --git a/src/apps/work/src/lib/components/ShowcasePostDetails/index.ts b/src/apps/work/src/lib/components/ShowcasePostDetails/index.ts new file mode 100644 index 000000000..01dde5146 --- /dev/null +++ b/src/apps/work/src/lib/components/ShowcasePostDetails/index.ts @@ -0,0 +1,10 @@ +export { + default as ShowcasePostDetails, + getShowcaseDetailItems, + getShowcaseStorySections, +} from './ShowcasePostDetails' +export type { + ShowcasePostDetailItem, + ShowcasePostDetailsProps, + ShowcasePostStorySection, +} from './ShowcasePostDetails' diff --git a/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.module.scss b/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.module.scss index 7f4a2985d..477fe9a0d 100644 --- a/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.module.scss +++ b/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.module.scss @@ -216,14 +216,6 @@ margin: 0; } -.emptyMessage { - margin: 0; - color: $black-80; - font-family: Roboto; - font-size: 14px; - line-height: 22px; -} - .mediaList { display: flex; flex-wrap: wrap; diff --git a/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.tsx b/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.tsx index aa62cb286..42e07abdc 100644 --- a/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.tsx +++ b/src/apps/work/src/lib/components/ShowcasePostPreview/ShowcasePostPreview.tsx @@ -6,6 +6,7 @@ import { renderRichTextToHtml } from '~/libs/shared/lib/utils/rich-text' import { textFormatDateLocaleShortString } from '~/libs/shared/lib/utils/text-format' import { ShowcaseMetadata } from '../../models/ProjectShowcasePost.model' +import { getShowcaseStorySections, ShowcasePostDetails } from '../ShowcasePostDetails' import styles from './ShowcasePostPreview.module.scss' @@ -101,41 +102,21 @@ const ShowcasePostPreview: FC = props => {
-
- {[ - ['Type', data.type], - ['Customer', data.customer], - ['SMU', data.smu === 'Others' ? data.smuOther : data.smu], - ['Deal Close Date', data.dealCloseDate], - ['Key Win', data.keyWin], - ['Current Status', data.currentStatus], - ['Owner', data.owner], - ].filter(([, value]) => !!value) - .map(([label, value]) => ( -
-
{label}
-
{value}
-
- ))} -
- {[ - ['The Challenge', data.challenge], - ['The Solution', data.content], - ['Business Impact Realised', data.businessImpact], - ].filter(([, value]) => !!value) - .map(([label, value]) => ( -
-
{label}
+ + {getShowcaseStorySections(data) + .map(section => ( +
+
{section.label}
))} -
-
Media assets
- {data.media.length > 0 ? ( + {data.media.length > 0 && ( +
+
Media assets
    {data.media.map((item, index) => { const key: string = `${item.url}-${index}` @@ -176,14 +157,12 @@ const ShowcasePostPreview: FC = props => { ) })}
- ) : ( -

No media added yet.

- )} -
+
+ )} -
-
Topcoder Challenge Launched
- {data.challenges.length > 0 ? ( + {data.challenges.length > 0 && ( +
+
Topcoder Challenge Launched
    {data.challenges.map(challenge => { const challengeUrl: string | undefined = getSafeHttpUrl( @@ -250,10 +229,8 @@ const ShowcasePostPreview: FC = props => { ) })}
- ) : ( -

No challenges selected.

- )} -
+
+ )}
-
-
Stats
-
    -
  • - {data.challengeCount} - Challenges -
  • -
  • - {data.registrantsCount} - Registrants -
  • -
  • - {data.countriesCount} - Countries -
  • -
-
+ {data.challengeCount > 0 && ( +
+
Stats
+
    +
  • + {data.challengeCount} + Challenges +
  • +
  • + {data.registrantsCount} + Registrants +
  • +
  • + {data.countriesCount} + Countries +
  • +
+
+ )} -
-
Skills
-

- This showcase includes - {' '} - - {data.skills.length} + {data.skills.length > 0 && ( +

+
Skills
+

+ This showcase includes {' '} - skills. - -

- {data.skills.length > 0 && ( + + {data.skills.length} + {' '} + skills. + +

    {data.skills.map(skill => (
  • {skill.name}
  • ))}
- )} -
+
+ )}
diff --git a/src/apps/work/src/lib/components/index.ts b/src/apps/work/src/lib/components/index.ts index cf96cd2af..93daa28ce 100644 --- a/src/apps/work/src/lib/components/index.ts +++ b/src/apps/work/src/lib/components/index.ts @@ -35,6 +35,7 @@ export * from './ProjectsShowcaseFilter' export * from './ProjectsTable' export * from './ResourceAddModal' export * from './ResourcesTable' +export * from './ShowcasePostDetails' export * from './ShowcasePostPreview' export * from './TerminateAssignmentModal' export * from './SubmissionDuplicatesRow'