Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 23 additions & 12 deletions packages/shared/src/components/auth/AuthenticationBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,39 @@ import {
cloudinaryAuthBannerBackground1440w as laptopBg,
cloudinaryAuthBannerBackground1920w as desktopBg,
} from '../../lib/image';
import { AuthDisplay } from './common';
import { AuthDisplay, type AuthOptionsProps, type AuthProps } from './common';
import type { AuthTriggersType } from '../../lib/auth';

const Section = classed('div', 'flex flex-col');

interface AuthenticationBannerProps extends PropsWithChildren {
compact?: boolean;
onAuthStateUpdate?: AuthOptionsProps['onAuthStateUpdate'];
targetId?: string;
trigger?: AuthTriggersType;
}

export function AuthenticationBanner({
children,
compact,
onAuthStateUpdate,
targetId,
trigger = AuthTriggers.Onboarding,
}: AuthenticationBannerProps): ReactElement {
const { showLogin } = useAuthContext();

const handleAuthStateUpdate = (props: Partial<AuthProps>): void => {
onAuthStateUpdate?.(props);
showLogin({
trigger,
options: {
isLogin: !!props.isLoginFlow,
defaultDisplay: props.defaultDisplay,
formValues: props.email ? { email: props.email } : undefined,
},
});
};

return (
<BottomBannerContainer
className={classNames(
Expand Down Expand Up @@ -75,23 +94,15 @@ export function AuthenticationBanner({
<AuthOptions
ignoreMessages
formRef={null as unknown as React.MutableRefObject<HTMLFormElement>}
trigger={AuthTriggers.Onboarding}
trigger={trigger}
simplified
defaultDisplay={AuthDisplay.OnboardingSignup}
forceDefaultDisplay
targetId={targetId}
className={{
onboardingSignup: compact ? '!gap-3' : '!gap-4',
}}
onAuthStateUpdate={(props) => {
showLogin({
trigger: AuthTriggers.Onboarding,
options: {
isLogin: !!props.isLoginFlow,
defaultDisplay: props.defaultDisplay,
formValues: props.email ? { email: props.email } : undefined,
},
});
}}
onAuthStateUpdate={handleAuthStateUpdate}
onboardingSignupButton={{
variant: ButtonVariant.Primary,
}}
Expand Down
74 changes: 74 additions & 0 deletions packages/shared/src/components/auth/PostTopicAuthBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { ReactElement } from 'react';
import React, { useCallback, useMemo } from 'react';
import { useActivePostContext } from '../../contexts/ActivePostContext';
import useLogEventOnce from '../../hooks/log/useLogEventOnce';
import { AuthTriggers } from '../../lib/auth';
import { LogEvent, Origin, TargetType } from '../../lib/log';
import { useLogContext } from '../../contexts/LogContext';
import {
getPostTopicLabel,
getPostTopicTags,
getPostTopicTargetId,
} from '../post/anonymousPostExperience';
import { PostTopicChips } from '../post/PostTopicChips';
import { AuthenticationBanner } from './AuthenticationBanner';

interface PostTopicAuthBannerProps {
compact?: boolean;
}

export const PostTopicAuthBanner = ({
compact,
}: PostTopicAuthBannerProps): ReactElement => {
const activePost = useActivePostContext()?.activePost;
const { logEvent } = useLogContext();
const topics = getPostTopicTags(activePost);
const topicLabel = getPostTopicLabel(topics);
const targetId = getPostTopicTargetId(activePost);
const extra = useMemo(
() =>
JSON.stringify({
origin: Origin.ArticlePage,
post_id: activePost?.id,
surface: 'post_topic_auth_banner',
tags: activePost?.tags?.slice(0, topics.length) ?? [],
}),
[activePost?.id, activePost?.tags, topics.length],
);

useLogEventOnce(() => ({
event_name: LogEvent.Impression,
target_id: 'post_topic_auth_banner',
target_type: TargetType.ArticleAnonymousCTA,
extra,
}));

const onAuthStateUpdate = useCallback(() => {
logEvent({
event_name: LogEvent.ClickArticleAnonymousCTA,
target_id: 'post_topic_auth_banner',
target_type: TargetType.ArticleAnonymousCTA,
extra,
});
}, [extra, logEvent]);

return (
<AuthenticationBanner
compact={compact}
onAuthStateUpdate={onAuthStateUpdate}
targetId={targetId}
trigger={AuthTriggers.PostPage}
>
<div className="flex flex-col gap-3">
<p className="font-bold text-text-primary typo-large-title">
Build a feed around {topicLabel}
</p>
<p className="text-text-secondary typo-body">
daily.dev turns this post into a personalized stream of stories,
discussions, and tools from developers who care about the same topics.
</p>
<PostTopicChips topics={topics} />
</div>
</AuthenticationBanner>
);
};
29 changes: 24 additions & 5 deletions packages/shared/src/components/brand/MentionedToolsWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ interface Tool {
interface MentionedToolsWidgetProps {
postTags: string[];
className?: string;
compact?: boolean;
}

/**
Expand All @@ -45,6 +46,7 @@ interface MentionedToolsWidgetProps {
export const MentionedToolsWidget = ({
postTags,
className,
compact,
}: MentionedToolsWidgetProps): ReactElement | null => {
const router = useRouter();
const { user, showLogin } = useAuthContext();
Expand Down Expand Up @@ -168,25 +170,30 @@ export const MentionedToolsWidget = ({
}

const highlightedWordResult = getHighlightedWordConfig(postTags);
const visibleTools = compact ? mentionedTools.slice(0, 2) : mentionedTools;
const hiddenToolsCount = mentionedTools.length - visibleTools.length;

return (
<>
<div
className={classNames(
'flex flex-col gap-4 rounded-16 border border-border-subtlest-tertiary p-4',
'flex flex-col rounded-16 border border-border-subtlest-tertiary',
compact ? 'gap-3 p-3' : 'gap-4 p-4',
className,
)}
>
<Typography
type={TypographyType.Body}
type={compact ? TypographyType.Callout : TypographyType.Body}
color={TypographyColor.Primary}
bold
>
Sponsored tools
</Typography>

<div className="flex flex-col gap-2">
{mentionedTools.map((tool) => {
<div
className={classNames('flex flex-col', compact ? 'gap-1' : 'gap-2')}
>
{visibleTools.map((tool) => {
const isSponsored =
tool.isSponsored && hasAnySponsoredTag(postTags);
const isInStack = isToolInStack(tool.name);
Expand All @@ -201,7 +208,10 @@ export const MentionedToolsWidget = ({
? () => handleSponsoredToolHover(tool.name)
: undefined
}
className="group flex h-12 w-full cursor-pointer items-center justify-between gap-3 rounded-12 px-3 text-left transition-colors hover:bg-surface-hover"
className={classNames(
'group flex w-full cursor-pointer items-center justify-between gap-3 rounded-12 px-3 text-left transition-colors hover:bg-surface-hover',
compact ? 'h-10' : 'h-12',
)}
>
<div className="flex items-center gap-2">
{tool.icon ? (
Expand Down Expand Up @@ -274,6 +284,15 @@ export const MentionedToolsWidget = ({

return toolItem;
})}
{hiddenToolsCount > 0 && (
<Typography
type={TypographyType.Caption1}
color={TypographyColor.Tertiary}
className="px-3 pt-1"
>
+{hiddenToolsCount} more tools mentioned
</Typography>
)}
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,26 @@ import {
} from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { HighlightPostSidebarWidget } from './HighlightPostSidebarWidget';
import { useAuthContext } from '../../../contexts/AuthContext';
import { useConditionalFeature } from '../../../hooks/useConditionalFeature';
import { useLogContext } from '../../../contexts/LogContext';
import { gqlClient } from '../../../graphql/common';
import { ONE_HOUR } from '../../../lib/time';
import { useAnonymousPostExperience } from '../../../hooks/post/useAnonymousPostExperience';

jest.mock('../../../lib/constants', () => ({
webappUrl: '/',
isDevelopment: false,
}));

jest.mock('../../../contexts/AuthContext');
jest.mock('../../../hooks/useConditionalFeature');
jest.mock('../../../contexts/LogContext');
jest.mock('../../../hooks/post/useAnonymousPostExperience');
jest.mock('../../../graphql/common', () => ({
gqlClient: {
request: jest.fn(),
},
}));

const mockUseAuthContext = jest.mocked(useAuthContext);
const mockUseConditionalFeature = jest.mocked(useConditionalFeature);
const mockUseLogContext = jest.mocked(useLogContext);
const mockUseAnonymousPostExperience = jest.mocked(useAnonymousPostExperience);
const mockGqlRequest = jest.mocked(gqlClient.request);

const buildHighlight = (id: string, headline: string, hoursAgo = 1) => ({
Expand Down Expand Up @@ -68,13 +65,9 @@ describe('HighlightPostSidebarWidget', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGqlRequest.mockReset();
mockUseAuthContext.mockReturnValue({
user: { id: 'u1' },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
mockUseConditionalFeature.mockReturnValue({
value: true,
isLoading: false,
mockUseAnonymousPostExperience.mockReturnValue({
isAnonPostExperience: false,
isPostPageExperience: true,
});
mockUseLogContext.mockReturnValue({
logEvent,
Expand Down Expand Up @@ -144,21 +137,18 @@ describe('HighlightPostSidebarWidget', () => {
).not.toBeInTheDocument();
});

it('returns null when feature flag is off', () => {
mockUseConditionalFeature.mockReturnValue({
value: false,
isLoading: false,
it('renders for anonymous users by default', async () => {
mockUseAnonymousPostExperience.mockReturnValue({
isAnonPostExperience: true,
isPostPageExperience: true,
});
mockGqlRequest.mockResolvedValue(
buildResponse([buildHighlight('h1', 'Hidden headline')]),
buildResponse([buildHighlight('h1', 'Anonymous headline')]),
);

renderWidget();

expect(screen.queryByText('Happening Now')).not.toBeInTheDocument();
expect(
screen.queryByTestId('postPageHighlightWidget'),
).not.toBeInTheDocument();
expect(await screen.findByText('Anonymous headline')).toBeInTheDocument();
});

it('points "Read all" to /highlights with the first highlight id', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,18 @@ import {
} from '../../../graphql/highlights';
import { RelativeTime } from '../../utilities/RelativeTime';
import Link from '../../utilities/Link';
import { useAuthContext } from '../../../contexts/AuthContext';
import { useConditionalFeature } from '../../../hooks/useConditionalFeature';
import { featurePostPageHighlights } from '../../../lib/featureManagement';
import { useLogContext } from '../../../contexts/LogContext';
import { LogEvent, Origin } from '../../../lib/log';
import { feedHighlightsLogEvent } from '../../../lib/feed';
import useLogEventOnce from '../../../hooks/log/useLogEventOnce';
import { ONE_HOUR, ONE_MINUTE } from '../../../lib/time';
import { useAnonymousPostExperience } from '../../../hooks/post/useAnonymousPostExperience';

const HIGHLIGHTS_LIMIT = 10;
const ROTATION_INTERVAL_MS = 6000;
const FADE_DURATION_MS = 500;
const FEED_NAME = 'post-page-highlights';
const ANONYMOUS_FEED_NAME = 'anonymous-post-page-highlights';
const MAX_HIGHLIGHT_AGE_MS = 24 * ONE_HOUR;

const prefersReducedMotion = (): boolean => {
Expand All @@ -33,16 +32,12 @@ const prefersReducedMotion = (): boolean => {
};

export const HighlightPostSidebarWidget = (): ReactElement | null => {
const { user } = useAuthContext();
const { logEvent } = useLogContext();
const { value: isEnabled } = useConditionalFeature({
feature: featurePostPageHighlights,
shouldEvaluate: !!user,
});
const { isAnonPostExperience } = useAnonymousPostExperience();
const feedName = isAnonPostExperience ? ANONYMOUS_FEED_NAME : FEED_NAME;

const { data } = useQuery({
...majorHeadlinesQueryOptions({ first: HIGHLIGHTS_LIMIT }),
enabled: isEnabled && !!user,
refetchInterval: ONE_MINUTE,
});

Expand All @@ -57,13 +52,13 @@ export const HighlightPostSidebarWidget = (): ReactElement | null => {
const fadeOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const hasHighlights = highlights.length > 0;
const shouldRender = isEnabled && hasHighlights;
const shouldRender = hasHighlights;
const canRotate = shouldRender && highlights.length > 1 && !isPaused;

useLogEventOnce(
() =>
feedHighlightsLogEvent(LogEvent.Impression, {
feedName: FEED_NAME,
feedName,
action: 'impression',
count: highlights.length,
highlightIds: highlights.map((h) => h.id),
Expand Down Expand Up @@ -123,7 +118,7 @@ export const HighlightPostSidebarWidget = (): ReactElement | null => {
const onHighlightClick = () => {
logEvent(
feedHighlightsLogEvent(LogEvent.Click, {
feedName: FEED_NAME,
feedName,
action: 'highlight_click',
position: index + 1,
count: highlights.length,
Expand All @@ -137,7 +132,7 @@ export const HighlightPostSidebarWidget = (): ReactElement | null => {
const onReadAllClick = () => {
logEvent(
feedHighlightsLogEvent(LogEvent.Click, {
feedName: FEED_NAME,
feedName,
action: 'read_all_click',
count: highlights.length,
highlightIds: highlights.map((h) => h.id),
Expand Down
Loading
Loading