From 0a42cdd5076fcc7390b5129c20206ff7184e5168 Mon Sep 17 00:00:00 2001 From: rebelchris Date: Wed, 11 Mar 2026 13:39:05 +0000 Subject: [PATCH 1/2] feat: align post gate copy with profile widget --- .../cards/ProfileCompletionCard.tsx | 90 +++++++++---------- .../modals/post/CreateSharedPostModal.tsx | 1 + .../write/ProfileCompletionPostGate.spec.tsx | 58 ++++++++++++ .../post/write/ProfileCompletionPostGate.tsx | 24 ++++- .../ProfileWidgets/ProfileCompletion.tsx | 60 ++----------- .../shared/src/lib/profileCompletion.spec.ts | 59 ++++++++++++ packages/shared/src/lib/profileCompletion.ts | 62 +++++++++++++ packages/webapp/pages/squads/create.tsx | 1 + 8 files changed, 249 insertions(+), 106 deletions(-) create mode 100644 packages/shared/src/components/post/write/ProfileCompletionPostGate.spec.tsx create mode 100644 packages/shared/src/lib/profileCompletion.spec.ts create mode 100644 packages/shared/src/lib/profileCompletion.ts diff --git a/packages/shared/src/components/cards/ProfileCompletionCard.tsx b/packages/shared/src/components/cards/ProfileCompletionCard.tsx index a2f932e39f7..34fe9ac3ed8 100644 --- a/packages/shared/src/components/cards/ProfileCompletionCard.tsx +++ b/packages/shared/src/components/cards/ProfileCompletionCard.tsx @@ -15,7 +15,6 @@ import { useLogContext } from '../../contexts/LogContext'; import { useActions } from '../../hooks'; import { ActionType } from '../../graphql/actions'; import type { ProfileCompletion } from '../../lib/user'; -import { webappUrl } from '../../lib/constants'; import { LogEvent, TargetType } from '../../lib/log'; import { profileCompletionCardBorder, @@ -23,11 +22,9 @@ import { profileCompletionButtonBg, } from '../../styles/custom'; import { useProfileCompletionIndicator } from '../../hooks/profile/useProfileCompletionIndicator'; +import { getCompletionItems } from '../../lib/profileCompletion'; -type CompletionItem = { - label: string; - completed: boolean; - redirectPath: string; +type CompletionCardItem = ReturnType[number] & { cta: string; benefit: string; }; @@ -39,51 +36,44 @@ type ProfileCompletionCardProps = { }>; }; -const getCompletionItems = ( +const completionItemMetadata: Record< + ReturnType[number]['label'], + Pick +> = { + 'Profile image': { + cta: 'Add profile image', + benefit: + 'Stand out in comments and discussions. Profiles with photos get more engagement.', + }, + Headline: { + cta: 'Write your headline', + benefit: + 'Tell the community who you are. A good headline helps others connect with you.', + }, + 'Experience level': { + cta: 'Set experience level', + benefit: + 'Get personalized content recommendations based on where you are in your career.', + }, + 'Work experience': { + cta: 'Add work experience', + benefit: + 'Showcase your background and unlock opportunities from companies looking for talent like you.', + }, + Education: { + cta: 'Add education', + benefit: + 'Complete your story. Education helps others understand your journey.', + }, +}; + +const getCompletionCardItems = ( completion: ProfileCompletion, -): CompletionItem[] => { - return [ - { - label: 'Profile image', - completed: completion.hasProfileImage, - redirectPath: `${webappUrl}settings/profile`, - cta: 'Add profile image', - benefit: - 'Stand out in comments and discussions. Profiles with photos get more engagement.', - }, - { - label: 'Headline', - completed: completion.hasHeadline, - redirectPath: `${webappUrl}settings/profile?field=bio`, - cta: 'Write your headline', - benefit: - 'Tell the community who you are. A good headline helps others connect with you.', - }, - { - label: 'Experience level', - completed: completion.hasExperienceLevel, - redirectPath: `${webappUrl}settings/profile?field=experienceLevel`, - cta: 'Set experience level', - benefit: - 'Get personalized content recommendations based on where you are in your career.', - }, - { - label: 'Work experience', - completed: completion.hasWork, - redirectPath: `${webappUrl}settings/profile/experience/work`, - cta: 'Add work experience', - benefit: - 'Showcase your background and unlock opportunities from companies looking for talent like you.', - }, - { - label: 'Education', - completed: completion.hasEducation, - redirectPath: `${webappUrl}settings/profile/experience/education`, - cta: 'Add education', - benefit: - 'Complete your story. Education helps others understand your journey.', - }, - ]; +): CompletionCardItem[] => { + return getCompletionItems(completion).map((item) => ({ + ...item, + ...completionItemMetadata[item.label], + })); }; export const ProfileCompletionCard = ({ @@ -99,7 +89,7 @@ export const ProfileCompletionCard = ({ useProfileCompletionIndicator(); const items = useMemo( - () => (profileCompletion ? getCompletionItems(profileCompletion) : []), + () => (profileCompletion ? getCompletionCardItems(profileCompletion) : []), [profileCompletion], ); diff --git a/packages/shared/src/components/modals/post/CreateSharedPostModal.tsx b/packages/shared/src/components/modals/post/CreateSharedPostModal.tsx index 13dc50f46bc..dabd7b533eb 100644 --- a/packages/shared/src/components/modals/post/CreateSharedPostModal.tsx +++ b/packages/shared/src/components/modals/post/CreateSharedPostModal.tsx @@ -131,6 +131,7 @@ export function CreateSharedPostModal({ currentPercentage={user?.profileCompletion?.percentage} requiredPercentage={requiredPercentage} description="Update your profile to share posts in squads." + profileCompletion={user?.profileCompletion} buttonSize={ButtonSize.Small} /> diff --git a/packages/shared/src/components/post/write/ProfileCompletionPostGate.spec.tsx b/packages/shared/src/components/post/write/ProfileCompletionPostGate.spec.tsx new file mode 100644 index 00000000000..259ba44b308 --- /dev/null +++ b/packages/shared/src/components/post/write/ProfileCompletionPostGate.spec.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ProfileCompletionPostGate } from './ProfileCompletionPostGate'; + +describe('ProfileCompletionPostGate', () => { + it('should render missing profile items when profile completion is available', () => { + render( + , + ); + + expect( + screen.getByText('Add Profile image, Headline and Work experience.'), + ).toBeInTheDocument(); + expect( + screen.queryByText('You are 50% away from the 70% requirement.'), + ).not.toBeInTheDocument(); + }); + + it('should fall back to the percentage requirement message when profile completion is missing', () => { + render( + , + ); + + expect( + screen.getByText('You are 50% away from the 70% requirement.'), + ).toBeInTheDocument(); + }); + + it('should keep the profile CTA destination unchanged', () => { + render( + , + ); + + expect( + screen.getByRole('link', { name: 'Complete profile' }), + ).toHaveAttribute('href', '/settings/profile'); + }); +}); diff --git a/packages/shared/src/components/post/write/ProfileCompletionPostGate.tsx b/packages/shared/src/components/post/write/ProfileCompletionPostGate.tsx index 796af7ad604..dd1e19798e5 100644 --- a/packages/shared/src/components/post/write/ProfileCompletionPostGate.tsx +++ b/packages/shared/src/components/post/write/ProfileCompletionPostGate.tsx @@ -10,6 +10,11 @@ import { } from '../../typography/Typography'; import Link from '../../utilities/Link'; import { settingsUrl } from '../../../lib/constants'; +import type { ProfileCompletion } from '../../../lib/user'; +import { + formatCompletionDescription, + getIncompleteCompletionItems, +} from '../../../lib/profileCompletion'; import { profileCompletionButtonBg, profileCompletionCardBg, @@ -21,6 +26,7 @@ interface ProfileCompletionPostGateProps { currentPercentage?: number; requiredPercentage?: number; description: string; + profileCompletion?: ProfileCompletion; compact?: boolean; buttonSize?: ButtonSize; } @@ -30,14 +36,22 @@ export function ProfileCompletionPostGate({ currentPercentage = 0, requiredPercentage = 0, description, + profileCompletion, compact = false, buttonSize = ButtonSize.Medium, }: ProfileCompletionPostGateProps): ReactElement { const normalizedCurrent = Math.max(0, Math.min(100, currentPercentage)); const normalizedRequired = Math.max(0, Math.min(100, requiredPercentage)); const completionGap = Math.max(0, normalizedRequired - normalizedCurrent); + const incompleteItems = profileCompletion + ? getIncompleteCompletionItems(profileCompletion) + : []; + const missingItemsDescription = + incompleteItems.length > 0 + ? formatCompletionDescription(incompleteItems) + : null; const requirementMessage = - normalizedRequired > 0 + !missingItemsDescription && normalizedRequired > 0 ? `You are ${completionGap}% away from the ${normalizedRequired}% requirement.` : null; @@ -77,6 +91,14 @@ export function ProfileCompletionPostGate({ + {missingItemsDescription && ( + + {missingItemsDescription} + + )} {requirementMessage && ( { - return [ - { - label: 'Profile image', - completed: completion.hasProfileImage, - redirectPath: `${webappUrl}settings/profile`, - }, - { - label: 'Headline', - completed: completion.hasHeadline, - redirectPath: `${webappUrl}settings/profile?field=bio`, - }, - { - label: 'Experience level', - completed: completion.hasExperienceLevel, - redirectPath: `${webappUrl}settings/profile?field=experienceLevel`, - }, - { - label: 'Work experience', - completed: completion.hasWork, - redirectPath: `${webappUrl}settings/profile/experience/work`, - }, - { - label: 'Education', - completed: completion.hasEducation, - redirectPath: `${webappUrl}settings/profile/experience/education`, - }, - ]; -}; - -const formatDescription = (incompleteItems: CompletionItem[]): string => { - if (incompleteItems.length === 0) { - return 'Profile completed!'; - } - - const labels = incompleteItems.map((item) => item.label); - const formattedList = - labels.length === 1 - ? labels[0] - : `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}`; - - return `Add ${formattedList}.`; -}; - export const ProfileCompletion = ({ className, }: ProfileCompletionProps): ReactElement | null => { @@ -92,7 +42,7 @@ export const ProfileCompletion = ({ ); const description = useMemo( - () => formatDescription(incompleteItems), + () => formatCompletionDescription(incompleteItems), [incompleteItems], ); diff --git a/packages/shared/src/lib/profileCompletion.spec.ts b/packages/shared/src/lib/profileCompletion.spec.ts new file mode 100644 index 00000000000..85447eab08f --- /dev/null +++ b/packages/shared/src/lib/profileCompletion.spec.ts @@ -0,0 +1,59 @@ +import { + formatCompletionDescription, + getCompletionItems, + getIncompleteCompletionItems, +} from './profileCompletion'; +import type { ProfileCompletion } from './user'; + +const profileCompletion: ProfileCompletion = { + percentage: 20, + hasProfileImage: false, + hasHeadline: false, + hasExperienceLevel: true, + hasWork: false, + hasEducation: true, +}; + +describe('profileCompletion', () => { + it('should return completion items in the expected priority order', () => { + expect( + getCompletionItems(profileCompletion).map((item) => item.label), + ).toEqual([ + 'Profile image', + 'Headline', + 'Experience level', + 'Work experience', + 'Education', + ]); + }); + + it('should return only incomplete completion items', () => { + expect( + getIncompleteCompletionItems(profileCompletion).map((item) => item.label), + ).toEqual(['Profile image', 'Headline', 'Work experience']); + }); + + it('should format a single incomplete item description', () => { + expect( + formatCompletionDescription([ + { + label: 'Headline', + completed: false, + redirectPath: '/settings/profile?field=bio', + }, + ]), + ).toBe('Add Headline.'); + }); + + it('should format a multi-item incomplete description', () => { + expect( + formatCompletionDescription( + getIncompleteCompletionItems(profileCompletion), + ), + ).toBe('Add Profile image, Headline and Work experience.'); + }); + + it('should format the completed state description', () => { + expect(formatCompletionDescription([])).toBe('Profile completed!'); + }); +}); diff --git a/packages/shared/src/lib/profileCompletion.ts b/packages/shared/src/lib/profileCompletion.ts new file mode 100644 index 00000000000..3c9630a2271 --- /dev/null +++ b/packages/shared/src/lib/profileCompletion.ts @@ -0,0 +1,62 @@ +import type { ProfileCompletion } from './user'; +import { webappUrl } from './constants'; + +export type CompletionItem = { + label: string; + completed: boolean; + redirectPath: string; +}; + +export const getCompletionItems = ( + completion: ProfileCompletion, +): CompletionItem[] => { + return [ + { + label: 'Profile image', + completed: completion.hasProfileImage, + redirectPath: `${webappUrl}settings/profile`, + }, + { + label: 'Headline', + completed: completion.hasHeadline, + redirectPath: `${webappUrl}settings/profile?field=bio`, + }, + { + label: 'Experience level', + completed: completion.hasExperienceLevel, + redirectPath: `${webappUrl}settings/profile?field=experienceLevel`, + }, + { + label: 'Work experience', + completed: completion.hasWork, + redirectPath: `${webappUrl}settings/profile/experience/work`, + }, + { + label: 'Education', + completed: completion.hasEducation, + redirectPath: `${webappUrl}settings/profile/experience/education`, + }, + ]; +}; + +export const getIncompleteCompletionItems = ( + completion: ProfileCompletion, +): CompletionItem[] => { + return getCompletionItems(completion).filter((item) => !item.completed); +}; + +export const formatCompletionDescription = ( + incompleteItems: CompletionItem[], +): string => { + if (incompleteItems.length === 0) { + return 'Profile completed!'; + } + + const labels = incompleteItems.map((item) => item.label); + const formattedList = + labels.length === 1 + ? labels[0] + : `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}`; + + return `Add ${formattedList}.`; +}; diff --git a/packages/webapp/pages/squads/create.tsx b/packages/webapp/pages/squads/create.tsx index 192c3333999..733acceb2d0 100644 --- a/packages/webapp/pages/squads/create.tsx +++ b/packages/webapp/pages/squads/create.tsx @@ -274,6 +274,7 @@ function CreatePost(): ReactElement { currentPercentage={user?.profileCompletion?.percentage} requiredPercentage={requiredPercentage} description="Add your profile details to keep post creation available." + profileCompletion={user?.profileCompletion} buttonSize={ButtonSize.Medium} /> From 77e05bd8307417f1419a6674f23bd22314ac2c1d Mon Sep 17 00:00:00 2001 From: rebelchris Date: Wed, 11 Mar 2026 13:41:51 +0000 Subject: [PATCH 2/2] refactor: simplify profile completion gate copy --- .../cards/ProfileCompletionCard.tsx | 90 ++++++++++--------- .../post/write/ProfileCompletionPostGate.tsx | 4 +- .../shared/src/lib/profileCompletion.spec.ts | 7 +- packages/shared/src/lib/profileCompletion.ts | 6 -- 4 files changed, 56 insertions(+), 51 deletions(-) diff --git a/packages/shared/src/components/cards/ProfileCompletionCard.tsx b/packages/shared/src/components/cards/ProfileCompletionCard.tsx index 34fe9ac3ed8..a2f932e39f7 100644 --- a/packages/shared/src/components/cards/ProfileCompletionCard.tsx +++ b/packages/shared/src/components/cards/ProfileCompletionCard.tsx @@ -15,6 +15,7 @@ import { useLogContext } from '../../contexts/LogContext'; import { useActions } from '../../hooks'; import { ActionType } from '../../graphql/actions'; import type { ProfileCompletion } from '../../lib/user'; +import { webappUrl } from '../../lib/constants'; import { LogEvent, TargetType } from '../../lib/log'; import { profileCompletionCardBorder, @@ -22,9 +23,11 @@ import { profileCompletionButtonBg, } from '../../styles/custom'; import { useProfileCompletionIndicator } from '../../hooks/profile/useProfileCompletionIndicator'; -import { getCompletionItems } from '../../lib/profileCompletion'; -type CompletionCardItem = ReturnType[number] & { +type CompletionItem = { + label: string; + completed: boolean; + redirectPath: string; cta: string; benefit: string; }; @@ -36,44 +39,51 @@ type ProfileCompletionCardProps = { }>; }; -const completionItemMetadata: Record< - ReturnType[number]['label'], - Pick -> = { - 'Profile image': { - cta: 'Add profile image', - benefit: - 'Stand out in comments and discussions. Profiles with photos get more engagement.', - }, - Headline: { - cta: 'Write your headline', - benefit: - 'Tell the community who you are. A good headline helps others connect with you.', - }, - 'Experience level': { - cta: 'Set experience level', - benefit: - 'Get personalized content recommendations based on where you are in your career.', - }, - 'Work experience': { - cta: 'Add work experience', - benefit: - 'Showcase your background and unlock opportunities from companies looking for talent like you.', - }, - Education: { - cta: 'Add education', - benefit: - 'Complete your story. Education helps others understand your journey.', - }, -}; - -const getCompletionCardItems = ( +const getCompletionItems = ( completion: ProfileCompletion, -): CompletionCardItem[] => { - return getCompletionItems(completion).map((item) => ({ - ...item, - ...completionItemMetadata[item.label], - })); +): CompletionItem[] => { + return [ + { + label: 'Profile image', + completed: completion.hasProfileImage, + redirectPath: `${webappUrl}settings/profile`, + cta: 'Add profile image', + benefit: + 'Stand out in comments and discussions. Profiles with photos get more engagement.', + }, + { + label: 'Headline', + completed: completion.hasHeadline, + redirectPath: `${webappUrl}settings/profile?field=bio`, + cta: 'Write your headline', + benefit: + 'Tell the community who you are. A good headline helps others connect with you.', + }, + { + label: 'Experience level', + completed: completion.hasExperienceLevel, + redirectPath: `${webappUrl}settings/profile?field=experienceLevel`, + cta: 'Set experience level', + benefit: + 'Get personalized content recommendations based on where you are in your career.', + }, + { + label: 'Work experience', + completed: completion.hasWork, + redirectPath: `${webappUrl}settings/profile/experience/work`, + cta: 'Add work experience', + benefit: + 'Showcase your background and unlock opportunities from companies looking for talent like you.', + }, + { + label: 'Education', + completed: completion.hasEducation, + redirectPath: `${webappUrl}settings/profile/experience/education`, + cta: 'Add education', + benefit: + 'Complete your story. Education helps others understand your journey.', + }, + ]; }; export const ProfileCompletionCard = ({ @@ -89,7 +99,7 @@ export const ProfileCompletionCard = ({ useProfileCompletionIndicator(); const items = useMemo( - () => (profileCompletion ? getCompletionCardItems(profileCompletion) : []), + () => (profileCompletion ? getCompletionItems(profileCompletion) : []), [profileCompletion], ); diff --git a/packages/shared/src/components/post/write/ProfileCompletionPostGate.tsx b/packages/shared/src/components/post/write/ProfileCompletionPostGate.tsx index dd1e19798e5..1964bc6bc1c 100644 --- a/packages/shared/src/components/post/write/ProfileCompletionPostGate.tsx +++ b/packages/shared/src/components/post/write/ProfileCompletionPostGate.tsx @@ -13,7 +13,7 @@ import { settingsUrl } from '../../../lib/constants'; import type { ProfileCompletion } from '../../../lib/user'; import { formatCompletionDescription, - getIncompleteCompletionItems, + getCompletionItems, } from '../../../lib/profileCompletion'; import { profileCompletionButtonBg, @@ -44,7 +44,7 @@ export function ProfileCompletionPostGate({ const normalizedRequired = Math.max(0, Math.min(100, requiredPercentage)); const completionGap = Math.max(0, normalizedRequired - normalizedCurrent); const incompleteItems = profileCompletion - ? getIncompleteCompletionItems(profileCompletion) + ? getCompletionItems(profileCompletion).filter((item) => !item.completed) : []; const missingItemsDescription = incompleteItems.length > 0 diff --git a/packages/shared/src/lib/profileCompletion.spec.ts b/packages/shared/src/lib/profileCompletion.spec.ts index 85447eab08f..ea2fbb0a8e7 100644 --- a/packages/shared/src/lib/profileCompletion.spec.ts +++ b/packages/shared/src/lib/profileCompletion.spec.ts @@ -1,7 +1,6 @@ import { formatCompletionDescription, getCompletionItems, - getIncompleteCompletionItems, } from './profileCompletion'; import type { ProfileCompletion } from './user'; @@ -29,7 +28,9 @@ describe('profileCompletion', () => { it('should return only incomplete completion items', () => { expect( - getIncompleteCompletionItems(profileCompletion).map((item) => item.label), + getCompletionItems(profileCompletion) + .filter((item) => !item.completed) + .map((item) => item.label), ).toEqual(['Profile image', 'Headline', 'Work experience']); }); @@ -48,7 +49,7 @@ describe('profileCompletion', () => { it('should format a multi-item incomplete description', () => { expect( formatCompletionDescription( - getIncompleteCompletionItems(profileCompletion), + getCompletionItems(profileCompletion).filter((item) => !item.completed), ), ).toBe('Add Profile image, Headline and Work experience.'); }); diff --git a/packages/shared/src/lib/profileCompletion.ts b/packages/shared/src/lib/profileCompletion.ts index 3c9630a2271..704cc776476 100644 --- a/packages/shared/src/lib/profileCompletion.ts +++ b/packages/shared/src/lib/profileCompletion.ts @@ -39,12 +39,6 @@ export const getCompletionItems = ( ]; }; -export const getIncompleteCompletionItems = ( - completion: ProfileCompletion, -): CompletionItem[] => { - return getCompletionItems(completion).filter((item) => !item.completed); -}; - export const formatCompletionDescription = ( incompleteItems: CompletionItem[], ): string => {