diff --git a/packages/shared/src/components/FeedItemComponent.tsx b/packages/shared/src/components/FeedItemComponent.tsx index 21155c6548..d199934f46 100644 --- a/packages/shared/src/components/FeedItemComponent.tsx +++ b/packages/shared/src/components/FeedItemComponent.tsx @@ -1,4 +1,4 @@ -import type { FunctionComponent, ReactElement } from 'react'; +import type { ElementType, ReactElement } from 'react'; import React from 'react'; import type { AdSquadItem, FeedItem } from '../hooks/useFeed'; import { isBoostedPostAd, isBoostedSquadAd } from '../hooks/useFeed'; @@ -9,7 +9,7 @@ import { PostType } from '../graphql/posts'; import type { LoggedUser } from '../lib/user'; import useLogImpression from '../hooks/feed/useLogImpression'; import type { FeedPostClick } from '../hooks/feed/useFeedOnPostClick'; -import { Origin, TargetType } from '../lib/log'; +import { Origin, TargetId, TargetType } from '../lib/log'; import type { UseVotePost } from '../hooks'; import { useFeedLayout } from '../hooks'; import { CollectionList } from './cards/collection/CollectionList'; @@ -18,6 +18,7 @@ import { MarketingCtaList } from './marketingCta/MarketingCtaList'; import { FeedItemType } from './cards/common/common'; import { AdGrid } from './cards/ad/AdGrid'; import { AdList } from './cards/ad/AdList'; +import type { AdCardProps } from './cards/ad/common/common'; import { AcquisitionFormGrid } from './cards/AcquisitionForm/AcquisitionFormGrid'; import { AcquisitionFormList } from './cards/AcquisitionForm/AcquisitionFormList'; import { FreeformGrid } from './cards/Freeform/FreeformGrid'; @@ -97,26 +98,30 @@ export function getFeedItemKey(item: FeedItem, index: number): string { } } -const PostTypeToTagCard: Record = { +const BriefFeedCard = (): ReactElement => ( + +); + +const PostTypeToTagCard: Record = { [PostType.Article]: ArticleGrid, [PostType.Share]: ShareGrid, [PostType.Welcome]: FreeformGrid, [PostType.Freeform]: FreeformGrid, [PostType.VideoYouTube]: ArticleGrid, [PostType.Collection]: CollectionGrid, - [PostType.Brief]: BriefCard, + [PostType.Brief]: BriefFeedCard, [PostType.Poll]: PollGrid, [PostType.SocialTwitter]: SocialTwitterGrid, }; -const PostTypeToTagList: Record = { +const PostTypeToTagList: Record = { [PostType.Article]: ArticleList, [PostType.Share]: ShareList, [PostType.Welcome]: FreeformList, [PostType.Freeform]: FreeformList, [PostType.VideoYouTube]: ArticleList, [PostType.Collection]: CollectionList, - [PostType.Brief]: BriefCard, + [PostType.Brief]: BriefFeedCard, [PostType.Poll]: PollList, [PostType.SocialTwitter]: SocialTwitterList, }; @@ -135,17 +140,25 @@ type GetTagsProps = { postType: PostType; }; +type FeedTags = { + PostTag: ElementType; + SquadAdTag: typeof SquadAdList | typeof SquadAdGrid; + PlaceholderTag: typeof PlaceholderList | typeof PlaceholderGrid; + MarketingCtaTag: typeof MarketingCtaList | typeof MarketingCtaCard; + PlusGridTag: typeof PlusGrid; + AcquisitionFormTag: typeof AcquisitionFormList | typeof AcquisitionFormGrid; +}; + const getTags = ({ isListFeedLayout, shouldUseListMode, postType, -}: GetTagsProps) => { +}: GetTagsProps): FeedTags => { const useListCards = isListFeedLayout || shouldUseListMode; return { PostTag: useListCards ? PostTypeToTagList[postType] ?? ArticleList : PostTypeToTagCard[postType] ?? ArticleGrid, - AdTag: useListCards ? AdList : AdGrid, SquadAdTag: useListCards ? SquadAdList : SquadAdGrid, PlaceholderTag: useListCards ? PlaceholderList : PlaceholderGrid, MarketingCtaTag: useListCards ? MarketingCtaList : MarketingCtaCard, @@ -232,7 +245,7 @@ function FeedItemComponent({ onCommentClick, onReadArticleClick, virtualizedNumCards, -}: FeedItemComponentProps): ReactElement { +}: FeedItemComponentProps): ReactElement | null { const { logEvent } = useLogContext(); const inViewRef = useLogImpression( item, @@ -248,7 +261,6 @@ function FeedItemComponent({ const { boostedBy } = useFeedCardContext(); const { PostTag, - AdTag, SquadAdTag, PlaceholderTag, MarketingCtaTag, @@ -287,6 +299,10 @@ function FeedItemComponent({ const itemPost = item.type === FeedItemType.Post ? item.post : item.ad.data?.post; + if (!itemPost) { + return null; + } + if ( !!itemPost.pinnedAt && itemPost.source?.currentMember?.flags?.collapsePinnedPosts @@ -303,7 +319,7 @@ function FeedItemComponent({ ref={inViewRef} post={{ ...itemPost }} data-testid="postItem" - onUpvoteClick={(post, origin = Origin.Feed) => { + onUpvoteClick={(post: Post, origin = Origin.Feed) => { toggleUpvote({ payload: post, origin, @@ -314,7 +330,7 @@ function FeedItemComponent({ }, }); }} - onDownvoteClick={(post, origin = Origin.Feed) => { + onDownvoteClick={(post: Post, origin = Origin.Feed) => { toggleDownvote({ payload: post, origin, @@ -325,13 +341,15 @@ function FeedItemComponent({ }, }); }} - onPostClick={(post) => onPostClick(post, index, row, column)} - onPostAuxClick={(post) => onPostClick(post, index, row, column, true)} + onPostClick={(post: Post) => onPostClick(post, index, row, column)} + onPostAuxClick={(post: Post) => + onPostClick(post, index, row, column, true) + } onReadArticleClick={() => onReadArticleClick(itemPost, index, row, column) } - onShare={(post) => onShare(post, row, column)} - onBookmarkClick={(post, origin = Origin.Feed) => { + onShare={(post: Post) => onShare(post, row, column)} + onBookmarkClick={(post: Post, origin = Origin.Feed) => { toggleBookmark({ post, origin, @@ -344,12 +362,14 @@ function FeedItemComponent({ }} openNewTab={openNewTab} enableMenu={!!user} - onMenuClick={(event) => onMenuClick(event, index, row, column)} - onCopyLinkClick={(event, post) => + onMenuClick={(event: React.MouseEvent) => + onMenuClick(event, index, row, column) + } + onCopyLinkClick={(event: React.MouseEvent, post: Post) => onCopyLinkClick(event, post, index, row, column) } menuOpened={postMenuIndex === index} - onCommentClick={(post) => + onCommentClick={(post: Post) => onCommentClick(post, index, row, column, !!boostedBy) } eagerLoadImage={row === 0 && column === 0} @@ -361,17 +381,38 @@ function FeedItemComponent({ } switch (item.type) { - case FeedItemType.Ad: + case FeedItemType.Ad: { + const AdListTag = AdList as React.ForwardRefExoticComponent< + AdCardProps & React.RefAttributes + >; + const AdGridTag = AdGrid as React.ForwardRefExoticComponent< + AdCardProps & React.RefAttributes + >; + + if (shouldUseListFeedLayout || shouldUseListMode) { + return ( + onAdAction(AdActions.Click, ad)} + onRefresh={(ad: Ad) => onAdAction(AdActions.Refresh, ad)} + /> + ); + } + return ( - onAdAction(AdActions.Click, ad)} - onRefresh={(ad) => onAdAction(AdActions.Refresh, ad)} + onLinkClick={(ad: Ad) => onAdAction(AdActions.Click, ad)} + onRefresh={(ad: Ad) => onAdAction(AdActions.Refresh, ad)} /> ); + } case FeedItemType.UserAcquisition: return ; case FeedItemType.MarketingCta: diff --git a/packages/shared/src/components/auth/AuthOptionsInner.tsx b/packages/shared/src/components/auth/AuthOptionsInner.tsx index e47ff43e6b..e6b86921b3 100644 --- a/packages/shared/src/components/auth/AuthOptionsInner.tsx +++ b/packages/shared/src/components/auth/AuthOptionsInner.tsx @@ -6,7 +6,10 @@ import dynamic from 'next/dynamic'; import { useAuthContext } from '../../contexts/AuthContext'; import { Tab, TabContainer } from '../tabs/TabContainer'; import type { RegistrationFormValues } from './RegistrationForm'; -import type { RegistrationError } from '../../lib/auth'; +import type { + RegistrationError, + SocialRegistrationParameters, +} from '../../lib/auth'; import { isNativeAuthSupported, AuthEventNames, @@ -138,7 +141,7 @@ function AuthOptionsInner({ !!router?.pathname?.startsWith('/onboarding') || isFunnel; const [flow, setFlow] = useState(''); const [activeDisplay, setActiveDisplay] = useState(() => - storage.getItem(SIGNIN_METHOD_KEY) && !forceDefaultDisplay + storage.getItem?.(SIGNIN_METHOD_KEY) && !forceDefaultDisplay ? AuthDisplay.SignBack : defaultDisplay, ); @@ -152,16 +155,26 @@ function AuthOptionsInner({ }; const [isForgotPasswordReturn, setIsForgotPasswordReturn] = useState(false); - const [handleLoginCheck, setHandleLoginCheck] = useState(null); - const [chosenProvider, setChosenProvider] = usePersistentState( + const [handleLoginCheck, setHandleLoginCheck] = useState( + null, + ); + const [chosenProvider, setChosenProvider] = usePersistentState( CHOSEN_PROVIDER_KEY, null, ); const [isRegistration, setIsRegistration] = useState(false); - const windowPopup = useRef(null); + const windowPopup = useRef(null); + + const refetchBootOrThrow = async () => { + if (!refetchBoot) { + throw new Error('Missing refetchBoot in AuthOptionsInner'); + } + + return refetchBoot(); + }; const checkForOnboardedUser = async (data: LoggedUser) => { - onAuthStateUpdate({ isLoading: true }); + onAuthStateUpdate?.({ isLoading: true }); const isOnboardingPage = router?.pathname?.startsWith('/onboarding'); if (isOnboardingPage) { @@ -188,7 +201,7 @@ function AuthOptionsInner({ } } - onAuthStateUpdate({ isLoading: false }); + onAuthStateUpdate?.({ isLoading: false }); return false; }; @@ -206,10 +219,14 @@ function AuthOptionsInner({ }, onInvalidRegistration: setRegistrationHints, onRedirectFail: () => { - windowPopup.current.close(); + windowPopup.current?.close(); windowPopup.current = null; }, onRedirect: (redirect) => { + if (!windowPopup.current) { + throw new Error('Missing social auth popup in AuthOptionsInner'); + } + windowPopup.current.location.href = redirect; }, keepSession: isOnboardingOrFunnel, @@ -220,22 +237,22 @@ function AuthOptionsInner({ ) => { setIsRegistration(true); const { redirect, setSignBack = true } = options; - const { data } = await refetchBoot(); + const { data } = await refetchBootOrThrow(); - const isLoggedUser = 'infoConfirmed' in data.user; - if (!isLoggedUser) { + if (!data?.user || !('infoConfirmed' in data.user)) { return; } - if (data.user && setSignBack) { + const loggedUser = data.user; + + if (setSignBack) { const provider = chosenProvider || 'password'; - onSignBackLogin(data.user as LoggedUser, provider as SignBackProvider); + onSignBackLogin(loggedUser, provider as SignBackProvider); } logEvent({ event_name: AuthEventNames.SignupSuccessfully, }); - const loggedUser = data?.user as LoggedUser; trackSignup(loggedUser); // if redirect is set, move before modal close @@ -243,8 +260,8 @@ function AuthOptionsInner({ await router.push(redirect); } - onSuccessfulRegistration?.(data?.user); - onClose?.(null, true); + onSuccessfulRegistration?.(loggedUser); + onClose?.({} as React.FormEvent, true); }; const { updateUserProfile, @@ -306,7 +323,7 @@ function AuthOptionsInner({ // Check for claimable items on login (e.g., Plus subscription) claimClaimableItem().then((hasClaimed) => { if (hasClaimed) { - refetchBoot(); + refetchBoot?.(); } }); @@ -316,6 +333,11 @@ function AuthOptionsInner({ } } else if (trigger === AuthTriggers.RecruiterSelfServe) { // For RecruiterSelfServe, auto-complete profile without showing the form + if (!user.email) { + displayToast(labels.auth.error.generic); + return; + } + await autoCompleteProfileForRecruiter(user.email, user.name); } else { onSetActiveDisplay(AuthDisplay.SocialRegistration); @@ -337,12 +359,16 @@ function AuthOptionsInner({ onSuccessfulLogin: onLoginCheck, ...(!isTesting && { queryEnabled: !user && isRegistrationReady }), trigger, - provider: chosenProvider, + provider: chosenProvider ?? undefined, onLoginError: () => { return displayToast(labels.auth.error.generic); }, }); + const fallbackLoginHint = useState('') as ReturnType; + const resolvedLoginHint: ReturnType = + loginHint ?? fallbackLoginHint; + const isReady = isTesting ? true : isLoginReady && isRegistrationReady; const onProviderClick = async (provider: string, login = true) => { logEvent({ @@ -355,9 +381,15 @@ function AuthOptionsInner({ }); // Only web auth requires a popup if (!isNativeAuthSupported(provider)) { - windowPopup.current = window.open(); + windowPopup.current = window.open() ?? null; } await setChosenProvider(provider); + if (!onSocialRegistration) { + throw new Error( + 'Missing social registration handler in AuthOptionsInner', + ); + } + await onSocialRegistration(provider); onAuthStateUpdate?.({ isLoading: true }); }; @@ -373,9 +405,10 @@ function AuthOptionsInner({ }; const handleLoginMessage = async () => { - const { data: boot } = await refetchBoot(); + const { data: boot } = await refetchBootOrThrow(); + const bootUser = boot?.user; - if (!boot.user || !('email' in boot.user)) { + if (!bootUser || !('email' in bootUser)) { logEvent({ event_name: AuthEventNames.SubmitSignUpFormError, extra: JSON.stringify({ @@ -387,9 +420,10 @@ function AuthOptionsInner({ } // If user is confirmed we can proceed with logging them in - if ('infoConfirmed' in boot.user && boot.user.infoConfirmed) { - await onSignBackLogin(boot.user, chosenProvider as SignBackProvider); - const isAlreadyOnboarded = await checkForOnboardedUser(boot.user); + if ('infoConfirmed' in bootUser && bootUser.infoConfirmed) { + const provider = (chosenProvider || 'password') as SignBackProvider; + await onSignBackLogin(bootUser, provider); + const isAlreadyOnboarded = await checkForOnboardedUser(bootUser); if (!isAlreadyOnboarded) { onSuccessfulLogin?.(); } @@ -398,13 +432,18 @@ function AuthOptionsInner({ // For RecruiterSelfServe, auto-complete profile without showing the form if (trigger === AuthTriggers.RecruiterSelfServe) { - const loggedUser = boot.user as LoggedUser; + const loggedUser = bootUser as LoggedUser; + if (!loggedUser.email) { + displayToast(labels.auth.error.generic); + return; + } + await autoCompleteProfileForRecruiter(loggedUser.email, loggedUser.name); return; } await setChosenProvider(chosenProvider || 'password'); - onAuthStateUpdate({ defaultDisplay: AuthDisplay.SocialRegistration }); + onAuthStateUpdate?.({ defaultDisplay: AuthDisplay.SocialRegistration }); onSetActiveDisplay(AuthDisplay.SocialRegistration); }; @@ -431,12 +470,14 @@ function AuthOptionsInner({ }), }); + const errorId = connected?.ui?.messages?.[0]?.id; if ( + errorId && [ KRATOS_ERROR.NO_STRATEGY_TO_LOGIN, KRATOS_ERROR.NO_STRATEGY_TO_SIGNUP, KRATOS_ERROR.EXISTING_USER, - ].includes(connected?.ui?.messages?.[0]?.id) + ].includes(errorId) ) { const registerUser = { name: getNodeValue('traits.name', connected.ui.nodes), @@ -446,8 +487,15 @@ function AuthOptionsInner({ // Native auth doesn't return traits, so we must validate that it exists if (registerUser.email) { const { result } = await getKratosProviders(connected.id); + const provider = result[0]; + if (!provider) { + throw new Error( + 'Missing social provider result in AuthOptionsInner', + ); + } + setIsConnected(true); - await onSignBackLogin(registerUser, result[0] as SignBackProvider); + await onSignBackLogin(registerUser, provider as SignBackProvider); return onSetActiveDisplay(AuthDisplay.SignBack); } onSetActiveDisplay(AuthDisplay.SignBack); @@ -462,7 +510,7 @@ function AuthOptionsInner({ useEventListener(broadcastChannel, 'message', onProviderMessage); - useEventListener(globalThis, 'message', onProviderMessage); + useEventListener(globalThis?.window, 'message', onProviderMessage); const onEmailRegistration = (emailAd: string) => { // before displaying registration, ensure the email doesn't exist @@ -470,8 +518,15 @@ function AuthOptionsInner({ setEmail(emailAd); }; - const onSocialCompletion = async (params) => { - updateUserProfile({ ...params }); + const onSocialCompletion = async (params: SocialRegistrationParameters) => { + const profileParams: Parameters[0] = { + name: params.name, + username: params.username, + acceptedMarketing: params.acceptedMarketing, + experienceLevel: params.experienceLevel as LoggedUser['experienceLevel'], + language: params.language, + }; + updateUserProfile(profileParams); await syncSettings(); }; @@ -488,7 +543,7 @@ function AuthOptionsInner({ event_name: 'click', target_type: AuthEventNames.ForgotPassword, }); - setEmail(withEmail); + setEmail(withEmail ?? ''); onSetActiveDisplay(AuthDisplay.ForgotPassword); }; @@ -524,7 +579,7 @@ function AuthOptionsInner({ isLoading={isPasswordLoginLoading} isLoginFlow={isForgotPasswordReturn || isLoginFlow} isReady={isReady} - loginHint={loginHint} + loginHint={resolvedLoginHint} onForgotPassword={onForgotPassword} onPasswordLogin={onEmailLogin} onProviderClick={onProviderClick} @@ -537,7 +592,7 @@ function AuthOptionsInner({ { - onAuthStateUpdate({ + onAuthStateUpdate?.({ isAuthenticating: undefined, defaultDisplay: AuthDisplay.OnboardingSignup, }); }} onExistingEmailLoginClick={() => { - onAuthStateUpdate({ + onAuthStateUpdate?.({ isLoginFlow: true, }); setActiveDisplay(AuthDisplay.Default); }} onSignup={(params) => { - setEmail(params['traits.email']); + setEmail(params['traits.email'] ?? ''); onRegister(params); }} onUpdateHints={setRegistrationHints} @@ -587,20 +642,20 @@ function AuthOptionsInner({ { - onAuthStateUpdate({ + onAuthStateUpdate?.({ isAuthenticating: true, defaultDisplay: AuthDisplay.Registration, }); }} onSignup={(signupEmail) => { - onAuthStateUpdate({ + onAuthStateUpdate?.({ isAuthenticating: true, email: signupEmail, defaultDisplay: AuthDisplay.Registration, }); }} onExistingEmail={(existingEmail) => { - onAuthStateUpdate({ + onAuthStateUpdate?.({ isAuthenticating: true, isLoginFlow: true, email: existingEmail, @@ -637,7 +692,7 @@ function AuthOptionsInner({ }} loginFormProps={{ isReady, - loginHint, + loginHint: resolvedLoginHint, onPasswordLogin, onForgotPassword, isLoading: isPasswordLoginLoading, @@ -672,7 +727,7 @@ function AuthOptionsInner({