Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2630ff8
feat: add profile completion card to feed with priority over brief card
AmarTrebinjac Jan 6, 2026
98cfe73
refactor: improve profile completion card styling and CTAs
AmarTrebinjac Jan 6, 2026
098388b
refactor: improve profile completion card with dismiss and all remain…
AmarTrebinjac Jan 6, 2026
5746845
fix: use TypographyTag.Span enum instead of string literal
AmarTrebinjac Jan 6, 2026
1069243
fix: prevent brief card from showing when profile card is dismissed
AmarTrebinjac Jan 6, 2026
1b6d860
refactor: restore brand purple color scheme for profile completion card
AmarTrebinjac Jan 6, 2026
1fba15f
Merge branch 'main' into profile-card-v2
AmarTrebinjac Jan 8, 2026
f166df5
Merge branch 'main' into profile-card-v2
AmarTrebinjac Jan 8, 2026
e9c9da1
feat: add feature flag for profile completion card
AmarTrebinjac Jan 8, 2026
772acdb
fix: prevent experiment double-exposure and layout shift for profile …
AmarTrebinjac Jan 8, 2026
f2e084f
feat: add analytics logging to profile completion card
AmarTrebinjac Jan 8, 2026
10a5b49
fix: use LogEvent.Click for dismiss action
AmarTrebinjac Jan 8, 2026
e7b7527
chore: remove comments from profile completion card
AmarTrebinjac Jan 12, 2026
9c4901c
chore: remove remaining comments from profile card feature
AmarTrebinjac Jan 12, 2026
6dd2064
Merge branch 'main' into profile-card-v2
AmarTrebinjac Jan 12, 2026
17812e8
refactor: move profile completion card styles to custom.ts
AmarTrebinjac Jan 12, 2026
8fc61c4
Merge branch 'main' into profile-card-v2
AmarTrebinjac Jan 13, 2026
da51aa7
Merge branch 'main' into profile-card-v2
AmarTrebinjac Jan 13, 2026
b191663
Merge branch 'main' into profile-card-v2
AmarTrebinjac Jan 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions packages/shared/src/components/Feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 =>
Expand Down Expand Up @@ -198,11 +206,24 @@ export default function Feed<T>({
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({
Expand Down Expand Up @@ -527,10 +548,11 @@ export default function Feed<T>({
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 (
<ActiveFeedContext.Provider value={feedContextValue}>
Expand All @@ -539,7 +561,14 @@ export default function Feed<T>({
<>{emptyScreen}</>
) : (
<>
{showBriefCard && (
{showProfileCompletionCard && (
<ProfileCompletionCard
className={{
container: 'p-4 pt-0',
}}
/>
)}
{showBriefCard && !showProfileCompletionCard && (
<BriefCardFeed
targetId={TargetId.Feed}
className={{
Expand Down
217 changes: 217 additions & 0 deletions packages/shared/src/components/cards/ProfileCompletionCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import type { ReactElement } from 'react';
import classNames from 'classnames';
import {
Typography,
TypographyColor,
TypographyTag,
TypographyType,
} from '../typography/Typography';
import { Button, ButtonSize, ButtonVariant } from '../buttons/Button';
import ProgressCircle from '../ProgressCircle';
import CloseButton from '../CloseButton';
import { useAuthContext } from '../../contexts/AuthContext';
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,
profileCompletionCardBg,
profileCompletionButtonBg,
} from '../../styles/custom';

type CompletionItem = {
label: string;
completed: boolean;
redirectPath: string;
cta: string;
benefit: string;
};

type ProfileCompletionCardProps = {
className?: Partial<{
container: string;
card: string;
}>;
};

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;
Comment on lines +107 to +114
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is there so much calculations in the useProfileCompletionCard hook and also here?


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 (
<div
className={classNames('flex flex-1 p-2 laptop:p-0', className?.container)}
>
<div
style={{
border: profileCompletionCardBorder,
background: profileCompletionCardBg,
}}
className={classNames(
'relative flex flex-1 flex-col gap-4 rounded-16 px-6 py-4',
'backdrop-blur-3xl',
className?.card,
)}
>
<CloseButton
className="absolute right-2 top-2"
size={ButtonSize.XSmall}
onClick={handleDismiss}
/>
<ProgressCircle progress={progress} size={48} showPercentage />
<Typography
type={TypographyType.Title2}
color={TypographyColor.Primary}
bold
>
Complete your profile
</Typography>
<div className="flex flex-col gap-2">
<Typography
type={TypographyType.Callout}
color={TypographyColor.Tertiary}
>
Finish setting up your profile to get the most out of daily.dev:
</Typography>
<ul className="flex list-inside list-disc flex-col gap-1">
{incompleteItems.map((item) => (
<li key={item.label}>
<Typography
tag={TypographyTag.Span}
type={TypographyType.Callout}
color={TypographyColor.Secondary}
>
{item.label}
</Typography>
</li>
))}
</ul>
</div>
<Button
style={{
background: profileCompletionButtonBg,
}}
className="mt-auto w-full"
tag="a"
href={firstIncompleteItem.redirectPath}
type="button"
variant={ButtonVariant.Primary}
size={ButtonSize.Small}
onClick={handleCtaClick}
>
{firstIncompleteItem.cta}
</Button>
</div>
</div>
);
};

export default ProfileCompletionCard;
1 change: 1 addition & 0 deletions packages/shared/src/graphql/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
48 changes: 48 additions & 0 deletions packages/shared/src/hooks/profile/useProfileCompletionCard.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
5 changes: 5 additions & 0 deletions packages/shared/src/lib/featureManagement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ export const briefCardFeedFeature = new Feature(
isDevelopment,
);

export const profileCompletionCardFeature = new Feature(
'profile_completion_card',
false,
);

export const briefGeneratePricing = new Feature<Record<BriefingType, number>>(
'brief_generate_pricing',
{
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/lib/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,7 @@ export enum TargetType {
CvBanner = 'cv banner',
Post = 'post',
Recruiter = 'recruiter',
ProfileCompletionCard = 'profile completion card',
}

export enum TargetId {
Expand Down
9 changes: 9 additions & 0 deletions packages/shared/src/styles/custom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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%)';