Skip to content

Commit a05e08a

Browse files
feat: profile completion card (#5254)
Co-authored-by: Claude Haiku 4.5 <[email protected]>
1 parent 7ad775e commit a05e08a

File tree

7 files changed

+314
-4
lines changed

7 files changed

+314
-4
lines changed

packages/shared/src/components/Feed.tsx

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import {
4141
useFeedVotePost,
4242
useMutationSubscription,
4343
} from '../hooks';
44+
import { useProfileCompletionCard } from '../hooks/profile/useProfileCompletionCard';
4445
import type { AllFeedPages } from '../lib/query';
4546
import { OtherFeedPage, RequestKey } from '../lib/query';
4647

@@ -129,6 +130,13 @@ const BriefCardFeed = dynamic(
129130
),
130131
);
131132

133+
const ProfileCompletionCard = dynamic(
134+
() =>
135+
import(
136+
/* webpackChunkName: "profileCompletionCard" */ './cards/ProfileCompletionCard'
137+
),
138+
);
139+
132140
const calculateRow = (index: number, numCards: number): number =>
133141
Math.floor(index / numCards);
134142
const calculateColumn = (index: number, numCards: number): number =>
@@ -198,11 +206,24 @@ export default function Feed<T>({
198206
const { isSearchPageLaptop } = useSearchResultsLayout();
199207
const hasNoBriefAction =
200208
isActionsFetched && !checkHasCompleted(ActionType.GeneratedBrief);
209+
210+
const {
211+
showProfileCompletionCard,
212+
isDismissed: isProfileCompletionCardDismissed,
213+
isLoading: isProfileCompletionCardLoading,
214+
} = useProfileCompletionCard({ isMyFeed });
215+
216+
const shouldEvaluateBriefCard =
217+
isMyFeed &&
218+
hasNoBriefAction &&
219+
!showProfileCompletionCard &&
220+
!isProfileCompletionCardLoading &&
221+
!isProfileCompletionCardDismissed;
201222
const { value: briefCardFeatureValue } = useConditionalFeature({
202223
feature: briefCardFeedFeature,
203-
shouldEvaluate: isMyFeed && hasNoBriefAction,
224+
shouldEvaluate: shouldEvaluateBriefCard,
204225
});
205-
const showBriefCard = isMyFeed && briefCardFeatureValue && hasNoBriefAction;
226+
const showBriefCard = shouldEvaluateBriefCard && briefCardFeatureValue;
206227
const [getProducts] = useUpdateQuery(getProductsQueryOptions());
207228

208229
const { value: briefBannerPage } = useConditionalFeature({
@@ -527,10 +548,11 @@ export default function Feed<T>({
527548
const currentPageSize = pageSize ?? currentSettings.pageSize;
528549
const showPromoBanner = !!briefBannerPage;
529550
const columnsDiffWithPage = currentPageSize % virtualizedNumCards;
551+
const showFirstSlotCard = showProfileCompletionCard || showBriefCard;
530552
const indexWhenShowingPromoBanner =
531553
currentPageSize * Number(briefBannerPage) - // number of items at that page
532554
columnsDiffWithPage * Number(briefBannerPage) - // cards let out of rows * page number
533-
Number(showBriefCard); // if showing the brief card, we need to subtract 1 to the index
555+
Number(showFirstSlotCard);
534556

535557
return (
536558
<ActiveFeedContext.Provider value={feedContextValue}>
@@ -539,7 +561,14 @@ export default function Feed<T>({
539561
<>{emptyScreen}</>
540562
) : (
541563
<>
542-
{showBriefCard && (
564+
{showProfileCompletionCard && (
565+
<ProfileCompletionCard
566+
className={{
567+
container: 'p-4 pt-0',
568+
}}
569+
/>
570+
)}
571+
{showBriefCard && !showProfileCompletionCard && (
543572
<BriefCardFeed
544573
targetId={TargetId.Feed}
545574
className={{
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
2+
import type { ReactElement } from 'react';
3+
import classNames from 'classnames';
4+
import {
5+
Typography,
6+
TypographyColor,
7+
TypographyTag,
8+
TypographyType,
9+
} from '../typography/Typography';
10+
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
11+
import ProgressCircle from '../ProgressCircle';
12+
import CloseButton from '../CloseButton';
13+
import { useAuthContext } from '../../contexts/AuthContext';
14+
import { useLogContext } from '../../contexts/LogContext';
15+
import { useActions } from '../../hooks';
16+
import { ActionType } from '../../graphql/actions';
17+
import type { ProfileCompletion } from '../../lib/user';
18+
import { webappUrl } from '../../lib/constants';
19+
import { LogEvent, TargetType } from '../../lib/log';
20+
import {
21+
profileCompletionCardBorder,
22+
profileCompletionCardBg,
23+
profileCompletionButtonBg,
24+
} from '../../styles/custom';
25+
26+
type CompletionItem = {
27+
label: string;
28+
completed: boolean;
29+
redirectPath: string;
30+
cta: string;
31+
benefit: string;
32+
};
33+
34+
type ProfileCompletionCardProps = {
35+
className?: Partial<{
36+
container: string;
37+
card: string;
38+
}>;
39+
};
40+
41+
const getCompletionItems = (
42+
completion: ProfileCompletion,
43+
): CompletionItem[] => {
44+
return [
45+
{
46+
label: 'Profile image',
47+
completed: completion.hasProfileImage,
48+
redirectPath: `${webappUrl}settings/profile`,
49+
cta: 'Add profile image',
50+
benefit:
51+
'Stand out in comments and discussions. Profiles with photos get more engagement.',
52+
},
53+
{
54+
label: 'Headline',
55+
completed: completion.hasHeadline,
56+
redirectPath: `${webappUrl}settings/profile?field=bio`,
57+
cta: 'Write your headline',
58+
benefit:
59+
'Tell the community who you are. A good headline helps others connect with you.',
60+
},
61+
{
62+
label: 'Experience level',
63+
completed: completion.hasExperienceLevel,
64+
redirectPath: `${webappUrl}settings/profile?field=experienceLevel`,
65+
cta: 'Set experience level',
66+
benefit:
67+
'Get personalized content recommendations based on where you are in your career.',
68+
},
69+
{
70+
label: 'Work experience',
71+
completed: completion.hasWork,
72+
redirectPath: `${webappUrl}settings/profile/experience/work`,
73+
cta: 'Add work experience',
74+
benefit:
75+
'Showcase your background and unlock opportunities from companies looking for talent like you.',
76+
},
77+
{
78+
label: 'Education',
79+
completed: completion.hasEducation,
80+
redirectPath: `${webappUrl}settings/profile/experience/education`,
81+
cta: 'Add education',
82+
benefit:
83+
'Complete your story. Education helps others understand your journey.',
84+
},
85+
];
86+
};
87+
88+
export const ProfileCompletionCard = ({
89+
className,
90+
}: ProfileCompletionCardProps): ReactElement | null => {
91+
const { user } = useAuthContext();
92+
const { logEvent } = useLogContext();
93+
const { checkHasCompleted, completeAction, isActionsFetched } = useActions();
94+
const profileCompletion = user?.profileCompletion;
95+
const isImpressionTracked = useRef(false);
96+
97+
const items = useMemo(
98+
() => (profileCompletion ? getCompletionItems(profileCompletion) : []),
99+
[profileCompletion],
100+
);
101+
102+
const incompleteItems = useMemo(
103+
() => items.filter((item) => !item.completed),
104+
[items],
105+
);
106+
107+
const firstIncompleteItem = incompleteItems[0];
108+
const progress = profileCompletion?.percentage ?? 0;
109+
const isCompleted = progress === 100;
110+
const isDismissed =
111+
isActionsFetched && checkHasCompleted(ActionType.ProfileCompletionCard);
112+
113+
const shouldShow =
114+
profileCompletion && !isCompleted && firstIncompleteItem && !isDismissed;
115+
116+
useEffect(() => {
117+
if (!shouldShow || isImpressionTracked.current) {
118+
return;
119+
}
120+
121+
logEvent({
122+
event_name: LogEvent.Impression,
123+
target_type: TargetType.ProfileCompletionCard,
124+
});
125+
isImpressionTracked.current = true;
126+
}, [shouldShow, logEvent]);
127+
128+
const handleCtaClick = useCallback(() => {
129+
logEvent({
130+
event_name: LogEvent.Click,
131+
target_type: TargetType.ProfileCompletionCard,
132+
target_id: 'cta',
133+
});
134+
}, [logEvent]);
135+
136+
const handleDismiss = useCallback(() => {
137+
logEvent({
138+
event_name: LogEvent.Click,
139+
target_type: TargetType.ProfileCompletionCard,
140+
target_id: 'dismiss',
141+
});
142+
completeAction(ActionType.ProfileCompletionCard);
143+
}, [logEvent, completeAction]);
144+
145+
if (!shouldShow) {
146+
return null;
147+
}
148+
149+
return (
150+
<div
151+
className={classNames('flex flex-1 p-2 laptop:p-0', className?.container)}
152+
>
153+
<div
154+
style={{
155+
border: profileCompletionCardBorder,
156+
background: profileCompletionCardBg,
157+
}}
158+
className={classNames(
159+
'relative flex flex-1 flex-col gap-4 rounded-16 px-6 py-4',
160+
'backdrop-blur-3xl',
161+
className?.card,
162+
)}
163+
>
164+
<CloseButton
165+
className="absolute right-2 top-2"
166+
size={ButtonSize.XSmall}
167+
onClick={handleDismiss}
168+
/>
169+
<ProgressCircle progress={progress} size={48} showPercentage />
170+
<Typography
171+
type={TypographyType.Title2}
172+
color={TypographyColor.Primary}
173+
bold
174+
>
175+
Complete your profile
176+
</Typography>
177+
<div className="flex flex-col gap-2">
178+
<Typography
179+
type={TypographyType.Callout}
180+
color={TypographyColor.Tertiary}
181+
>
182+
Finish setting up your profile to get the most out of daily.dev:
183+
</Typography>
184+
<ul className="flex list-inside list-disc flex-col gap-1">
185+
{incompleteItems.map((item) => (
186+
<li key={item.label}>
187+
<Typography
188+
tag={TypographyTag.Span}
189+
type={TypographyType.Callout}
190+
color={TypographyColor.Secondary}
191+
>
192+
{item.label}
193+
</Typography>
194+
</li>
195+
))}
196+
</ul>
197+
</div>
198+
<Button
199+
style={{
200+
background: profileCompletionButtonBg,
201+
}}
202+
className="mt-auto w-full"
203+
tag="a"
204+
href={firstIncompleteItem.redirectPath}
205+
type="button"
206+
variant={ButtonVariant.Primary}
207+
size={ButtonSize.Small}
208+
onClick={handleCtaClick}
209+
>
210+
{firstIncompleteItem.cta}
211+
</Button>
212+
</div>
213+
</div>
214+
);
215+
};
216+
217+
export default ProfileCompletionCard;

packages/shared/src/graphql/actions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export enum ActionType {
5555
UserPostInOpenSquadWarningSeen = 'user_post_in_open_squad_warning_seen',
5656
ProfileCompleted = 'profile_completed',
5757
ClickedOpportunityNavigation = 'click_opportunity_navigation',
58+
ProfileCompletionCard = 'profile_completion_card',
5859
}
5960

6061
export const cvActions = [
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { useAuthContext } from '../../contexts/AuthContext';
2+
import { useConditionalFeature } from '../useConditionalFeature';
3+
import { profileCompletionCardFeature } from '../../lib/featureManagement';
4+
import { useActions } from '../useActions';
5+
import { ActionType } from '../../graphql/actions';
6+
7+
interface UseProfileCompletionCard {
8+
showProfileCompletionCard: boolean;
9+
isDismissed: boolean;
10+
isLoading: boolean;
11+
}
12+
13+
interface UseProfileCompletionCardProps {
14+
isMyFeed: boolean;
15+
}
16+
17+
export const useProfileCompletionCard = ({
18+
isMyFeed,
19+
}: UseProfileCompletionCardProps): UseProfileCompletionCard => {
20+
const { user } = useAuthContext();
21+
const { checkHasCompleted, isActionsFetched } = useActions();
22+
23+
const profileCompletion = user?.profileCompletion;
24+
const isCompleted = (profileCompletion?.percentage ?? 100) === 100;
25+
const isDismissed =
26+
isActionsFetched && checkHasCompleted(ActionType.ProfileCompletionCard);
27+
28+
const hasNotDismissed =
29+
isActionsFetched && !checkHasCompleted(ActionType.ProfileCompletionCard);
30+
const shouldEvaluate = isMyFeed && !isCompleted && hasNotDismissed;
31+
32+
const { value: featureEnabled, isLoading: isFeatureLoading } =
33+
useConditionalFeature({
34+
feature: profileCompletionCardFeature,
35+
shouldEvaluate,
36+
});
37+
38+
const couldPotentiallyShow = isMyFeed && !isCompleted && !!profileCompletion;
39+
const isLoading =
40+
couldPotentiallyShow && (!isActionsFetched || isFeatureLoading);
41+
42+
return {
43+
showProfileCompletionCard:
44+
shouldEvaluate && featureEnabled && !!profileCompletion,
45+
isDismissed,
46+
isLoading,
47+
};
48+
};

packages/shared/src/lib/featureManagement.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ export const briefCardFeedFeature = new Feature(
8585
isDevelopment,
8686
);
8787

88+
export const profileCompletionCardFeature = new Feature(
89+
'profile_completion_card',
90+
false,
91+
);
92+
8893
export const briefGeneratePricing = new Feature<Record<BriefingType, number>>(
8994
'brief_generate_pricing',
9095
{

packages/shared/src/lib/log.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,7 @@ export enum TargetType {
401401
CvBanner = 'cv banner',
402402
Post = 'post',
403403
Recruiter = 'recruiter',
404+
ProfileCompletionCard = 'profile completion card',
404405
}
405406

406407
export enum TargetId {

packages/shared/src/styles/custom.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,12 @@ export const cvUploadBannerBg =
2525

2626
export const recruiterPremiumPlanBg =
2727
'radial-gradient(76.99% 27.96% at 53.99% 54.97%, #CE3DF3 0%, rgba(114, 41, 240, 0.08) 50%)';
28+
29+
export const profileCompletionCardBorder =
30+
'1px solid color-mix(in srgb, var(--theme-accent-cabbage-subtler), transparent 50%)';
31+
32+
export const profileCompletionCardBg =
33+
'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%)';
34+
35+
export const profileCompletionButtonBg =
36+
'color-mix(in srgb, var(--theme-accent-cabbage-default), transparent 20%)';

0 commit comments

Comments
 (0)