Skip to content

Commit 62bc574

Browse files
authored
fix: open add hot take modal from hot takes CTA (#6085)
1 parent 8c5b474 commit 62bc574

9 files changed

Lines changed: 336 additions & 25 deletions

File tree

packages/shared/src/components/modals/hotTakes/HotAndColdModal.spec.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,7 @@ describe('HotAndColdModal', () => {
229229
const shareLink = screen.getByRole('link', {
230230
name: 'Share your hot takes',
231231
});
232-
expect(shareLink).toHaveAttribute('href', '/tester#hot-takes');
232+
expect(shareLink).toHaveAttribute('href', '/tester?addHotTake=1#hot-takes');
233233

234234
fireEvent.click(shareLink);
235235

@@ -242,7 +242,7 @@ describe('HotAndColdModal', () => {
242242
const addButton = screen.getByRole('link', {
243243
name: 'Add your own hot take',
244244
});
245-
expect(addButton).toHaveAttribute('href', '/tester#hot-takes');
245+
expect(addButton).toHaveAttribute('href', '/tester?addHotTake=1#hot-takes');
246246

247247
fireEvent.click(addButton);
248248

packages/shared/src/components/modals/hotTakes/HotAndColdModal.tsx

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { useVoteHotTake } from '../../../hooks/vote/useVoteHotTake';
1010
import { useLogContext } from '../../../contexts/LogContext';
1111
import { useAuthContext } from '../../../contexts/AuthContext';
1212
import { LogEvent, Origin } from '../../../lib/log';
13-
import { webappUrl } from '../../../lib/constants';
1413
import { Button, ButtonSize, ButtonVariant } from '../../buttons/Button';
1514
import { HotIcon } from '../../icons/Hot';
1615
import {
@@ -23,6 +22,7 @@ import { ReputationUserBadge } from '../../ReputationUserBadge';
2322
import { VerifiedCompanyUserBadge } from '../../VerifiedCompanyUserBadge';
2423
import { PlusUserBadge } from '../../PlusUserBadge';
2524
import type { HotTake } from '../../../graphql/user/userHotTake';
25+
import { getAddHotTakeProfileUrl } from '../../../features/profile/components/hotTakes/common';
2626

2727
const SWIPE_THRESHOLD = 80;
2828
const DISMISS_ANIMATION_MS = 340;
@@ -795,10 +795,10 @@ const HotTakeCard = ({
795795
};
796796

797797
const EmptyState = ({
798-
onClose,
798+
onAddOwnHotTakeClick,
799799
username,
800800
}: {
801-
onClose: ModalProps['onRequestClose'];
801+
onAddOwnHotTakeClick: (e: React.MouseEvent) => void;
802802
username?: string;
803803
}): ReactElement => (
804804
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-6">
@@ -823,10 +823,8 @@ const EmptyState = ({
823823
variant={ButtonVariant.Primary}
824824
size={ButtonSize.Large}
825825
tag="a"
826-
href={`${webappUrl}${username}#hot-takes`}
827-
onClick={(e: React.MouseEvent) => {
828-
onClose?.(e);
829-
}}
826+
href={getAddHotTakeProfileUrl(username)}
827+
onClick={onAddOwnHotTakeClick}
830828
>
831829
Share your hot takes
832830
</Button>
@@ -1032,6 +1030,13 @@ const HotAndColdModal = ({
10321030
],
10331031
);
10341032

1033+
const handleAddOwnHotTakeClick = useCallback(
1034+
(e: React.MouseEvent) => {
1035+
onRequestClose?.(e);
1036+
},
1037+
[onRequestClose],
1038+
);
1039+
10351040
const isCurrentTakeAnimating =
10361041
!!currentTake && isAnimating && animatingTakeId === currentTake.id;
10371042
const cardSwipeDelta =
@@ -1103,7 +1108,10 @@ const HotAndColdModal = ({
11031108
)}
11041109

11051110
{!isLoading && isEmpty && (
1106-
<EmptyState onClose={onRequestClose} username={user?.username} />
1111+
<EmptyState
1112+
onAddOwnHotTakeClick={handleAddOwnHotTakeClick}
1113+
username={user?.username}
1114+
/>
11071115
)}
11081116

11091117
{!isLoading && !isEmpty && currentTake && (
@@ -1186,11 +1194,9 @@ const HotAndColdModal = ({
11861194
variant={ButtonVariant.Tertiary}
11871195
size={ButtonSize.Medium}
11881196
tag="a"
1189-
href={`${webappUrl}${user.username}#hot-takes`}
1197+
href={getAddHotTakeProfileUrl(user.username)}
11901198
className="w-full"
1191-
onClick={(e: React.MouseEvent) => {
1192-
onRequestClose?.(e);
1193-
}}
1199+
onClick={handleAddOwnHotTakeClick}
11941200
>
11951201
Add your own hot take
11961202
</Button>
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import React from 'react';
2+
import { render, screen, waitFor } from '@testing-library/react';
3+
import type { NextRouter } from 'next/router';
4+
import { useRouter } from 'next/router';
5+
import type { PublicProfile } from '../../../../lib/user';
6+
import type { HotTake } from '../../../../graphql/user/userHotTake';
7+
import { useHotTakes } from '../../hooks/useHotTakes';
8+
import { useToastNotification } from '../../../../hooks/useToastNotification';
9+
import { usePrompt } from '../../../../hooks/usePrompt';
10+
import { useVoteHotTake } from '../../../../hooks/vote/useVoteHotTake';
11+
import { useLogContext } from '../../../../contexts/LogContext';
12+
import { LogEvent } from '../../../../lib/log';
13+
import { ProfileUserHotTakes } from './ProfileUserHotTakes';
14+
15+
jest.mock('../../hooks/useHotTakes', () => ({
16+
HOT_TAKE_LIMIT_REACHED_MESSAGE:
17+
'You already have all 5 hot takes. Remove one to add a new one.',
18+
useHotTakes: jest.fn(),
19+
}));
20+
21+
jest.mock('../../../../hooks/useToastNotification', () => ({
22+
useToastNotification: jest.fn(),
23+
}));
24+
25+
jest.mock('../../../../hooks/usePrompt', () => ({
26+
usePrompt: jest.fn(),
27+
}));
28+
29+
jest.mock('../../../../hooks/vote/useVoteHotTake', () => ({
30+
useVoteHotTake: jest.fn(),
31+
}));
32+
33+
jest.mock('../../../../contexts/LogContext', () => ({
34+
...jest.requireActual('../../../../contexts/LogContext'),
35+
useLogContext: jest.fn(),
36+
}));
37+
38+
jest.mock('./HotTakeModal', () => ({
39+
HotTakeModal: () => <div data-testid="hot-take-modal" />,
40+
}));
41+
42+
jest.mock('./HotTakeItem', () => ({
43+
HotTakeItem: ({ item }: { item: { title: string } }) => (
44+
<div>{item.title}</div>
45+
),
46+
}));
47+
48+
const mockedUseRouter = useRouter as jest.Mock;
49+
const mockedUseHotTakes = useHotTakes as jest.Mock;
50+
const mockedUseToastNotification = useToastNotification as jest.Mock;
51+
const mockedUsePrompt = usePrompt as jest.Mock;
52+
const mockedUseVoteHotTake = useVoteHotTake as jest.Mock;
53+
const mockedUseLogContext = useLogContext as jest.Mock;
54+
55+
const user: PublicProfile = {
56+
id: 'user-1',
57+
name: 'Tester',
58+
username: 'tester',
59+
createdAt: '2026-01-01T00:00:00.000Z',
60+
premium: false,
61+
image: '',
62+
reputation: 0,
63+
permalink: '/tester',
64+
};
65+
66+
const createHotTake = (position: number): HotTake => ({
67+
id: `take-${position}`,
68+
emoji: '🔥',
69+
title: `Hot take ${position}`,
70+
subtitle: null,
71+
position,
72+
createdAt: '2026-01-01T00:00:00.000Z',
73+
upvotes: 0,
74+
upvoted: false,
75+
});
76+
77+
const mockRouter = (query: NextRouter['query'] = {}) => {
78+
const replace = jest.fn();
79+
80+
mockedUseRouter.mockReturnValue({
81+
query,
82+
pathname: '/[userId]',
83+
replace,
84+
} as unknown as NextRouter);
85+
86+
return { replace };
87+
};
88+
89+
describe('ProfileUserHotTakes', () => {
90+
const displayToast = jest.fn();
91+
const logEvent = jest.fn();
92+
93+
beforeEach(() => {
94+
jest.clearAllMocks();
95+
window.history.pushState({}, '', '/tester');
96+
mockRouter();
97+
mockedUseToastNotification.mockReturnValue({
98+
displayToast,
99+
dismissToast: jest.fn(),
100+
});
101+
mockedUsePrompt.mockReturnValue({
102+
showPrompt: jest.fn(),
103+
});
104+
mockedUseVoteHotTake.mockReturnValue({
105+
toggleUpvote: jest.fn(),
106+
});
107+
mockedUseLogContext.mockReturnValue({
108+
logEvent,
109+
});
110+
mockedUseHotTakes.mockReturnValue({
111+
hotTakes: [],
112+
isOwner: true,
113+
canAddMore: true,
114+
isLoading: false,
115+
add: jest.fn(),
116+
update: jest.fn(),
117+
remove: jest.fn(),
118+
});
119+
});
120+
121+
it('should open the add hot take modal from the profile query param', async () => {
122+
window.history.pushState({}, '', '/tester?addHotTake=1#hot-takes');
123+
const { replace } = mockRouter({
124+
userId: 'tester',
125+
addHotTake: '1',
126+
});
127+
128+
render(<ProfileUserHotTakes user={user} />);
129+
130+
expect(await screen.findByTestId('hot-take-modal')).toBeVisible();
131+
expect(logEvent).toHaveBeenCalledWith({
132+
event_name: LogEvent.StartAddHotTake,
133+
});
134+
await waitFor(() => {
135+
expect(replace).toHaveBeenCalledWith('/tester#hot-takes', undefined, {
136+
shallow: true,
137+
});
138+
});
139+
});
140+
141+
it('should show the limit toast from the profile query param when the user cannot add more', async () => {
142+
window.history.pushState({}, '', '/tester?addHotTake=1#hot-takes');
143+
const { replace } = mockRouter({
144+
userId: 'tester',
145+
addHotTake: '1',
146+
});
147+
mockedUseHotTakes.mockReturnValue({
148+
hotTakes: Array.from({ length: 5 }, (_, index) =>
149+
createHotTake(index + 1),
150+
),
151+
isOwner: true,
152+
canAddMore: false,
153+
isLoading: false,
154+
add: jest.fn(),
155+
update: jest.fn(),
156+
remove: jest.fn(),
157+
});
158+
159+
render(<ProfileUserHotTakes user={user} />);
160+
161+
await waitFor(() => {
162+
expect(displayToast).toHaveBeenCalledWith(
163+
'You already have all 5 hot takes. Remove one to add a new one.',
164+
);
165+
});
166+
expect(screen.queryByTestId('hot-take-modal')).not.toBeInTheDocument();
167+
expect(replace).toHaveBeenCalledWith('/tester#hot-takes', undefined, {
168+
shallow: true,
169+
});
170+
});
171+
});

packages/shared/src/features/profile/components/hotTakes/ProfileUserHotTakes.tsx

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import type { ReactElement } from 'react';
2-
import React, { useState, useCallback } from 'react';
2+
import React, { useState, useCallback, useEffect, useRef } from 'react';
3+
import { useRouter } from 'next/router';
34
import type { PublicProfile } from '../../../../lib/user';
4-
import { useHotTakes, MAX_HOT_TAKES } from '../../hooks/useHotTakes';
5+
import {
6+
HOT_TAKE_LIMIT_REACHED_MESSAGE,
7+
useHotTakes,
8+
} from '../../hooks/useHotTakes';
59
import {
610
Typography,
711
TypographyType,
@@ -24,6 +28,11 @@ import { usePrompt } from '../../../../hooks/usePrompt';
2428
import { useVoteHotTake } from '../../../../hooks/vote/useVoteHotTake';
2529
import { useLogContext } from '../../../../contexts/LogContext';
2630
import { LogEvent, Origin } from '../../../../lib/log';
31+
import {
32+
HOT_TAKES_ANCHOR,
33+
isOpenAddHotTakeQuery,
34+
OPEN_ADD_HOT_TAKE_QUERY_PARAM,
35+
} from './common';
2736

2837
interface ProfileUserHotTakesProps {
2938
user: PublicProfile;
@@ -32,7 +41,8 @@ interface ProfileUserHotTakesProps {
3241
export function ProfileUserHotTakes({
3342
user,
3443
}: ProfileUserHotTakesProps): ReactElement | null {
35-
const { hotTakes, isOwner, canAddMore, add, update, remove } =
44+
const router = useRouter();
45+
const { hotTakes, isOwner, canAddMore, add, update, remove, isLoading } =
3646
useHotTakes(user);
3747
const { displayToast } = useToastNotification();
3848
const { showPrompt } = usePrompt();
@@ -41,6 +51,7 @@ export function ProfileUserHotTakes({
4151

4252
const [isModalOpen, setIsModalOpen] = useState(false);
4353
const [editingItem, setEditingItem] = useState<HotTake | null>(null);
54+
const handledOpenAddHotTakeQueryRef = useRef(false);
4455

4556
const handleAdd = useCallback(
4657
async (input: AddHotTakeInput) => {
@@ -111,7 +122,7 @@ export function ProfileUserHotTakes({
111122

112123
const handleOpenModal = useCallback(() => {
113124
if (!canAddMore) {
114-
displayToast(`Maximum of ${MAX_HOT_TAKES} hot takes allowed`);
125+
displayToast(HOT_TAKE_LIMIT_REACHED_MESSAGE);
115126
return;
116127
}
117128
logEvent({
@@ -120,6 +131,47 @@ export function ProfileUserHotTakes({
120131
setIsModalOpen(true);
121132
}, [canAddMore, displayToast, logEvent]);
122133

134+
const clearOpenAddHotTakeQuery = useCallback(() => {
135+
if (typeof window === 'undefined') {
136+
return;
137+
}
138+
139+
const url = new URL(window.location.href);
140+
if (!url.searchParams.has(OPEN_ADD_HOT_TAKE_QUERY_PARAM)) {
141+
return;
142+
}
143+
144+
url.searchParams.delete(OPEN_ADD_HOT_TAKE_QUERY_PARAM);
145+
router.replace(`${url.pathname}${url.search}${url.hash}`, undefined, {
146+
shallow: true,
147+
});
148+
}, [router]);
149+
150+
const shouldOpenAddHotTakeFromQuery = isOpenAddHotTakeQuery(
151+
router.query[OPEN_ADD_HOT_TAKE_QUERY_PARAM],
152+
);
153+
154+
useEffect(() => {
155+
if (!shouldOpenAddHotTakeFromQuery) {
156+
handledOpenAddHotTakeQueryRef.current = false;
157+
return;
158+
}
159+
160+
if (handledOpenAddHotTakeQueryRef.current || isLoading || !isOwner) {
161+
return;
162+
}
163+
164+
handledOpenAddHotTakeQueryRef.current = true;
165+
handleOpenModal();
166+
clearOpenAddHotTakeQuery();
167+
}, [
168+
clearOpenAddHotTakeQuery,
169+
handleOpenModal,
170+
isLoading,
171+
isOwner,
172+
shouldOpenAddHotTakeFromQuery,
173+
]);
174+
123175
const handleUpvote = useCallback(
124176
async (item: HotTake) => {
125177
await toggleUpvote({ payload: item, origin: Origin.HotTakeList });
@@ -136,7 +188,7 @@ export function ProfileUserHotTakes({
136188
return (
137189
<div className="flex flex-col gap-4 py-4">
138190
{/* eslint-disable-next-line jsx-a11y/anchor-has-content */}
139-
<a id="hot-takes" />
191+
<a id={HOT_TAKES_ANCHOR} />
140192
<div className="flex items-center justify-between">
141193
<Typography
142194
type={TypographyType.Body}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { webappUrl } from '../../../../lib/constants';
2+
3+
export const HOT_TAKES_ANCHOR = 'hot-takes';
4+
export const OPEN_ADD_HOT_TAKE_QUERY_PARAM = 'addHotTake';
5+
export const OPEN_ADD_HOT_TAKE_QUERY_VALUE = '1';
6+
7+
export const getAddHotTakeProfileUrl = (username: string): string =>
8+
`${webappUrl}${username}?${OPEN_ADD_HOT_TAKE_QUERY_PARAM}=${OPEN_ADD_HOT_TAKE_QUERY_VALUE}#${HOT_TAKES_ANCHOR}`;
9+
10+
export const isOpenAddHotTakeQuery = (
11+
value: string | string[] | undefined,
12+
): boolean =>
13+
value === OPEN_ADD_HOT_TAKE_QUERY_VALUE ||
14+
(Array.isArray(value) && value.includes(OPEN_ADD_HOT_TAKE_QUERY_VALUE));

0 commit comments

Comments
 (0)