From 9e41d526738bf7276b15fa9f72830334da65b4d3 Mon Sep 17 00:00:00 2001 From: LautaroPetaccio Date: Sat, 7 Mar 2026 22:06:02 -0300 Subject: [PATCH] feat: Refactor components --- .../CharacterCounter.styled.ts | 16 +-- src/components/Connection/Connection.types.ts | 14 ++- .../AvatarSetupPage/AvatarSetupPage.styled.ts | 26 +--- .../Pages/AvatarSetupPage/AvatarSetupPage.tsx | 65 +++------- .../Pages/LoginPage/LoginPage.styled.ts | 14 +-- .../MobileAuthPage/MobileAuthPage.styled.ts | 13 +- .../RequestPage/Views/RecoverError.styled.ts | 8 +- .../TransferCanceledView.tsx | 28 ++--- .../TransferCanceledView.types.ts | 7 +- .../TransferCompletedView.tsx | 30 ++--- .../TransferCompletedView.types.ts | 7 +- .../TransferConfirmView.tsx | 27 ++--- .../TransferConfirmView.types.ts | 9 +- .../Views/TransferView/useTransferViewData.ts | 24 ++++ src/components/Pages/SetupPage/SetupPage.tsx | 111 ++++-------------- src/components/Transfer/Transfer.styled.ts | 82 +------------ src/components/shared/ErrorDisplay.styled.ts | 26 ++++ .../shared/GradientBackground.styled.ts | 38 ++++++ src/hooks/targetConfig.ts | 8 +- src/hooks/useAuthFlow.ts | 2 +- src/hooks/usePostSignupActions.ts | 47 ++++++++ src/hooks/useRequestIdFromRedirect.ts | 21 ++++ src/hooks/useRequireAuth.ts | 29 +++++ src/hooks/useSetupFormValidation.ts | 41 +++++++ src/hooks/useTrackReferral.ts | 2 +- src/hooks/useWalletOptions.ts | 8 +- src/shared/auth/httpClient.ts | 62 ++-------- src/shared/auth/index.ts | 1 + src/shared/auth/utils.ts | 73 ++++++++++++ src/shared/auth/wsClient.ts | 59 +--------- src/shared/connection/hook.ts | 33 ------ src/shared/locations.ts | 34 ++---- src/shared/mobile.ts | 31 ++--- src/shared/utils/stateParameter.ts | 46 ++++++++ 34 files changed, 488 insertions(+), 554 deletions(-) create mode 100644 src/components/Pages/RequestPage/Views/TransferView/useTransferViewData.ts create mode 100644 src/components/shared/ErrorDisplay.styled.ts create mode 100644 src/components/shared/GradientBackground.styled.ts create mode 100644 src/hooks/usePostSignupActions.ts create mode 100644 src/hooks/useRequestIdFromRedirect.ts create mode 100644 src/hooks/useRequireAuth.ts create mode 100644 src/hooks/useSetupFormValidation.ts create mode 100644 src/shared/auth/utils.ts delete mode 100644 src/shared/connection/hook.ts create mode 100644 src/shared/utils/stateParameter.ts diff --git a/src/components/CharacterCounter/CharacterCounter.styled.ts b/src/components/CharacterCounter/CharacterCounter.styled.ts index bcfec3a8..2c82f25a 100644 --- a/src/components/CharacterCounter/CharacterCounter.styled.ts +++ b/src/components/CharacterCounter/CharacterCounter.styled.ts @@ -1,6 +1,5 @@ -import { Box, Typography, muiIcons, styled } from 'decentraland-ui2' - -const WarningAmberOutlinedIcon = muiIcons.WarningAmberOutlined +import { Box, Typography, styled } from 'decentraland-ui2' +import { ErrorText, WarningIcon } from '../shared/ErrorDisplay.styled' const CharacterCounter = styled(Box)({ display: 'flex', @@ -16,15 +15,4 @@ const CharacterCounterText = styled(Typography)<{ isError: boolean }>(({ isError margin: 0 })) -const WarningIcon = styled(WarningAmberOutlinedIcon)({ - color: 'rgba(224, 0, 0, 1)', - height: '15px', - width: '15px' -}) - -const ErrorText = styled('span')({ - color: 'rgba(224, 0, 0, 1)', - fontSize: '14px' -}) - export { CharacterCounter, CharacterCounterText, WarningIcon, ErrorText } diff --git a/src/components/Connection/Connection.types.ts b/src/components/Connection/Connection.types.ts index 08375f54..7439c5e3 100644 --- a/src/components/Connection/Connection.types.ts +++ b/src/components/Connection/Connection.types.ts @@ -38,13 +38,15 @@ const connectionOptionTitles: { [key in ConnectionOptionType]: string } = { type MetamaskEthereumWindow = typeof window.ethereum & { isMetaMask?: boolean } +type ConnectionOptions = { + primary: ConnectionOptionType + secondary?: ConnectionOptionType + extraOptions?: ConnectionOptionType[] +} + type ConnectionProps = { shouldShowGoogleOptionAsPrimary?: boolean - connectionOptions?: { - primary: ConnectionOptionType - secondary?: ConnectionOptionType - extraOptions?: ConnectionOptionType[] - } + connectionOptions?: ConnectionOptions className?: string loadingOption?: ConnectionOptionType isNewUser?: boolean @@ -57,4 +59,4 @@ type ConnectionProps = { } export { SignInOptionsMode, ConnectionOptionType, connectionOptionTitles } -export type { MetamaskEthereumWindow, ConnectionProps } +export type { ConnectionOptions, MetamaskEthereumWindow, ConnectionProps } diff --git a/src/components/Pages/AvatarSetupPage/AvatarSetupPage.styled.ts b/src/components/Pages/AvatarSetupPage/AvatarSetupPage.styled.ts index d3631d1b..69eab2a4 100644 --- a/src/components/Pages/AvatarSetupPage/AvatarSetupPage.styled.ts +++ b/src/components/Pages/AvatarSetupPage/AvatarSetupPage.styled.ts @@ -1,12 +1,10 @@ // eslint-disable-next-line @typescript-eslint/naming-convention import Lottie from 'lottie-react' import { brand, neutral } from 'decentraland-ui2/dist/theme/colors' -import { Box, Button, Checkbox, FormControlLabel, Link, Logo, TextField, Typography, muiIcons, styled } from 'decentraland-ui2' +import { Box, Button, Checkbox, FormControlLabel, Link, Logo, TextField, Typography, styled } from 'decentraland-ui2' // eslint-disable-next-line @typescript-eslint/naming-convention import SetupRightBackground from '../../../assets/images/setup-right-background.webp' -const WarningAmberOutlinedIcon = muiIcons.WarningAmberOutlined - const MainContainer = styled(Box)({ display: 'flex', height: '100vh', @@ -160,23 +158,6 @@ const TextInput = styled(TextField)<{ hasError?: boolean }>(({ hasError }) => ({ } })) -const ErrorContainer = styled(Box)({ - display: 'flex', - alignItems: 'center', - gap: '4px' -}) - -const ErrorText = styled('span')({ - color: 'rgba(224, 0, 0, 1)', - fontSize: '14px' -}) - -const WarningIcon = styled(WarningAmberOutlinedIcon)({ - color: 'rgba(224, 0, 0, 1)', - height: '15px', - width: '15px' -}) - const EmailDescription = styled(Typography)({ fontSize: '14px', lineHeight: '100%', @@ -292,10 +273,7 @@ export { InputContainer, InputLabel, TextInput, - ErrorContainer, ErrorLabel, - ErrorText, - WarningIcon, EmailDescription, CheckboxContainer, CheckboxRow, @@ -307,3 +285,5 @@ export { AvatarParticles, PreloadedWearableContainer } + +export { ErrorContainer, ErrorText, WarningIcon } from '../../shared/ErrorDisplay.styled' diff --git a/src/components/Pages/AvatarSetupPage/AvatarSetupPage.tsx b/src/components/Pages/AvatarSetupPage/AvatarSetupPage.tsx index c093d2c6..ddd6da48 100644 --- a/src/components/Pages/AvatarSetupPage/AvatarSetupPage.tsx +++ b/src/components/Pages/AvatarSetupPage/AvatarSetupPage.tsx @@ -1,9 +1,8 @@ // eslint-disable-next-line @typescript-eslint/naming-convention import * as React from 'react' -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { useTranslation } from '@dcl/hooks' -import { EthAddress } from '@dcl/schemas' import { PreviewUnityMode } from '@dcl/schemas/dist/dapps/preview' import { CircularProgress, WearablePreview, launchDesktopApp } from 'decentraland-ui2' import avatarFloat from '../../../assets/animations/AvatarFloat_Lottie.json' @@ -11,12 +10,13 @@ import avatarParticles from '../../../assets/animations/AvatarParticles_Lottie.j import { useNavigateWithSearchParams } from '../../../hooks/navigation' import { useAfterLoginRedirection } from '../../../hooks/redirection' import { useAnalytics } from '../../../hooks/useAnalytics' +import { usePostSignupActions } from '../../../hooks/usePostSignupActions' +import { useRequestIdFromRedirect } from '../../../hooks/useRequestIdFromRedirect' +import { useRequireAuth } from '../../../hooks/useRequireAuth' import { useSignRequest } from '../../../hooks/useSignRequest' -import { useTrackReferral } from '../../../hooks/useTrackReferral' import { config } from '../../../modules/config' import { fetchProfile } from '../../../modules/profile' import { IpValidationError, createAuthServerHttpClient, createAuthServerWsClient } from '../../../shared/auth' -import { useCurrentConnectionData } from '../../../shared/connection/hooks' import { isEmailValid } from '../../../shared/email' import { locations } from '../../../shared/locations' import { isProfileComplete } from '../../../shared/profile' @@ -24,8 +24,7 @@ import { handleError } from '../../../shared/utils/errorHandler' import { checkWebGpuSupport } from '../../../shared/utils/webgpu' import { AnimatedBackground } from '../../AnimatedBackground' import { CharacterCounterComponent } from '../../CharacterCounter' -import { FeatureFlagsContext, FeatureFlagsKeys } from '../../FeatureFlagsProvider' -import { subscribeToNewsletter } from '../SetupPage/utils' +import { FeatureFlagsKeys } from '../../FeatureFlagsProvider' import { deployProfileFromAvatarShape } from './utils' import { AvatarSetupState, AvatarShape } from './AvatarSetupPage.types' import { @@ -62,16 +61,14 @@ const MAX_CHARACTERS = 15 const AvatarSetupPage: React.FC = () => { const { t } = useTranslation() - const hasTrackedReferral = useRef(false) const [urlSearchParams] = useSearchParams() - const { flags, initialized: initializedFlags } = useContext(FeatureFlagsContext) + const { isReady, isAuthenticated, flags, account, identity, provider } = useRequireAuth() const [initialized, setInitialized] = useState(false) const { url: redirectTo, redirect } = useAfterLoginRedirection() - const { isLoading: isConnecting, account, identity, provider } = useCurrentConnectionData() const { signRequest, authServerClient } = useSignRequest(redirect) const navigate = useNavigateWithSearchParams() const referrer = urlSearchParams.get('referrer') - const { track: trackReferral } = useTrackReferral() + const { trackReferralOnDeploy, trackReferralOnInit, subscribeEmail } = usePostSignupActions(referrer) const { trackClick, trackAvatarEditSuccess, trackTermsOfServiceSuccess, trackCheckTermsOfService } = useAnalytics() const [state, setState] = useState({ @@ -92,18 +89,7 @@ const AvatarSetupPage: React.FC = () => { const [isAvatarParticlesAnimationEnded, setIsAvatarParticlesAnimationEnded] = useState(false) - const requestId = useMemo(() => { - const redirectTo = urlSearchParams.get('redirectTo') - let requestId: string | null = null - try { - const url = new URL(redirectTo ?? '', window.location.origin) - const regex = /^\/?auth\/requests\/([a-zA-Z0-9-]+)$/ - requestId = url.pathname.match(regex)?.[1] ?? null - } catch { - // Do nothing - } - return requestId - }, [urlSearchParams]) + const requestId = useRequestIdFromRedirect() const characterCount = useMemo(() => state.username.length, [state.username]) @@ -202,22 +188,8 @@ const AvatarSetupPage: React.FC = () => { avatarShape }) - if (referrer && EthAddress.validate(referrer)) { - try { - await trackReferral(referrer, 'PATCH') - } catch { - // Error is already handled in trackReferral - } - } - - // Subscribe to the newsletter only if the user has provided an email - if (state.email) { - try { - await subscribeToNewsletter(state.email) - } catch (e) { - handleError(e, 'Error subscribing to newsletter', { skipTracking: true }) - } - } + await trackReferralOnDeploy() + await subscribeEmail(state.email) const storedEmail = localStorage.getItem('dcl_magic_user_email') if (storedEmail) { @@ -260,14 +232,14 @@ const AvatarSetupPage: React.FC = () => { state.email, account, identity, - referrer, requestId, provider, flags, redirect, signRequest, trackClick, - trackReferral, + trackReferralOnDeploy, + subscribeEmail, trackTermsOfServiceSuccess, isProcessingMessage ] @@ -310,13 +282,10 @@ const AvatarSetupPage: React.FC = () => { console.warn('Failed to get user email from localStorage:', error) } - if (referrer && EthAddress.validate(referrer) && !hasTrackedReferral.current) { - await trackReferral(referrer, 'POST') - hasTrackedReferral.current = true - } + await trackReferralOnInit() setInitialized(true) - }, [account, flags, provider, referrer, redirect, trackReferral]) + }, [account, flags, provider, redirect, trackReferralOnInit]) useEffect(() => { window.addEventListener('message', handleMessage, false) @@ -332,9 +301,9 @@ const AvatarSetupPage: React.FC = () => { }, [state.hasWearablePreviewLoaded]) useEffect(() => { - if (isConnecting || !initializedFlags) return + if (!isReady) return - if (!account || !identity) { + if (!isAuthenticated) { console.warn('No previous connection found') return navigate(locations.login(redirectTo, referrer)) } @@ -345,7 +314,7 @@ const AvatarSetupPage: React.FC = () => { } initializeAvatarSetup() }) - }, [initializeAvatarSetup, account, identity, isConnecting, initializedFlags, navigate, redirectTo, referrer]) + }, [initializeAvatarSetup, account, identity, isReady, isAuthenticated, navigate, redirectTo, referrer]) if (!initialized) { return ( diff --git a/src/components/Pages/LoginPage/LoginPage.styled.ts b/src/components/Pages/LoginPage/LoginPage.styled.ts index fc35148a..3df7a6e8 100644 --- a/src/components/Pages/LoginPage/LoginPage.styled.ts +++ b/src/components/Pages/LoginPage/LoginPage.styled.ts @@ -1,4 +1,5 @@ import { Box, keyframes, styled } from 'decentraland-ui2' +import { desktopLinearGradient, gradientPseudoElement, mobileLinearGradient } from '../../shared/GradientBackground.styled' const moveBackground = keyframes({ from: { @@ -20,15 +21,8 @@ const Main = styled('main')(({ theme }) => ({ minWidth: 0, boxSizing: 'border-box', ['&::before']: { - content: '""', - position: 'fixed', - width: '100%', - height: '350%', - background: - 'linear-gradient(89.65deg, rgba(149, 45, 198, 0) 29.59%, rgba(94, 30, 130, 0.559754) 39.08%, rgba(75, 25, 106, 0.750004) 45.36%, rgba(64, 23, 93, 0.859976) 54.22%, #32134C 72.84%)', - top: '-100%', - transform: 'rotate(180deg)', - overflow: 'hidden' + ...(gradientPseudoElement as object), + background: desktopLinearGradient }, [theme.breakpoints.down('lg')]: { gridTemplateColumns: '50% 50%' @@ -37,7 +31,7 @@ const Main = styled('main')(({ theme }) => ({ display: 'flex', height: 'auto', ['&::before']: { - background: 'linear-gradient(151.89deg, #491975 47.67%, #D72CCD 103.3%)' + background: mobileLinearGradient } } })) diff --git a/src/components/Pages/MobileAuthPage/MobileAuthPage.styled.ts b/src/components/Pages/MobileAuthPage/MobileAuthPage.styled.ts index 62191be8..72e5aadd 100644 --- a/src/components/Pages/MobileAuthPage/MobileAuthPage.styled.ts +++ b/src/components/Pages/MobileAuthPage/MobileAuthPage.styled.ts @@ -1,4 +1,5 @@ import { Box, styled } from 'decentraland-ui2' +import { desktopRadialGradient, gradientPseudoElement, mobileRadialGradient } from '../../shared/GradientBackground.styled' const Main = styled(Box)({ display: 'flex', @@ -13,19 +14,13 @@ const Main = styled(Box)({ padding: '20vh 20px 0', boxSizing: 'border-box', ['&::before']: { - content: '""', - position: 'fixed', - width: '100%', - height: '350%', - background: 'radial-gradient(ellipse at 0 50%, transparent 10%, #e02dd3 40%, #491975 70%)', - top: '-100%', - transform: 'rotate(180deg)', - overflow: 'hidden', + ...(gradientPseudoElement as object), + background: desktopRadialGradient, zIndex: -1 }, ['@media screen and (max-width: 800px)']: { ['&::before']: { - background: 'radial-gradient(ellipse at 0 50%, #e02dd3 0%, #491975 70%)' + background: mobileRadialGradient } } }) diff --git a/src/components/Pages/RequestPage/Views/RecoverError.styled.ts b/src/components/Pages/RequestPage/Views/RecoverError.styled.ts index 4524d362..4a5a341c 100644 --- a/src/components/Pages/RequestPage/Views/RecoverError.styled.ts +++ b/src/components/Pages/RequestPage/Views/RecoverError.styled.ts @@ -1,7 +1 @@ -import { muiIcons, styled } from 'decentraland-ui2' - -const ErrorMessageIcon = styled(muiIcons.ErrorOutline)({ - color: '#fb3b3b' -}) - -export { ErrorMessageIcon } +export { ErrorMessageIcon } from '../../../shared/ErrorDisplay.styled' diff --git a/src/components/Pages/RequestPage/Views/TransferView/TransferCanceledView/TransferCanceledView.tsx b/src/components/Pages/RequestPage/Views/TransferView/TransferCanceledView/TransferCanceledView.tsx index 6dc9c2c3..125cd445 100644 --- a/src/components/Pages/RequestPage/Views/TransferView/TransferCanceledView/TransferCanceledView.tsx +++ b/src/components/Pages/RequestPage/Views/TransferView/TransferCanceledView/TransferCanceledView.tsx @@ -4,23 +4,19 @@ import { Rarity } from '@dcl/schemas' import { Box, Profile } from 'decentraland-ui2' import { TransferAlert, TransferAssetImage, TransferLayout, TransferSecondaryText } from '../../../../../Transfer' import { CenteredContent, ItemName, Label, Title } from '../../../../../Transfer/Transfer.styled' -import { type MANATransferData, type NFTTransferData, type ProfileAvatar, TransferType } from '../../../types' import { ColumnContainer, SceneName } from '../TransferTipComponents.styled' +import { useTransferViewData } from '../useTransferViewData' import { TransferCanceledViewProps } from './TransferCanceledView.types' const TransferCanceledView = memo((props: TransferCanceledViewProps) => { const { t } = useTranslation() - const { type, transferData } = props - const isTip = type === TransferType.TIP - const recipientAvatar = transferData.recipientProfile?.avatars?.[0] + const { isTip, recipientAvatar, tipData, giftData, transferData } = useTransferViewData(props) return ( - {isTip - ? t('transfer.canceled.tip_cancelled', { manaAmount: (transferData as MANATransferData).manaAmount }) - : t('transfer.canceled.gift_canceled')}{' '} + {isTip ? t('transfer.canceled.tip_cancelled', { manaAmount: tipData!.manaAmount }) : t('transfer.canceled.gift_canceled')}{' '} {isTip && ( <> @@ -29,7 +25,7 @@ const TransferCanceledView = memo((props: TransferCanceledViewProps) => { {t('transfer.canceled.tip_not_delivered')} { - - {(transferData as MANATransferData).sceneName} + + {tipData!.sceneName} )} @@ -49,15 +45,15 @@ const TransferCanceledView = memo((props: TransferCanceledViewProps) => { <> {t('transfer.canceled.gift_not_delivered')} - + - {(transferData as NFTTransferData).name} + {giftData!.name} )} diff --git a/src/components/Pages/RequestPage/Views/TransferView/TransferCanceledView/TransferCanceledView.types.ts b/src/components/Pages/RequestPage/Views/TransferView/TransferCanceledView/TransferCanceledView.types.ts index e950859b..627f8958 100644 --- a/src/components/Pages/RequestPage/Views/TransferView/TransferCanceledView/TransferCanceledView.types.ts +++ b/src/components/Pages/RequestPage/Views/TransferView/TransferCanceledView/TransferCanceledView.types.ts @@ -1,6 +1,3 @@ -import { TransferType } from '../../../types' -import type { MANATransferData, NFTTransferData } from '../../../types' +import { BaseTransferViewProps } from '../useTransferViewData' -export type TransferCanceledViewProps = - | { type: TransferType.TIP; transferData: MANATransferData } - | { type: TransferType.GIFT; transferData: NFTTransferData } +export type TransferCanceledViewProps = BaseTransferViewProps diff --git a/src/components/Pages/RequestPage/Views/TransferView/TransferCompletedView/TransferCompletedView.tsx b/src/components/Pages/RequestPage/Views/TransferView/TransferCompletedView/TransferCompletedView.tsx index 7762a616..94659911 100644 --- a/src/components/Pages/RequestPage/Views/TransferView/TransferCompletedView/TransferCompletedView.tsx +++ b/src/components/Pages/RequestPage/Views/TransferView/TransferCompletedView/TransferCompletedView.tsx @@ -4,9 +4,8 @@ import { Rarity } from '@dcl/schemas' import { Box, Profile } from 'decentraland-ui2' import { TransferAlert, TransferAssetImage, TransferLayout } from '../../../../../Transfer' import { CenteredContent, ItemName, Label, Title } from '../../../../../Transfer/Transfer.styled' -import { TransferType } from '../../../types' -import type { MANATransferData, NFTTransferData, ProfileAvatar } from '../../../types' import { SceneName } from '../TransferTipComponents.styled' +import { useTransferViewData } from '../useTransferViewData' import { TransferCompletedViewProps } from './TransferCompletedView.types' import { SceneImageWrapper, SuccessAnimation } from './TransferCompletedView.styled' @@ -22,23 +21,19 @@ const TransferCompletedView = (props: TransferCompletedViewProps) => { useEffect(() => { import('../../../../../../assets/animations/successAnimation_Lottie.json').then(m => setSuccessAnimation(m.default)) }, []) - const { type, transferData } = props - const isTip = type === TransferType.TIP - const recipientAvatar = transferData.recipientProfile?.avatars?.[0] + const { isTip, recipientAvatar, tipData, giftData, transferData } = useTransferViewData(props) return ( - {isTip - ? t('transfer.completed.tip_success', { manaAmount: (transferData as MANATransferData).manaAmount }) - : t('transfer.completed.gift_sent')} + {isTip ? t('transfer.completed.tip_success', { manaAmount: tipData!.manaAmount }) : t('transfer.completed.gift_sent')} {isTip ? ( <> { /> - + {successAnimation ? : null} - {(transferData as MANATransferData).sceneName} + {tipData!.sceneName} ) : ( <> - + {successAnimation ? : null} - {(transferData as NFTTransferData).name && {(transferData as NFTTransferData).name}} + {giftData!.name && {giftData!.name}} )} diff --git a/src/components/Pages/RequestPage/Views/TransferView/TransferCompletedView/TransferCompletedView.types.ts b/src/components/Pages/RequestPage/Views/TransferView/TransferCompletedView/TransferCompletedView.types.ts index e55900c9..b1ecbf8f 100644 --- a/src/components/Pages/RequestPage/Views/TransferView/TransferCompletedView/TransferCompletedView.types.ts +++ b/src/components/Pages/RequestPage/Views/TransferView/TransferCompletedView/TransferCompletedView.types.ts @@ -1,6 +1,3 @@ -import { TransferType } from '../../../types' -import type { MANATransferData, NFTTransferData } from '../../../types' +import { BaseTransferViewProps } from '../useTransferViewData' -export type TransferCompletedViewProps = - | { type: TransferType.TIP; transferData: MANATransferData } - | { type: TransferType.GIFT; transferData: NFTTransferData } +export type TransferCompletedViewProps = BaseTransferViewProps diff --git a/src/components/Pages/RequestPage/Views/TransferView/TransferConfirmView/TransferConfirmView.tsx b/src/components/Pages/RequestPage/Views/TransferView/TransferConfirmView/TransferConfirmView.tsx index 557a50e0..c693e298 100644 --- a/src/components/Pages/RequestPage/Views/TransferView/TransferConfirmView/TransferConfirmView.tsx +++ b/src/components/Pages/RequestPage/Views/TransferView/TransferConfirmView/TransferConfirmView.tsx @@ -4,17 +4,14 @@ import { Rarity } from '@dcl/schemas' import { Profile } from 'decentraland-ui2' import { TransferActionButtons, TransferAssetImage, TransferLayout, TransferLoadingState } from '../../../../../Transfer' import { CenteredContent, ItemName, Label, Title, WarningAlert } from '../../../../../Transfer/Transfer.styled' -import { TransferType } from '../../../types' -import type { MANATransferData, NFTTransferData, ProfileAvatar } from '../../../types' import { SceneName } from '../TransferTipComponents.styled' +import { useTransferViewData } from '../useTransferViewData' import { TransferConfirmViewProps } from './TransferConfirmView.types' const TransferConfirmView = (props: TransferConfirmViewProps) => { const { t } = useTranslation() const [isProcessing, setIsProcessing] = useState(false) - const { type, transferData } = props - const isTip = type === TransferType.TIP - const recipientAvatar = transferData.recipientProfile?.avatars?.[0] + const { isTip, recipientAvatar, tipData, giftData, transferData } = useTransferViewData(props) const handleApprove = async () => { setIsProcessing(true) @@ -28,8 +25,8 @@ const TransferConfirmView = (props: TransferConfirmViewProps) => { {isTip ? ( <> {isProcessing - ? t('transfer.confirm.sending_tip', { manaAmount: (transferData as MANATransferData).manaAmount }) - : t('transfer.confirm.confirm_tip', { manaAmount: (transferData as MANATransferData).manaAmount })} + ? t('transfer.confirm.sending_tip', { manaAmount: tipData!.manaAmount }) + : t('transfer.confirm.confirm_tip', { manaAmount: tipData!.manaAmount })} ) : ( <>{isProcessing ? t('transfer.confirm.sending_gift') : t('transfer.confirm.confirm_gift')} @@ -39,7 +36,7 @@ const TransferConfirmView = (props: TransferConfirmViewProps) => { <> { highlightName /> - - {(transferData as MANATransferData).sceneName} + + {tipData!.sceneName} ) : ( <> - + - {(transferData as NFTTransferData).name && {(transferData as NFTTransferData).name}} + {giftData!.name && {giftData!.name}} {!isProcessing && {t('transfer.confirm.gifting_warning')}} )} diff --git a/src/components/Pages/RequestPage/Views/TransferView/TransferConfirmView/TransferConfirmView.types.ts b/src/components/Pages/RequestPage/Views/TransferView/TransferConfirmView/TransferConfirmView.types.ts index 240894c6..b4beb1fb 100644 --- a/src/components/Pages/RequestPage/Views/TransferView/TransferConfirmView/TransferConfirmView.types.ts +++ b/src/components/Pages/RequestPage/Views/TransferView/TransferConfirmView/TransferConfirmView.types.ts @@ -1,12 +1,9 @@ -import { TransferType } from '../../../types' -import type { MANATransferData, NFTTransferData } from '../../../types' +import { BaseTransferViewProps } from '../useTransferViewData' -type BaseProps = { +type ConfirmProps = { isLoading: boolean onApprove: () => Promise onDeny: () => void } -export type TransferConfirmViewProps = - | ({ type: TransferType.TIP; transferData: MANATransferData } & BaseProps) - | ({ type: TransferType.GIFT; transferData: NFTTransferData } & BaseProps) +export type TransferConfirmViewProps = BaseTransferViewProps & ConfirmProps diff --git a/src/components/Pages/RequestPage/Views/TransferView/useTransferViewData.ts b/src/components/Pages/RequestPage/Views/TransferView/useTransferViewData.ts new file mode 100644 index 00000000..a91a0f12 --- /dev/null +++ b/src/components/Pages/RequestPage/Views/TransferView/useTransferViewData.ts @@ -0,0 +1,24 @@ +import { TransferType } from '../../types' +import type { MANATransferData, NFTTransferData, ProfileAvatar } from '../../types' + +type TransferViewData = + | { + type: TransferType.TIP + transferData: MANATransferData + } + | { + type: TransferType.GIFT + transferData: NFTTransferData + } + +export type BaseTransferViewProps = TransferViewData + +export const useTransferViewData = (props: TransferViewData) => { + const { type, transferData } = props + const isTip = type === TransferType.TIP + const recipientAvatar = transferData.recipientProfile?.avatars?.[0] as ProfileAvatar | undefined + const tipData = isTip ? (transferData as MANATransferData) : undefined + const giftData = !isTip ? (transferData as NFTTransferData) : undefined + + return { isTip, recipientAvatar, tipData, giftData, transferData } +} diff --git a/src/components/Pages/SetupPage/SetupPage.tsx b/src/components/Pages/SetupPage/SetupPage.tsx index 92b5b9f1..d81ab716 100644 --- a/src/components/Pages/SetupPage/SetupPage.tsx +++ b/src/components/Pages/SetupPage/SetupPage.tsx @@ -1,8 +1,7 @@ -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useSearchParams } from 'react-router-dom' import classNames from 'classnames' import { useTranslation } from '@dcl/hooks' -import { EthAddress } from '@dcl/schemas' import { Button, CircularProgress, useMobileMediaQuery } from 'decentraland-ui2' import backImg from '../../../assets/images/back.svg' import diceImg from '../../../assets/images/dice.svg' @@ -11,26 +10,28 @@ import wrongImg from '../../../assets/images/wrong.svg' import { useNavigateWithSearchParams } from '../../../hooks/navigation' import { useAfterLoginRedirection } from '../../../hooks/redirection' import { useAnalytics } from '../../../hooks/useAnalytics' +import { usePostSignupActions } from '../../../hooks/usePostSignupActions' +import { useRequestIdFromRedirect } from '../../../hooks/useRequestIdFromRedirect' +import { useRequireAuth } from '../../../hooks/useRequireAuth' +import { useSetupFormValidation } from '../../../hooks/useSetupFormValidation' import { useSignRequest } from '../../../hooks/useSignRequest' -import { useTrackReferral } from '../../../hooks/useTrackReferral' import { ClickEvents } from '../../../modules/analytics/types' import { fetchProfile } from '../../../modules/profile' import { createAuthServerHttpClient, createAuthServerWsClient } from '../../../shared/auth' -import { useCurrentConnectionData } from '../../../shared/connection/hooks' import { locations } from '../../../shared/locations' import { isProfileComplete } from '../../../shared/profile' import { handleError } from '../../../shared/utils/errorHandler' import { ConnectionModal } from '../../ConnectionModal' import { ConnectionLayoutState } from '../../ConnectionModal/ConnectionLayout.type' import { CustomWearablePreview } from '../../CustomWearablePreview' -import { FeatureFlagsContext, FeatureFlagsKeys } from '../../FeatureFlagsProvider' +import { FeatureFlagsKeys } from '../../FeatureFlagsProvider' import { DifferentAccountError } from '../RequestPage/Views/DifferentAccountError' import { IpValidationError as IpValidationErrorView } from '../RequestPage/Views/IpValidationError' import { RecoverError } from '../RequestPage/Views/RecoverError' import { SignInComplete } from '../RequestPage/Views/SignInComplete' import { SigningError } from '../RequestPage/Views/SigningError' import { TimeoutError } from '../RequestPage/Views/TimeoutError' -import { deployProfileFromDefault, subscribeToNewsletter } from './utils' +import { deployProfileFromDefault } from './utils' import styles from './SetupPage.module.css' enum View { @@ -70,10 +71,9 @@ export const SetupPage = () => { const hasStartedToWriteSomethingInName = useRef(false) const hasStartedToWriteSomethingInEmail = useRef(false) const hasCheckedAgree = useRef(false) - const hasTrackedReferral = useRef(false) const [urlSearchParams] = useSearchParams() const [isConnectionModalOpen, setIsConnectionModalOpen] = useState(false) - const { flags, initialized: initializedFlags } = useContext(FeatureFlagsContext) + const { isReady, isAuthenticated, flags, account, identity, provider, providerType } = useRequireAuth() const [initialized, setInitialized] = useState(false) const [view, setView] = useState(View.RANDOMIZE) const [profile, setProfile] = useState(getRandomDefaultProfile()) @@ -86,7 +86,6 @@ export const SetupPage = () => { const [deployError, setDeployError] = useState(null) const isMobile = useMobileMediaQuery() const { url: redirectTo, redirect } = useAfterLoginRedirection() - const { isLoading: isConnecting, account, identity, provider, providerType } = useCurrentConnectionData() const navigate = useNavigateWithSearchParams() const referrer = urlSearchParams.get('referrer') const { @@ -97,60 +96,11 @@ export const SetupPage = () => { trackStartAddingEmail, trackCheckTermsOfService } = useAnalytics() - const { track: trackReferral } = useTrackReferral() - - const requestId = useMemo(() => { - // Grab the request id from redirectTo parameter. - const redirectTo = urlSearchParams.get('redirectTo') - let requestId: string | null = null - try { - const url = new URL(redirectTo ?? '', window.location.origin) - // Match the path: /auth/requests/0377e459-8fdf-4ce5-89f4-4f1f1c7bbb7f - const regex = /^\/?auth\/requests\/([a-zA-Z0-9-]+)$/ - requestId = url.pathname.match(regex)?.[1] ?? null - } catch { - // Do nothing - } - return requestId - }, [urlSearchParams]) - - // Validate the name. - const nameError = useMemo(() => { - if (!name.length) { - return t('setup.validation.username_empty') - } - if (name.length >= 15) { - return t('setup.validation.username_max_length') - } - - if (name.includes(' ')) { - return t('setup.validation.username_no_spaces') - } - - if (!/^[a-zA-Z0-9]+$/.test(name)) { - return t('setup.validation.username_no_special_chars') - } - - return '' - }, [name, t]) + const { trackReferralOnDeploy, trackReferralOnInit, subscribeEmail } = usePostSignupActions(referrer) - // Validate the email. - const emailError = useMemo(() => { - if (email && !email.includes('@')) { - return t('setup.validation.email_invalid') - } - - return '' - }, [email, t]) + const requestId = useRequestIdFromRedirect() - // Validate the agree checkbox. - const agreeError = useMemo(() => { - if (!agree) { - return t('setup.validation.agree_required') - } - - return '' - }, [agree, t]) + const { nameError, emailError, agreeError } = useSetupFormValidation(name, email, agree) // Message displayed on the button that completes the avatar creation. // Will display a message according to where the user will be redirected to. @@ -285,23 +235,8 @@ export const SetupPage = () => { deploymentProfileName: name }) - if (referrer && EthAddress.validate(referrer)) { - try { - await trackReferral(referrer, 'PATCH') - } catch { - // Error is already handled in trackReferral - } - } - - // Subscribe to the newsletter only if the user has provided an email. - if (email) { - // Given that the subscription is an extra step, we don't want to block the user if it fails. - try { - await subscribeToNewsletter(email) - } catch (e) { - handleError(e, 'Error subscribing to newsletter', { skipTracking: true }) - } - } + await trackReferralOnDeploy() + await subscribeEmail(email) trackTermsOfServiceSuccess({ ethAddress: account, @@ -332,12 +267,13 @@ export const SetupPage = () => { agree, profile, provider, - referrer, flags[FeatureFlagsKeys.LOGIN_ON_SETUP], redirect, signRequest, trackClick, trackTermsOfServiceSuccess, + trackReferralOnDeploy, + subscribeEmail, account, identity ] @@ -346,16 +282,16 @@ export const SetupPage = () => { // Initialization effect. // Will run some checks to see if the user can proceed with the simplified avatar setup flow. useEffect(() => { - if (isConnecting || !initializedFlags) return + if (!isReady) return - if (!account || !identity) { + if (!isAuthenticated) { console.warn('No previous connection found') return navigate(locations.login(redirectTo)) } ;(async () => { // Check if the wallet is connected. - const profile = await fetchProfile(account) + const profile = await fetchProfile(account!) // Check that the connected account does not have a profile already. if (profile && isProfileComplete(profile)) { @@ -375,18 +311,11 @@ export const SetupPage = () => { console.warn('Failed to get user email from localStorage:', error) } - if (referrer && EthAddress.validate(referrer) && !hasTrackedReferral.current) { - try { - await trackReferral(referrer, 'POST') - hasTrackedReferral.current = true - } catch { - // Error is already handled in trackReferral - } - } + await trackReferralOnInit() setInitialized(true) })() - }, [redirect, navigate, account, identity, isConnecting, initializedFlags, flags, referrer, provider]) + }, [redirect, navigate, account, identity, isReady, isAuthenticated, flags, provider, trackReferralOnInit]) if (!initialized) { return ( diff --git a/src/components/Transfer/Transfer.styled.ts b/src/components/Transfer/Transfer.styled.ts index c685bf25..bcef0530 100644 --- a/src/components/Transfer/Transfer.styled.ts +++ b/src/components/Transfer/Transfer.styled.ts @@ -1,24 +1,4 @@ -import { Alert, Box, Button, CircularProgress, Typography, circularProgressClasses, styled } from 'decentraland-ui2' - -const ButtonsContainer = styled(Box)(({ theme }) => ({ - display: 'flex', - gap: theme.spacing(4), - maxWidth: theme.spacing(100), - width: '100%' -})) - -const CancelButton = styled(Button)(({ theme }) => ({ - backgroundColor: theme.palette.action.selected, - color: theme.palette.text.primary, - ['&:hover']: { - backgroundColor: theme.palette.action.hover, - color: theme.palette.text.primary - }, - ['&:focus-visible']: { - outline: `2px solid ${theme.palette.primary.main}`, - outlineOffset: 2 - } -})) +import { Alert, Box, Typography, styled } from 'decentraland-ui2' const CenteredContent = styled(Box)(({ theme }) => ({ alignItems: 'center', @@ -30,14 +10,6 @@ const CenteredContent = styled(Box)(({ theme }) => ({ width: 'fit-content' })) -const ConfirmButton = styled(Button)(({ theme }) => ({ - borderRadius: theme.shape.borderRadius, - ['&:focus-visible']: { - outline: `2px solid ${theme.palette.primary.main}`, - outlineOffset: 2 - } -})) - const ItemName = styled(Box)(({ theme }) => ({ fontSize: theme.typography.pxToRem(22), fontWeight: 600, @@ -52,42 +24,6 @@ const Label = styled(Typography)(({ theme }) => ({ opacity: 0.8 })) -const LoadingContainer = styled(Box)(({ theme }) => ({ - alignItems: 'center', - display: 'flex', - flexDirection: 'row', - gap: theme.spacing(4), - marginTop: theme.spacing(6) -})) - -const LoadingText = styled(Typography)(({ theme }) => ({ - color: theme.palette.text.primary, - fontSize: theme.typography.pxToRem(18), - fontStyle: 'normal', - fontWeight: 400, - letterSpacing: '0px', - lineHeight: '100%' -})) - -const ProgressContainer = styled(Box)({ - display: 'inline-flex', - position: 'relative' -}) - -const ProgressSpinner = styled(CircularProgress)(({ theme }) => ({ - animationDuration: `${theme.transitions.duration.shorter}ms`, - color: theme.palette.primary.main, - left: 0, - position: 'absolute', - [`& .${circularProgressClasses.circle}`]: { - strokeLinecap: 'round' - } -})) - -const ProgressTrack = styled(CircularProgress)(({ theme }) => ({ - color: theme.palette.action.disabled -})) - const Title = styled(Typography)(({ theme }) => ({ fontSize: theme.typography.pxToRem(36), fontStyle: 'normal', @@ -116,18 +52,4 @@ const WarningAlert = styled(Alert)(({ theme }) => ({ } })) -export { - ButtonsContainer, - CancelButton, - CenteredContent, - ConfirmButton, - ItemName, - Label, - LoadingContainer, - LoadingText, - ProgressContainer, - ProgressSpinner, - ProgressTrack, - Title, - WarningAlert -} +export { CenteredContent, ItemName, Label, Title, WarningAlert } diff --git a/src/components/shared/ErrorDisplay.styled.ts b/src/components/shared/ErrorDisplay.styled.ts new file mode 100644 index 00000000..afaf699f --- /dev/null +++ b/src/components/shared/ErrorDisplay.styled.ts @@ -0,0 +1,26 @@ +import { Box, muiIcons, styled } from 'decentraland-ui2' + +const WarningAmberOutlinedIcon = muiIcons.WarningAmberOutlined + +const ErrorContainer = styled(Box)({ + display: 'flex', + alignItems: 'center', + gap: '4px' +}) + +const ErrorText = styled('span')({ + color: 'rgba(224, 0, 0, 1)', + fontSize: '14px' +}) + +const WarningIcon = styled(WarningAmberOutlinedIcon)({ + color: 'rgba(224, 0, 0, 1)', + height: '15px', + width: '15px' +}) + +const ErrorMessageIcon = styled(muiIcons.ErrorOutline)({ + color: '#fb3b3b' +}) + +export { ErrorContainer, ErrorText, WarningIcon, ErrorMessageIcon } diff --git a/src/components/shared/GradientBackground.styled.ts b/src/components/shared/GradientBackground.styled.ts new file mode 100644 index 00000000..5c3939bb --- /dev/null +++ b/src/components/shared/GradientBackground.styled.ts @@ -0,0 +1,38 @@ +import { SxProps, Theme } from 'decentraland-ui2' + +/** + * Shared gradient pseudo-element styles for page backgrounds. + * Apply as `&::before` on the main page container. + */ +const gradientPseudoElement: SxProps = { + content: '""', + position: 'fixed', + width: '100%', + height: '350%', + top: '-100%', + transform: 'rotate(180deg)', + overflow: 'hidden' +} + +/** + * Desktop gradient: purple linear gradient (used by LoginPage). + */ +const desktopLinearGradient = + 'linear-gradient(89.65deg, rgba(149, 45, 198, 0) 29.59%, rgba(94, 30, 130, 0.559754) 39.08%, rgba(75, 25, 106, 0.750004) 45.36%, rgba(64, 23, 93, 0.859976) 54.22%, #32134C 72.84%)' + +/** + * Mobile gradient: simple purple gradient. + */ +const mobileLinearGradient = 'linear-gradient(151.89deg, #491975 47.67%, #D72CCD 103.3%)' + +/** + * Desktop radial gradient (used by MobileAuthPage). + */ +const desktopRadialGradient = 'radial-gradient(ellipse at 0 50%, transparent 10%, #e02dd3 40%, #491975 70%)' + +/** + * Mobile radial gradient (used by MobileAuthPage). + */ +const mobileRadialGradient = 'radial-gradient(ellipse at 0 50%, #e02dd3 0%, #491975 70%)' + +export { gradientPseudoElement, desktopLinearGradient, mobileLinearGradient, desktopRadialGradient, mobileRadialGradient } diff --git a/src/hooks/targetConfig.ts b/src/hooks/targetConfig.ts index 0d71f940..7b18c085 100644 --- a/src/hooks/targetConfig.ts +++ b/src/hooks/targetConfig.ts @@ -1,16 +1,10 @@ import { Location, useLocation } from 'react-router-dom' -import { ConnectionOptionType } from '../components/Connection' +import { ConnectionOptionType, ConnectionOptions } from '../components/Connection' import { isIos, isMobile } from '../components/Pages/LoginPage/utils' import { extractRedirectToFromSearchParameters } from '../shared/locations' type TargetConfigId = 'default' | 'alternative' | 'ios' | 'android' | 'androidSocial' | 'androidWeb3' -type ConnectionOptions = { - primary: ConnectionOptionType - secondary?: ConnectionOptionType - extraOptions?: ConnectionOptionType[] -} - type TargetConfig = { skipSetup: boolean showWearablePreview: boolean diff --git a/src/hooks/useAuthFlow.ts b/src/hooks/useAuthFlow.ts index c02654d9..9e2c9213 100644 --- a/src/hooks/useAuthFlow.ts +++ b/src/hooks/useAuthFlow.ts @@ -6,7 +6,7 @@ import { connection } from 'decentraland-connect' import { FeatureFlagsContext, FeatureFlagsKeys, OnboardingFlowVariant } from '../components/FeatureFlagsProvider' import { config } from '../modules/config' import { fetchProfileWithConsistencyCheck, redeployExistingProfile, redeployExistingProfileWithContentServerData } from '../modules/profile' -import { useCurrentConnectionData } from '../shared/connection/hook' +import { useCurrentConnectionData } from '../shared/connection/hooks' import { createFetcher } from '../shared/fetcher' import { locations } from '../shared/locations' import { isProfileComplete } from '../shared/profile' diff --git a/src/hooks/usePostSignupActions.ts b/src/hooks/usePostSignupActions.ts new file mode 100644 index 00000000..940c4722 --- /dev/null +++ b/src/hooks/usePostSignupActions.ts @@ -0,0 +1,47 @@ +import { useCallback, useRef } from 'react' +import { EthAddress } from '@dcl/schemas' +import { subscribeToNewsletter } from '../components/Pages/SetupPage/utils' +import { handleError } from '../shared/utils/errorHandler' +import { useTrackReferral } from './useTrackReferral' + +/** + * Shared hook for post-signup side effects: referral tracking and newsletter subscription. + * Used by both SetupPage and AvatarSetupPage. + */ +export const usePostSignupActions = (referrer: string | null) => { + const { track: trackReferral } = useTrackReferral() + const hasTrackedReferral = useRef(false) + + const trackReferralOnDeploy = useCallback(async () => { + if (referrer && EthAddress.validate(referrer)) { + try { + await trackReferral(referrer, 'PATCH') + } catch { + // Error is already handled in trackReferral + } + } + }, [referrer, trackReferral]) + + const trackReferralOnInit = useCallback(async () => { + if (referrer && EthAddress.validate(referrer) && !hasTrackedReferral.current) { + try { + await trackReferral(referrer, 'POST') + hasTrackedReferral.current = true + } catch { + // Error is already handled in trackReferral + } + } + }, [referrer, trackReferral]) + + const subscribeEmail = useCallback(async (email: string) => { + if (email) { + try { + await subscribeToNewsletter(email) + } catch (e) { + handleError(e, 'Error subscribing to newsletter', { skipTracking: true }) + } + } + }, []) + + return { trackReferralOnDeploy, trackReferralOnInit, subscribeEmail, hasTrackedReferral } +} diff --git a/src/hooks/useRequestIdFromRedirect.ts b/src/hooks/useRequestIdFromRedirect.ts new file mode 100644 index 00000000..9b68cb1d --- /dev/null +++ b/src/hooks/useRequestIdFromRedirect.ts @@ -0,0 +1,21 @@ +import { useMemo } from 'react' +import { useSearchParams } from 'react-router-dom' + +/** + * Extracts the request ID from the redirectTo URL search parameter. + * Matches paths like /auth/requests/0377e459-8fdf-4ce5-89f4-4f1f1c7bbb7f + */ +export const useRequestIdFromRedirect = (): string | null => { + const [urlSearchParams] = useSearchParams() + + return useMemo(() => { + const redirectTo = urlSearchParams.get('redirectTo') + try { + const url = new URL(redirectTo ?? '', window.location.origin) + const regex = /^\/?auth\/requests\/([a-zA-Z0-9-]+)$/ + return url.pathname.match(regex)?.[1] ?? null + } catch { + return null + } + }, [urlSearchParams]) +} diff --git a/src/hooks/useRequireAuth.ts b/src/hooks/useRequireAuth.ts new file mode 100644 index 00000000..f81f5da0 --- /dev/null +++ b/src/hooks/useRequireAuth.ts @@ -0,0 +1,29 @@ +import { useContext } from 'react' +import { FeatureFlagsContext } from '../components/FeatureFlagsProvider' +import { useCurrentConnectionData } from '../shared/connection/hooks' + +/** + * Checks if the user is authenticated and feature flags are initialized. + * Returns connection data plus readiness flags for page initialization effects. + */ +export const useRequireAuth = () => { + const { isLoading: isConnecting, account, identity, provider, providerType, chainId } = useCurrentConnectionData() + const { flags, variants, initialized: initializedFlags } = useContext(FeatureFlagsContext) + + const isReady = !isConnecting && initializedFlags + const isAuthenticated = !!account && !!identity + + return { + isReady, + isAuthenticated, + isConnecting, + initializedFlags, + account, + identity, + provider, + providerType, + chainId, + flags, + variants + } +} diff --git a/src/hooks/useSetupFormValidation.ts b/src/hooks/useSetupFormValidation.ts new file mode 100644 index 00000000..0aad00e8 --- /dev/null +++ b/src/hooks/useSetupFormValidation.ts @@ -0,0 +1,41 @@ +import { useMemo } from 'react' +import { useTranslation } from '@dcl/hooks' + +/** + * Shared form validation for SetupPage and AvatarSetupPage. + */ +export const useSetupFormValidation = (name: string, email: string, agree: boolean) => { + const { t } = useTranslation() + + const nameError = useMemo(() => { + if (!name.length) { + return t('setup.validation.username_empty') + } + if (name.length >= 15) { + return t('setup.validation.username_max_length') + } + if (name.includes(' ')) { + return t('setup.validation.username_no_spaces') + } + if (!/^[a-zA-Z0-9]+$/.test(name)) { + return t('setup.validation.username_no_special_chars') + } + return '' + }, [name, t]) + + const emailError = useMemo(() => { + if (email && !email.includes('@')) { + return t('setup.validation.email_invalid') + } + return '' + }, [email, t]) + + const agreeError = useMemo(() => { + if (!agree) { + return t('setup.validation.agree_required') + } + return '' + }, [agree, t]) + + return { nameError, emailError, agreeError } +} diff --git a/src/hooks/useTrackReferral.ts b/src/hooks/useTrackReferral.ts index d29c5809..711880ac 100644 --- a/src/hooks/useTrackReferral.ts +++ b/src/hooks/useTrackReferral.ts @@ -1,7 +1,7 @@ import { useCallback } from 'react' import fetch from 'decentraland-crypto-fetch' import { config } from '../modules/config' -import { useCurrentConnectionData } from '../shared/connection/hook' +import { useCurrentConnectionData } from '../shared/connection/hooks' import { handleErrorWithContext } from '../shared/utils/errorHandler' const REFERRAL_SERVER_URL = config.get('REFERRAL_SERVER_URL') diff --git a/src/hooks/useWalletOptions.ts b/src/hooks/useWalletOptions.ts index 2c94572f..34e2561b 100644 --- a/src/hooks/useWalletOptions.ts +++ b/src/hooks/useWalletOptions.ts @@ -1,11 +1,5 @@ import { useMemo } from 'react' -import { ConnectionOptionType, SignInOptionsMode } from '../components/Connection/Connection.types' - -type ConnectionOptions = { - primary: ConnectionOptionType - secondary?: ConnectionOptionType - extraOptions?: ConnectionOptionType[] -} +import { ConnectionOptionType, ConnectionOptions, SignInOptionsMode } from '../components/Connection/Connection.types' type UseWalletOptionsParams = { connectionOptions?: ConnectionOptions diff --git a/src/shared/auth/httpClient.ts b/src/shared/auth/httpClient.ts index 14c48ee8..6e636dc4 100644 --- a/src/shared/auth/httpClient.ts +++ b/src/shared/auth/httpClient.ts @@ -1,11 +1,11 @@ import { AuthIdentity } from '@dcl/crypto' import signedFetch from 'decentraland-crypto-fetch' -import { RequestInteractionType, TrackingEvents } from '../../modules/analytics/types' +import { TrackingEvents } from '../../modules/analytics/types' import { config } from '../../modules/config' import { trackEvent } from '../utils/analytics' import { handleError } from '../utils/errorHandler' -import { DifferentSenderError, ExpiredRequestError, IpValidationError, RequestFulfilledError, RequestNotFoundError } from './errors' import { IdentityResponse, OutcomeError, OutcomeResponse, RecoverResponse } from './types' +import { handleRecoverError, throwAuthServerError, trackRecoverMethod, validateRecoverResponse } from './utils' export const createAuthServerHttpClient = (authServerUrl?: string) => { const baseUrl = authServerUrl ?? config.get('AUTH_SERVER_URL') @@ -17,16 +17,8 @@ export const createAuthServerHttpClient = (authServerUrl?: string) => { throw new Error('Unknown error') } - if (data.error?.includes('already been fulfilled')) { - throw new RequestFulfilledError(requestId) - } else if (data.error?.includes('not found')) { - throw new RequestNotFoundError(requestId) - } else if (data.error?.includes('has expired')) { - throw new ExpiredRequestError(requestId) - } else if (data.error?.includes('IP validation failed')) { - throw new IpValidationError(requestId, data.error) - } else if (data.error) { - throw new Error(data.error) + if (data.error) { + throwAuthServerError(data.error, requestId) } throw new Error('Unknown error') @@ -135,50 +127,14 @@ export const createAuthServerHttpClient = (authServerUrl?: string) => { await extractError(response, requestId) } - const recoverResponse = await response.json() + recoverResponse = await response.json() - // If the sender defined in the request is different than the one that is connected, show an error. - if (recoverResponse.sender && recoverResponse.sender !== signerAddress.toLowerCase()) { - throw new DifferentSenderError(signerAddress, recoverResponse.sender) - } - - if (recoverResponse.expiration && new Date(recoverResponse.expiration) < new Date()) { - throw new ExpiredRequestError(requestId, recoverResponse.expiration) - } + validateRecoverResponse(recoverResponse!, signerAddress, requestId) + trackRecoverMethod(recoverResponse!, isDeepLinkFlow) - switch (recoverResponse.method) { - case 'dcl_personal_sign': - trackEvent(TrackingEvents.REQUEST_INTERACTION, { - type: isDeepLinkFlow ? RequestInteractionType.DEEP_LINK_SIGN_IN : RequestInteractionType.VERIFY_SIGN_IN, - browserTime: Date.now(), - requestType: recoverResponse?.method - }) - break - case 'eth_sendTransaction': - trackEvent(TrackingEvents.REQUEST_INTERACTION, { - type: RequestInteractionType.WALLET_INTERACTION, - requestType: recoverResponse.method - }) - break - default: - trackEvent(TrackingEvents.REQUEST_INTERACTION, { - type: RequestInteractionType.WALLET_INTERACTION, - requestType: recoverResponse.method - }) - } - - return recoverResponse + return recoverResponse! } catch (e) { - // Don't report fulfilled requests to Sentry — they are an expected state after successful login - if (!(e instanceof RequestFulfilledError)) { - handleError(e, 'Error recovering request', { - trackingData: { - browserTime: Date.now(), - requestType: recoverResponse?.method ?? 'Unknown' - }, - trackingEvent: TrackingEvents.REQUEST_LOADING_ERROR - }) - } + handleRecoverError(e, recoverResponse) throw e } } diff --git a/src/shared/auth/index.ts b/src/shared/auth/index.ts index 33810ca5..c0b3de37 100644 --- a/src/shared/auth/index.ts +++ b/src/shared/auth/index.ts @@ -1,4 +1,5 @@ export * from './errors' export * from './httpClient' export * from './types' +export * from './utils' export * from './wsClient' diff --git a/src/shared/auth/utils.ts b/src/shared/auth/utils.ts new file mode 100644 index 00000000..c7fc99e0 --- /dev/null +++ b/src/shared/auth/utils.ts @@ -0,0 +1,73 @@ +import { RequestInteractionType, TrackingEvents } from '../../modules/analytics/types' +import { trackEvent } from '../utils/analytics' +import { handleError } from '../utils/errorHandler' +import { DifferentSenderError, ExpiredRequestError, IpValidationError, RequestFulfilledError, RequestNotFoundError } from './errors' +import { RecoverResponse } from './types' + +/** + * Parses an error string from the auth server and throws the appropriate typed error. + */ +const throwAuthServerError = (error: string, requestId: string): never => { + if (error.includes('already been fulfilled')) { + throw new RequestFulfilledError(requestId) + } else if (error.includes('not found')) { + throw new RequestNotFoundError(requestId) + } else if (error.includes('has expired')) { + throw new ExpiredRequestError(requestId) + } else if (error.includes('IP validation failed')) { + throw new IpValidationError(requestId, error) + } + throw new Error(error) +} + +/** + * Validates a recovered request: checks sender mismatch and expiration. + */ +const validateRecoverResponse = (response: RecoverResponse, signerAddress: string, requestId: string) => { + if (response.sender && response.sender !== signerAddress.toLowerCase()) { + throw new DifferentSenderError(signerAddress, response.sender) + } + + if (response.expiration && new Date(response.expiration) < new Date()) { + throw new ExpiredRequestError(requestId, response.expiration) + } +} + +/** + * Tracks the appropriate analytics event based on the recover response method. + */ +const trackRecoverMethod = (response: RecoverResponse, isDeepLinkFlow: boolean) => { + switch (response.method) { + case 'dcl_personal_sign': + trackEvent(TrackingEvents.REQUEST_INTERACTION, { + type: isDeepLinkFlow ? RequestInteractionType.DEEP_LINK_SIGN_IN : RequestInteractionType.VERIFY_SIGN_IN, + browserTime: Date.now(), + requestTime: response.expiration ? new Date(response.expiration).getTime() : undefined, + requestType: response.method + }) + break + case 'eth_sendTransaction': + default: + trackEvent(TrackingEvents.REQUEST_INTERACTION, { + type: RequestInteractionType.WALLET_INTERACTION, + requestType: response.method + }) + } +} + +/** + * Handles error reporting for recover flow, excluding fulfilled requests from Sentry. + */ +const handleRecoverError = (e: unknown, response: RecoverResponse | undefined) => { + if (!(e instanceof RequestFulfilledError)) { + handleError(e, 'Error recovering request', { + trackingData: { + browserTime: Date.now(), + requestType: response?.method ?? 'Unknown' + }, + trackingEvent: TrackingEvents.REQUEST_LOADING_ERROR + }) + } +} + +export { throwAuthServerError, validateRecoverResponse, trackRecoverMethod, handleRecoverError } diff --git a/src/shared/auth/wsClient.ts b/src/shared/auth/wsClient.ts index 4c2d3d60..c99819de 100644 --- a/src/shared/auth/wsClient.ts +++ b/src/shared/auth/wsClient.ts @@ -1,10 +1,8 @@ import { io } from 'socket.io-client' -import { RequestInteractionType, TrackingEvents } from '../../modules/analytics/types' import { config } from '../../modules/config' -import { trackEvent } from '../utils/analytics' import { handleError } from '../utils/errorHandler' -import { DifferentSenderError, ExpiredRequestError, IpValidationError, RequestFulfilledError, RequestNotFoundError } from './errors' import { OutcomeError, OutcomeResponse, RecoverResponse, ValidationResponse } from './types' +import { handleRecoverError, throwAuthServerError, trackRecoverMethod, validateRecoverResponse } from './utils' export const createAuthServerWsClient = (authServerUrl?: string) => { const url = authServerUrl ?? config.get('AUTH_SERVER_URL') @@ -24,16 +22,8 @@ export const createAuthServerWsClient = (authServerUrl?: string) => { // Close client, we don't need it anymore socket.close() - if (response.error?.includes('already been fulfilled')) { - throw new RequestFulfilledError(message.requestId) - } else if (response.error?.includes('not found')) { - throw new RequestNotFoundError(message.requestId) - } else if (response.error?.includes('has expired')) { - throw new ExpiredRequestError(message.requestId) - } else if (response.error?.includes('IP validation failed')) { - throw new IpValidationError(message.requestId, response.error) - } else if (response.error) { - throw new Error(response.error) + if (response.error) { + throwAuthServerError(response.error, message.requestId) } return response @@ -75,49 +65,12 @@ export const createAuthServerWsClient = (authServerUrl?: string) => { throw new Error(response.error) } - // If the sender defined in the request is different than the one that is connected, show an error. - if (response.sender && response.sender !== signerAddress.toLowerCase()) { - throw new DifferentSenderError(signerAddress, response.sender) - } - - if (response.expiration && new Date(response.expiration) < new Date()) { - throw new ExpiredRequestError(requestId, response.expiration) - } - - switch (response.method) { - case 'dcl_personal_sign': - trackEvent(TrackingEvents.REQUEST_INTERACTION, { - type: isDeepLinkFlow ? RequestInteractionType.DEEP_LINK_SIGN_IN : RequestInteractionType.VERIFY_SIGN_IN, - browserTime: Date.now(), - requestTime: new Date(response.expiration).getTime(), - requestType: response?.method - }) - break - case 'eth_sendTransaction': - trackEvent(TrackingEvents.REQUEST_INTERACTION, { - type: RequestInteractionType.WALLET_INTERACTION, - requestType: response.method - }) - break - default: - trackEvent(TrackingEvents.REQUEST_INTERACTION, { - type: RequestInteractionType.WALLET_INTERACTION, - requestType: response.method - }) - } + validateRecoverResponse(response, signerAddress, requestId) + trackRecoverMethod(response, isDeepLinkFlow) return response } catch (e) { - // Don't report fulfilled requests to Sentry — they are an expected state after successful login - if (!(e instanceof RequestFulfilledError)) { - handleError(e, 'Error recovering request', { - trackingData: { - browserTime: Date.now(), - requestType: response?.method ?? 'Unknown' - }, - trackingEvent: TrackingEvents.REQUEST_LOADING_ERROR - }) - } + handleRecoverError(e, response) throw e } } diff --git a/src/shared/connection/hook.ts b/src/shared/connection/hook.ts deleted file mode 100644 index d696163e..00000000 --- a/src/shared/connection/hook.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { useEffect, useState } from 'react' -import { ConnectionData, getCurrentConnectionData } from './connection' - -export const useCurrentConnectionData = () => { - const [connectionData, setConnectionData] = useState(null) - const [isLoading, setIsLoading] = useState(true) - - useEffect(() => { - let cancelled = false - - const fetchConnectionData = async () => { - const connectionData = await getCurrentConnectionData() - if (cancelled) return - setConnectionData(connectionData) - setIsLoading(false) - } - - fetchConnectionData() - - return () => { - cancelled = true - } - }, []) - - return { - isLoading, - account: connectionData?.account, - identity: connectionData?.identity, - provider: connectionData?.provider, - providerType: connectionData?.providerType, - chainId: connectionData?.chainId - } -} diff --git a/src/shared/locations.ts b/src/shared/locations.ts index e311e4cd..7c30f86b 100644 --- a/src/shared/locations.ts +++ b/src/shared/locations.ts @@ -1,4 +1,5 @@ import { EthAddress } from '@dcl/schemas' +import { extractFromStateParameter } from './utils/stateParameter' /** * Login method types for direct login via URL parameters @@ -80,18 +81,11 @@ const locations = { const extractRedirectToFromSearchParameters = (searchParams: URLSearchParams): string => { // Extract 'redirectTo' from current search parameters let redirectToSearchParam = searchParams.get('redirectTo') - try { - const state = searchParams.get('state') - // Decode the state parameter to get the original 'redirectTo' - if (state) { - const stateRedirectToParam = atob(state) - const parsedRedirectTo = JSON.parse(JSON.parse(stateRedirectToParam).customData).redirectTo - if (parsedRedirectTo) { - redirectToSearchParam = parsedRedirectTo ?? null - } - } - } catch { - console.error("Can't decode state parameter") + + // Try to extract from OAuth state parameter + const stateRedirectTo = extractFromStateParameter(searchParams, 'redirectTo') + if (stateRedirectTo) { + redirectToSearchParam = stateRedirectTo } // Initialize redirectTo with a default value @@ -111,17 +105,11 @@ const extractRedirectToFromSearchParameters = (searchParams: URLSearchParams): s const extractReferrerFromSearchParameters = (searchParams: URLSearchParams): string | null => { let referrerSearchParam = searchParams.get('referrer') - try { - const state = searchParams.get('state') - if (state) { - const stateReferrerParam = atob(state) - const parsedReferrer = JSON.parse(JSON.parse(stateReferrerParam).customData).referrer - if (parsedReferrer) { - referrerSearchParam = parsedReferrer ?? null - } - } - } catch { - console.error("Can't decode state parameter") + + // Try to extract from OAuth state parameter + const stateReferrer = extractFromStateParameter(searchParams, 'referrer') + if (stateReferrer) { + referrerSearchParam = stateReferrer } if (referrerSearchParam && !EthAddress.validate(referrerSearchParam)) { diff --git a/src/shared/mobile.ts b/src/shared/mobile.ts index 632f25fb..e51974fe 100644 --- a/src/shared/mobile.ts +++ b/src/shared/mobile.ts @@ -1,3 +1,5 @@ +import { extractMultipleFromStateParameter } from './utils/stateParameter' + interface MobileSession { u?: string s?: string @@ -6,26 +8,6 @@ interface MobileSession { // In-memory cache: survives SPA navigations without localStorage let cachedSession: MobileSession | null | undefined -// Helper that parses the state param from the OAuth callback (same pattern as extractRedirectToFromSearchParameters) -function extractMobileDataFromState(): { isMobileFlow?: boolean; mobileUserId?: string; mobileSessionId?: string } | null { - try { - const params = new URLSearchParams(window.location.search) - const state = params.get('state') - if (state) { - const decoded = JSON.parse(atob(state)) - const customData = JSON.parse(decoded.customData) - return { - isMobileFlow: customData.isMobileFlow, - mobileUserId: customData.mobileUserId, - mobileSessionId: customData.mobileSessionId - } - } - } catch { - // ignore malformed state - } - return null -} - function getMobileSession(): MobileSession | null { if (cachedSession !== undefined) { return cachedSession @@ -40,8 +22,13 @@ function getMobileSession(): MobileSession | null { } // On callback, extract from OAuth state param - const stateData = extractMobileDataFromState() - if (stateData?.isMobileFlow) { + const stateData = extractMultipleFromStateParameter<{ + isMobileFlow?: boolean + mobileUserId?: string + mobileSessionId?: string + }>(params, ['isMobileFlow', 'mobileUserId', 'mobileSessionId']) + + if (stateData.isMobileFlow) { cachedSession = { u: stateData.mobileUserId ?? undefined, s: stateData.mobileSessionId ?? undefined diff --git a/src/shared/utils/stateParameter.ts b/src/shared/utils/stateParameter.ts new file mode 100644 index 00000000..9cb555d3 --- /dev/null +++ b/src/shared/utils/stateParameter.ts @@ -0,0 +1,46 @@ +/** + * Extracts a field from the OAuth state parameter's customData. + * The state parameter is base64-encoded JSON with a customData field that is also JSON-encoded. + */ +const extractFromStateParameter = (searchParams: URLSearchParams, field: string): T | null => { + try { + const state = searchParams.get('state') + if (!state) return null + + const decoded = JSON.parse(atob(state)) + const customData = JSON.parse(decoded.customData) + return customData[field] ?? null + } catch { + console.error("Can't decode state parameter") + return null + } +} + +/** + * Extracts multiple fields from the OAuth state parameter's customData. + */ +const extractMultipleFromStateParameter = >( + searchParams: URLSearchParams, + fields: string[] +): Partial => { + try { + const state = searchParams.get('state') + if (!state) return {} + + const decoded = JSON.parse(atob(state)) + const customData = JSON.parse(decoded.customData) + + const result: Record = {} + for (const field of fields) { + if (customData[field] !== undefined) { + result[field] = customData[field] + } + } + return result as Partial + } catch { + console.error("Can't decode state parameter") + return {} + } +} + +export { extractFromStateParameter, extractMultipleFromStateParameter }