diff --git a/packages/shared/src/components/Feed.tsx b/packages/shared/src/components/Feed.tsx index f3c2c4de8e..366a80e21f 100644 --- a/packages/shared/src/components/Feed.tsx +++ b/packages/shared/src/components/Feed.tsx @@ -41,6 +41,7 @@ import { useFeedVotePost, useMutationSubscription, } from '../hooks'; +import { useProfileCompletionCard } from '../hooks/profile/useProfileCompletionCard'; import type { AllFeedPages } from '../lib/query'; import { OtherFeedPage, RequestKey } from '../lib/query'; @@ -129,6 +130,13 @@ const BriefCardFeed = dynamic( ), ); +const ProfileCompletionCard = dynamic( + () => + import( + /* webpackChunkName: "profileCompletionCard" */ './cards/ProfileCompletionCard' + ), +); + const calculateRow = (index: number, numCards: number): number => Math.floor(index / numCards); const calculateColumn = (index: number, numCards: number): number => @@ -198,11 +206,24 @@ export default function Feed({ const { isSearchPageLaptop } = useSearchResultsLayout(); const hasNoBriefAction = isActionsFetched && !checkHasCompleted(ActionType.GeneratedBrief); + + const { + showProfileCompletionCard, + isDismissed: isProfileCompletionCardDismissed, + isLoading: isProfileCompletionCardLoading, + } = useProfileCompletionCard({ isMyFeed }); + + const shouldEvaluateBriefCard = + isMyFeed && + hasNoBriefAction && + !showProfileCompletionCard && + !isProfileCompletionCardLoading && + !isProfileCompletionCardDismissed; const { value: briefCardFeatureValue } = useConditionalFeature({ feature: briefCardFeedFeature, - shouldEvaluate: isMyFeed && hasNoBriefAction, + shouldEvaluate: shouldEvaluateBriefCard, }); - const showBriefCard = isMyFeed && briefCardFeatureValue && hasNoBriefAction; + const showBriefCard = shouldEvaluateBriefCard && briefCardFeatureValue; const [getProducts] = useUpdateQuery(getProductsQueryOptions()); const { value: briefBannerPage } = useConditionalFeature({ @@ -527,10 +548,11 @@ export default function Feed({ const currentPageSize = pageSize ?? currentSettings.pageSize; const showPromoBanner = !!briefBannerPage; const columnsDiffWithPage = currentPageSize % virtualizedNumCards; + const showFirstSlotCard = showProfileCompletionCard || showBriefCard; const indexWhenShowingPromoBanner = currentPageSize * Number(briefBannerPage) - // number of items at that page columnsDiffWithPage * Number(briefBannerPage) - // cards let out of rows * page number - Number(showBriefCard); // if showing the brief card, we need to subtract 1 to the index + Number(showFirstSlotCard); return ( @@ -539,7 +561,14 @@ export default function Feed({ <>{emptyScreen} ) : ( <> - {showBriefCard && ( + {showProfileCompletionCard && ( + + )} + {showBriefCard && !showProfileCompletionCard && ( ; +}; + +const getCompletionItems = ( + 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.', + }, + ]; +}; + +export const ProfileCompletionCard = ({ + className, +}: ProfileCompletionCardProps): ReactElement | null => { + const { user } = useAuthContext(); + const { logEvent } = useLogContext(); + const { checkHasCompleted, completeAction, isActionsFetched } = useActions(); + const profileCompletion = user?.profileCompletion; + const isImpressionTracked = useRef(false); + + const items = useMemo( + () => (profileCompletion ? getCompletionItems(profileCompletion) : []), + [profileCompletion], + ); + + const incompleteItems = useMemo( + () => items.filter((item) => !item.completed), + [items], + ); + + const firstIncompleteItem = incompleteItems[0]; + const progress = profileCompletion?.percentage ?? 0; + const isCompleted = progress === 100; + const isDismissed = + isActionsFetched && checkHasCompleted(ActionType.ProfileCompletionCard); + + const shouldShow = + profileCompletion && !isCompleted && firstIncompleteItem && !isDismissed; + + useEffect(() => { + if (!shouldShow || isImpressionTracked.current) { + return; + } + + logEvent({ + event_name: LogEvent.Impression, + target_type: TargetType.ProfileCompletionCard, + }); + isImpressionTracked.current = true; + }, [shouldShow, logEvent]); + + const handleCtaClick = useCallback(() => { + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.ProfileCompletionCard, + target_id: 'cta', + }); + }, [logEvent]); + + const handleDismiss = useCallback(() => { + logEvent({ + event_name: LogEvent.Click, + target_type: TargetType.ProfileCompletionCard, + target_id: 'dismiss', + }); + completeAction(ActionType.ProfileCompletionCard); + }, [logEvent, completeAction]); + + if (!shouldShow) { + return null; + } + + return ( +
+
+ + + + Complete your profile + +
+ + Finish setting up your profile to get the most out of daily.dev: + +
    + {incompleteItems.map((item) => ( +
  • + + {item.label} + +
  • + ))} +
+
+ +
+
+ ); +}; + +export default ProfileCompletionCard; diff --git a/packages/shared/src/graphql/actions.ts b/packages/shared/src/graphql/actions.ts index 251e3953d7..1e4a28c6a3 100644 --- a/packages/shared/src/graphql/actions.ts +++ b/packages/shared/src/graphql/actions.ts @@ -55,6 +55,7 @@ export enum ActionType { UserPostInOpenSquadWarningSeen = 'user_post_in_open_squad_warning_seen', ProfileCompleted = 'profile_completed', ClickedOpportunityNavigation = 'click_opportunity_navigation', + ProfileCompletionCard = 'profile_completion_card', } export const cvActions = [ diff --git a/packages/shared/src/hooks/profile/useProfileCompletionCard.ts b/packages/shared/src/hooks/profile/useProfileCompletionCard.ts new file mode 100644 index 0000000000..4f2e6ba7ff --- /dev/null +++ b/packages/shared/src/hooks/profile/useProfileCompletionCard.ts @@ -0,0 +1,48 @@ +import { useAuthContext } from '../../contexts/AuthContext'; +import { useConditionalFeature } from '../useConditionalFeature'; +import { profileCompletionCardFeature } from '../../lib/featureManagement'; +import { useActions } from '../useActions'; +import { ActionType } from '../../graphql/actions'; + +interface UseProfileCompletionCard { + showProfileCompletionCard: boolean; + isDismissed: boolean; + isLoading: boolean; +} + +interface UseProfileCompletionCardProps { + isMyFeed: boolean; +} + +export const useProfileCompletionCard = ({ + isMyFeed, +}: UseProfileCompletionCardProps): UseProfileCompletionCard => { + const { user } = useAuthContext(); + const { checkHasCompleted, isActionsFetched } = useActions(); + + const profileCompletion = user?.profileCompletion; + const isCompleted = (profileCompletion?.percentage ?? 100) === 100; + const isDismissed = + isActionsFetched && checkHasCompleted(ActionType.ProfileCompletionCard); + + const hasNotDismissed = + isActionsFetched && !checkHasCompleted(ActionType.ProfileCompletionCard); + const shouldEvaluate = isMyFeed && !isCompleted && hasNotDismissed; + + const { value: featureEnabled, isLoading: isFeatureLoading } = + useConditionalFeature({ + feature: profileCompletionCardFeature, + shouldEvaluate, + }); + + const couldPotentiallyShow = isMyFeed && !isCompleted && !!profileCompletion; + const isLoading = + couldPotentiallyShow && (!isActionsFetched || isFeatureLoading); + + return { + showProfileCompletionCard: + shouldEvaluate && featureEnabled && !!profileCompletion, + isDismissed, + isLoading, + }; +}; diff --git a/packages/shared/src/lib/featureManagement.ts b/packages/shared/src/lib/featureManagement.ts index 393b0af1c6..14d85364cc 100644 --- a/packages/shared/src/lib/featureManagement.ts +++ b/packages/shared/src/lib/featureManagement.ts @@ -85,6 +85,11 @@ export const briefCardFeedFeature = new Feature( isDevelopment, ); +export const profileCompletionCardFeature = new Feature( + 'profile_completion_card', + false, +); + export const briefGeneratePricing = new Feature>( 'brief_generate_pricing', { diff --git a/packages/shared/src/lib/log.ts b/packages/shared/src/lib/log.ts index e8581d856c..a7cd174998 100644 --- a/packages/shared/src/lib/log.ts +++ b/packages/shared/src/lib/log.ts @@ -401,6 +401,7 @@ export enum TargetType { CvBanner = 'cv banner', Post = 'post', Recruiter = 'recruiter', + ProfileCompletionCard = 'profile completion card', } export enum TargetId { diff --git a/packages/shared/src/styles/custom.ts b/packages/shared/src/styles/custom.ts index 8de1c4f6c6..b97b054427 100644 --- a/packages/shared/src/styles/custom.ts +++ b/packages/shared/src/styles/custom.ts @@ -25,3 +25,12 @@ export const cvUploadBannerBg = export const recruiterPremiumPlanBg = 'radial-gradient(76.99% 27.96% at 53.99% 54.97%, #CE3DF3 0%, rgba(114, 41, 240, 0.08) 50%)'; + +export const profileCompletionCardBorder = + '1px solid color-mix(in srgb, var(--theme-accent-cabbage-subtler), transparent 50%)'; + +export const profileCompletionCardBg = + 'linear-gradient(180deg, color-mix(in srgb, var(--theme-accent-cabbage-bolder), transparent 92%) 0%, color-mix(in srgb, var(--theme-accent-cabbage-bolder), transparent 96%) 100%)'; + +export const profileCompletionButtonBg = + 'color-mix(in srgb, var(--theme-accent-cabbage-default), transparent 20%)';