diff --git a/e2e/specs/stateless/extendNames.spec.ts b/e2e/specs/stateless/extendNames.spec.ts index 5cf031d5e..b36103402 100644 --- a/e2e/specs/stateless/extendNames.spec.ts +++ b/e2e/specs/stateless/extendNames.spec.ts @@ -91,7 +91,7 @@ test('should be able to extend multiple names (including names in grace preiod) await transactionModal.autoComplete() await expect(page.getByText('Your "Extend names" transaction was successful')).toBeVisible({ - timeout: 10000, + timeout: 15000, }) await subgraph.sync() @@ -618,7 +618,7 @@ test('should be able to extend a single wrapped name using deep link', async ({ const homePage = makePageObject('HomePage') await homePage.goto() await login.connect() - await page.goto(`/${name}?renew`) + await page.goto(`/${name}?renew=123`) const timestamp = await profilePage.getExpiryTimestamp() @@ -664,7 +664,7 @@ test('should not be able to extend a name which is not registered', async ({ const homePage = makePageObject('HomePage') await homePage.goto() await login.connect() - await page.goto(`/${name}?renew`) + await page.goto(`/${name}?renew=123`) await expect(page.getByRole('heading', { name: `Register ${name}` })).toBeVisible() }) @@ -675,6 +675,55 @@ test('renew deep link should redirect to registration when not logged in', async const name = 'this-name-does-not-exist.eth' const homePage = makePageObject('HomePage') await homePage.goto() - await page.goto(`/${name}?renew`) + await page.goto(`/${name}?renew=123`) await expect(page.getByRole('heading', { name: `Register ${name}` })).toBeVisible() }) + +test('should handle URL-based renew parameter', async ({ page, login, makeName }) => { + const name = await makeName({ + label: 'legacy', + type: 'legacy', + owner: 'user', + }) + + await test.step('should handle large duration', async () => { + await page.goto(`/${name}?renew=315360000`) // 10 years + await login.connect() + await expect(page.getByText('10 years extension', { exact: true })).toBeVisible() + }) +}) + +test('should handle URL-based renew for names in grace period', async ({ + page, + login, + makeName, +}) => { + const name = await makeName({ + label: 'legacy', + type: 'legacy', + owner: 'user', + duration: -24 * 60 * 60, + }) + + await test.step('should allow extend in grace period', async () => { + await page.goto(`/${name}?renew=94608000`) // 3 years + await login.connect() + + await expect(page.getByText(`${name} has expired`)).toBeVisible() + await expect(page.getByText(`You do not own ${name}`)).toBeVisible() + await page.getByTestId('extend-names-confirm').click() + + await expect(page.getByText('3 years extension', { exact: true })).toBeVisible() + }) +}) + +test('should handle URL-based renew for disconnected users', async ({ page, makeName }) => { + const name = await makeName({ + label: 'legacy', + type: 'legacy', + owner: 'user', + }) + + await page.goto(`/${name}?renew=94608000`) + await expect(page.getByText('Connect a wallet')).toBeVisible() +}) diff --git a/src/components/ProfileSnippet.tsx b/src/components/ProfileSnippet.tsx index 148dc7b1c..503b7d28a 100644 --- a/src/components/ProfileSnippet.tsx +++ b/src/components/ProfileSnippet.tsx @@ -1,8 +1,6 @@ -import { useSearchParams } from 'next/navigation' -import { useEffect, useMemo } from 'react' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' -import { useAccount } from 'wagmi' import { Button, mq, NametagSVG, Tag, Typography } from '@ensdomains/thorin' @@ -10,7 +8,6 @@ import FastForwardSVG from '@app/assets/FastForward.svg' import VerifiedPersonSVG from '@app/assets/VerifiedPerson.svg' import { useAbilities } from '@app/hooks/abilities/useAbilities' import { useBeautifiedName } from '@app/hooks/useBeautifiedName' -import { useNameDetails } from '@app/hooks/useNameDetails' import { useRouterWithHistory } from '@app/hooks/useRouterWithHistory' import { useTransactionFlow } from '../transaction-flow/TransactionFlowProvider' @@ -195,8 +192,6 @@ export const ProfileSnippet = ({ const { usePreparedDataInput } = useTransactionFlow() const showExtendNamesInput = usePreparedDataInput('ExtendNames') const abilities = useAbilities({ name }) - const details = useNameDetails({ name }) - const { isConnected } = useAccount() const beautifiedName = useBeautifiedName(name) @@ -206,27 +201,8 @@ export const ProfileSnippet = ({ const location = getTextRecord?.('location')?.value const recordName = getTextRecord?.('name')?.value - const searchParams = useSearchParams() - - const renew = (searchParams.get('renew') ?? null) !== null - const available = details.registrationStatus === 'available' - const { canSelfExtend, canEdit } = abilities.data ?? {} - useEffect(() => { - if (renew && !isConnected) { - return router.push(`/${name}/register`) - } - - if (renew && !available) { - showExtendNamesInput(`extend-names-${name}`, { - names: [name], - isSelf: canSelfExtend, - }) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isConnected, available, renew, name, canSelfExtend]) - const ActionButton = useMemo(() => { if (button === 'extend') return ( diff --git a/src/components/pages/profile/[name]/Profile.tsx b/src/components/pages/profile/[name]/Profile.tsx index 68847c87f..5f67940be 100644 --- a/src/components/pages/profile/[name]/Profile.tsx +++ b/src/components/pages/profile/[name]/Profile.tsx @@ -11,6 +11,7 @@ import BaseLink from '@app/components/@atoms/BaseLink' import { Outlink } from '@app/components/Outlink' import { useAbilities } from '@app/hooks/abilities/useAbilities' import { useChainName } from '@app/hooks/chain/useChainName' +import { useRenew } from '@app/hooks/pages/profile/useRenew/useRenew' import { useNameDetails } from '@app/hooks/useNameDetails' import { useProtectedRoute } from '@app/hooks/useProtectedRoute' import { useQueryParameterState } from '@app/hooks/useQueryParameterState' @@ -201,6 +202,8 @@ const ProfileContent = ({ isSelf, isLoading: parentIsLoading, name }: Props) => // } // }, [name, router, transactions]) + useRenew(normalisedName) + const infoBanner = useMemo(() => { if ( registrationStatus !== 'gracePeriod' && diff --git a/src/hooks/pages/profile/useRenew/useRenew.test.ts b/src/hooks/pages/profile/useRenew/useRenew.test.ts new file mode 100644 index 000000000..fd0522b17 --- /dev/null +++ b/src/hooks/pages/profile/useRenew/useRenew.test.ts @@ -0,0 +1,709 @@ +import type { ParsedUrlQuery } from 'querystring' +import { mockFunction, renderHook, screen } from '@app/test-utils' + +import { useConnectModal } from '@rainbow-me/rainbowkit' +import mockRouter from 'next-router-mock' +import { useSearchParams } from 'next/navigation' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAccount, type UseAccountReturnType } from 'wagmi' + +import { sc } from '@ensdomains/address-encoder/coins' + +import { useAbilities } from '@app/hooks/abilities/useAbilities' +import { useBasicName } from '@app/hooks/useBasicName' +import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import type { RegistrationStatus } from '@app/utils/registrationStatus' + +import { calculateRenewState, removeRenewParam, useRenew } from './useRenew' + +vi.mock('next/router', async () => await vi.importActual('next-router-mock')) +vi.mock('@app/hooks/useBasicName') +vi.mock('@app/hooks/abilities/useAbilities') +vi.mock('@rainbow-me/rainbowkit') +vi.mock('wagmi') +vi.mock('@app/transaction-flow/TransactionFlowProvider') +vi.mock('next/navigation') + +const mockUseBasicName = mockFunction(useBasicName) +const mockUseAbilities = mockFunction(useAbilities) +const mockUseConnectModal = mockFunction(useConnectModal) +const mockUseAccount = mockFunction(useAccount) +const mockUseTransactionFlow = mockFunction(useTransactionFlow) +const mockUseSearchParams = mockFunction(useSearchParams) + +describe('calculateRenewState', () => { + it('should return connect-user if accountStatus is disconnected', () => { + expect( + calculateRenewState({ + registrationStatus: 'gracePeriod', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'disconnected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('connect-user') + }) + + it('should return display-extend-names if accountStatus is connected', () => { + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('display-extend-names') + }) + + it('should return idle if registration status is available', () => { + expect( + calculateRenewState({ + registrationStatus: 'available', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('idle') + }) + + it('should return idle if registration status is loading', () => { + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: true, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('idle') + }) + + it('should return idle if renewSeconds is null', () => { + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: null, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('idle') + }) + + it('should return idle if connectModalOpen is true', () => { + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: true, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('idle') + }) + + it('should return idle if abilities is loading', () => { + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: true, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('idle') + }) + + it('should return idle if isRouterReady is false', () => { + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: false, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('idle') + }) + + it('should return idle if name is empty', () => { + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: '', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('idle') + }) + + it('should return idle if openedConnectModal is true', () => { + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: true, + openConnectModal: () => {}, + }), + ).toBe('idle') + }) + + it('should return idle if openConnectModal is undefined and accountStatus is disconnected', () => { + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'disconnected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: undefined, + }), + ).toBe('idle') + }) + + it('should return idle if registration status is neither registered nor gracePeriod', () => { + expect( + calculateRenewState({ + registrationStatus: 'premium', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('idle') + }) + + it('should return display-extend-names when all conditions are met for connected user', () => { + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('display-extend-names') + }) + + it('should return idle when isRenewActive is false due to missing renewSeconds', () => { + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: null, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('idle') + }) + + it('should return connect-user when user is disconnected and openConnectModal is available', () => { + const openConnectModal = () => {} + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'disconnected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal, + }), + ).toBe('connect-user') + }) + + it('should return display-extend-names when name is in grace period and user is connected', () => { + expect( + calculateRenewState({ + registrationStatus: 'gracePeriod', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('display-extend-names') + }) + + it('should return connect-user when name is in grace period but user is disconnected', () => { + const openConnectModal = () => {} + expect( + calculateRenewState({ + registrationStatus: 'gracePeriod', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'disconnected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal, + }), + ).toBe('connect-user') + }) + + describe('registration status tests', () => { + const baseProps = { + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + } + + const testCases: { + status: RegistrationStatus + expected: 'idle' | 'display-extend-names' | 'connect-user' + }[] = [ + { status: 'invalid', expected: 'idle' }, + { status: 'short', expected: 'idle' }, + { status: 'imported', expected: 'idle' }, + { status: 'owned', expected: 'idle' }, + { status: 'notImported', expected: 'idle' }, + { status: 'notOwned', expected: 'idle' }, + { status: 'unsupportedTLD', expected: 'idle' }, + { status: 'offChain', expected: 'idle' }, + ] + + testCases.forEach(({ status, expected }) => { + it(`should return ${expected} for registration status ${status}`, () => { + expect( + calculateRenewState({ + ...baseProps, + registrationStatus: status, + }), + ).toBe(expected) + }) + }) + }) +}) + +describe('removeRenewParam', () => { + it('should remove both name and renew params while preserving others', () => { + // URL: ?name=test.eth&renew=123&other=value -> ?other=value + expect( + removeRenewParam({ + query: { + name: 'test.eth', + renew: '123', + other: 'value', + } as ParsedUrlQuery, + }), + ).toBe('?other=value') + }) + + it('should handle multiple values for other params', () => { + // URL: ?name=test.eth&other=value1&other=value2&renew=123 -> ?other=value1&other=value2 + expect( + removeRenewParam({ + query: { + name: 'test.eth', + other: ['value1', 'value2'], + renew: '123', + } as ParsedUrlQuery, + }), + ).toBe('?other=value1%2Cvalue2') + }) + + it('should preserve order of remaining params', () => { + // URL: ?z=last&name=test.eth&a=first&renew=123&m=middle -> ?z=last&a=first&m=middle + expect( + removeRenewParam({ + query: { + z: 'last', + name: 'test.eth', + a: 'first', + renew: '123', + m: 'middle', + } as ParsedUrlQuery, + }), + ).toBe('?z=last&a=first&m=middle') + }) + + it('should handle encoded characters in params', () => { + // URL: ?name=test%20name.eth¶m=special%20value&renew=123 -> ?param=special%20value + expect( + removeRenewParam({ + query: { + name: 'test name.eth', + param: 'special value', + renew: '123', + } as ParsedUrlQuery, + }), + ).toBe('?param=special+value') + }) + + it('should return empty string when all params are removed', () => { + // URL: ?name=test.eth&renew=123 -> '' + expect( + removeRenewParam({ + query: { + name: 'test.eth', + renew: '123', + } as ParsedUrlQuery, + }), + ).toBe('') + }) + + it('should handle empty query object', () => { + // URL: '' -> '' + expect( + removeRenewParam({ + query: {} as ParsedUrlQuery, + }), + ).toBe('') + }) + + it('should handle query with only name param', () => { + // URL: ?name=test.eth -> '' + expect( + removeRenewParam({ + query: { + name: 'test.eth', + } as ParsedUrlQuery, + }), + ).toBe('') + }) + + it('should handle query with only renew param', () => { + // URL: ?renew=123 -> '' + expect( + removeRenewParam({ + query: { + renew: '123', + } as ParsedUrlQuery, + }), + ).toBe('') + }) + + it('should preserve empty values in other params', () => { + // URL: ?name=test.eth&empty=&renew=123 -> ?empty= + expect( + removeRenewParam({ + query: { + name: 'test.eth', + empty: '', + renew: '123', + } as ParsedUrlQuery, + }), + ).toBe('?empty=') + }) +}) + +describe('useRenew', () => { + const mockShowExtendNamesInput = vi.fn() + + beforeEach(() => { + mockUseBasicName.mockReturnValue({ + registrationStatus: 'registered', + isLoading: false, + }) + + mockUseAbilities.mockReturnValue({ + data: { canSelfExtend: true }, + isLoading: false, + }) + + mockUseAccount.mockReturnValue({ + status: 'connected', + }) + + mockUseConnectModal.mockReturnValue({ + connectModalOpen: false, + openConnectModal: undefined, + }) + + mockUseTransactionFlow.mockReturnValue({ + usePreparedDataInput: () => mockShowExtendNamesInput, + }) + + mockUseSearchParams.mockReturnValue({ + get: (key: string) => (key === 'renew' ? '123' : null), + }) + + mockRouter.setCurrentUrl('/test.eth') + }) + + afterEach(() => { + vi.clearAllMocks() + mockRouter.setCurrentUrl('/') + }) + + it('should handle URL changes', () => { + mockRouter.push('/test.eth?renew=86400') + + mockUseSearchParams.mockReturnValue({ + get: (key: string) => (key === 'renew' ? '86400' : null), + }) + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).toHaveBeenCalledWith('extend-names-test.eth', { + names: ['test.eth'], + isSelf: true, + seconds: 86400, + }) + + mockRouter.push('/test.eth?renew=94608000') + + mockUseAbilities.mockReturnValue({ + data: { canSelfExtend: true }, + isLoading: false, + }) + + mockUseSearchParams.mockReturnValue({ + get: (key: string) => (key === 'renew' ? '94608000' : null), + }) + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).toHaveBeenCalledWith('extend-names-test.eth', { + names: ['test.eth'], + isSelf: true, + seconds: 94608000, + }) + }) + + it('should show extend names input for registered names', () => { + mockRouter.push('/test.eth?renew=123') + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).toHaveBeenCalledWith('extend-names-test.eth', { + names: ['test.eth'], + isSelf: true, + seconds: 86400, + }) + expect(mockRouter.asPath).toBe('/test.eth') + }) + + it('should show extend names input for registered names with default duration', () => { + mockRouter.push('/test.eth?renew') + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).toHaveBeenCalledWith('extend-names-test.eth', { + names: ['test.eth'], + isSelf: true, + seconds: 86400, + }) + expect(mockRouter.asPath).toBe('/test.eth') + }) + + it('should show extend names input for registered names with large duration', () => { + const largeDuration = 31536000000 // very large number + mockRouter.push(`/test.eth?renew=${largeDuration}`) + mockUseSearchParams.mockReturnValue({ + get: (key: string) => (key === 'renew' ? largeDuration.toString() : null), + }) + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).toHaveBeenCalledWith('extend-names-test.eth', { + names: ['test.eth'], + isSelf: true, + seconds: largeDuration, + }) + expect(mockRouter.asPath).toBe('/test.eth') + }) + + it('should show extend names input for names in grace period', () => { + mockRouter.push('/test.eth?renew=123') + mockUseBasicName.mockReturnValue({ + registrationStatus: 'gracePeriod', + isLoading: false, + }) + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).toHaveBeenCalledWith('extend-names-test.eth', { + names: ['test.eth'], + isSelf: true, + seconds: 86400, + }) + }) + + it('should open connect modal for disconnected users', () => { + mockRouter.push('/test.eth?renew=123') + const mockOpenConnectModal = vi.fn() + mockUseAccount.mockReturnValue({ status: 'disconnected' }) + mockUseConnectModal.mockReturnValue({ + openConnectModal: mockOpenConnectModal, + connectModalOpen: false, + }) + + renderHook(() => useRenew('test.eth')) + + expect(mockOpenConnectModal).toHaveBeenCalled() + expect(mockShowExtendNamesInput).not.toHaveBeenCalled() + }) + + it('should do nothing when name is not registered or in grace period', () => { + mockRouter.push('/test.eth?renew=123') + mockUseBasicName.mockReturnValue({ + registrationStatus: 'available', + isLoading: false, + }) + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).not.toHaveBeenCalled() + }) + + it('should do nothing when registration status is loading', () => { + mockRouter.push('/test.eth?renew=123') + mockUseBasicName.mockReturnValue({ + registrationStatus: 'registered', + isLoading: true, + }) + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).not.toHaveBeenCalled() + }) + + it('should handle non-self-extendable names', () => { + mockRouter.push('/test.eth?renew=123') + mockUseAbilities.mockReturnValue({ + data: { canSelfExtend: false }, + isLoading: false, + }) + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).toHaveBeenCalledWith('extend-names-test.eth', { + names: ['test.eth'], + isSelf: false, + seconds: 86400, + }) + }) + + it('should do nothing when abilities are loading', () => { + mockRouter.push('/test.eth?renew=123') + mockUseAbilities.mockReturnValue({ + data: { canSelfExtend: true }, + isLoading: true, + }) + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).not.toHaveBeenCalled() + }) + + it('should do nothing when router is not ready', () => { + mockRouter.push('/test.eth?renew=123') + mockRouter.isReady = false + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).not.toHaveBeenCalled() + }) + + it('should do nothing when connect modal is open', () => { + mockRouter.push('/test.eth?renew=123') + mockUseConnectModal.mockReturnValue({ + openConnectModal: vi.fn(), + connectModalOpen: true, + }) + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).not.toHaveBeenCalled() + }) + + it('should handle invalid duration in URL', () => { + mockRouter.push('/test.eth?renew=invalid') + mockUseSearchParams.mockReturnValue({ + get: (key: string) => (key === 'renew' ? 'invalid' : null), + }) + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/pages/profile/useRenew/useRenew.ts b/src/hooks/pages/profile/useRenew/useRenew.ts new file mode 100644 index 000000000..0c9802afe --- /dev/null +++ b/src/hooks/pages/profile/useRenew/useRenew.ts @@ -0,0 +1,118 @@ +import { useConnectModal } from '@rainbow-me/rainbowkit' +import { useSearchParams } from 'next/navigation' +import { useEffect, useState } from 'react' +import { match } from 'ts-pattern' +import { useAccount } from 'wagmi' + +import { useAbilities } from '@app/hooks/abilities/useAbilities' +import { useBasicName } from '@app/hooks/useBasicName' +import { useRouterWithHistory } from '@app/hooks/useRouterWithHistory' +import { validateExtendNamesDuration } from '@app/transaction-flow/input/ExtendNames/utils/validateExtendNamesDuration' +import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { RegistrationStatus } from '@app/utils/registrationStatus' + +type RenewStatus = 'connect-user' | 'display-extend-names' | 'idle' + +export const calculateRenewState = ({ + registrationStatus, + isRegistrationStatusLoading, + renewSeconds, + openConnectModal, + connectModalOpen, + accountStatus, + isAbilitiesLoading, + isRouterReady, + name, + openedConnectModal, +}: { + registrationStatus?: RegistrationStatus + isRegistrationStatusLoading: boolean + renewSeconds: number | null + connectModalOpen: boolean + openConnectModal: ReturnType['openConnectModal'] + accountStatus: ReturnType['status'] + isAbilitiesLoading: boolean + isRouterReady: boolean + name?: string + openedConnectModal: boolean +}): RenewStatus => { + const isNameRegisteredOrGracePeriod = + registrationStatus === 'registered' || registrationStatus === 'gracePeriod' + const isRenewActive = + isRouterReady && + !isRegistrationStatusLoading && + !!name && + isNameRegisteredOrGracePeriod && + !!renewSeconds && + !connectModalOpen && + !openedConnectModal + + if (isRenewActive && accountStatus === 'disconnected' && !!openConnectModal) return 'connect-user' + if (isRenewActive && accountStatus === 'connected' && !isAbilitiesLoading) + return 'display-extend-names' + return 'idle' +} + +export const removeRenewParam = ({ + query, +}: { + query: ReturnType['query'] +}): string => { + const searchParams = new URLSearchParams(query as any) + // remove the name param in case the page is a redirect from /profile page + searchParams.delete('name') + searchParams.delete('renew') + const newParams = searchParams.toString() + return newParams ? `?${newParams}` : '' +} + +export function useRenew(name: string) { + const router = useRouterWithHistory() + const { registrationStatus, isLoading: isBasicNameLoading } = useBasicName({ name }) + const abilities = useAbilities({ name }) + const searchParams = useSearchParams() + const { status } = useAccount() + + const { openConnectModal, connectModalOpen } = useConnectModal() + const [openedConnectModal, setOpenedConnectModal] = useState(connectModalOpen) + + const { usePreparedDataInput } = useTransactionFlow() + const showExtendNamesInput = usePreparedDataInput('ExtendNames') + + const { data: { canSelfExtend } = {}, isLoading: isAbilitiesLoading } = abilities + + const renewSeconds = validateExtendNamesDuration({ duration: searchParams.get('renew') }) + + const renewState = calculateRenewState({ + registrationStatus, + isRegistrationStatusLoading: isBasicNameLoading, + renewSeconds, + connectModalOpen, + accountStatus: status, + isAbilitiesLoading, + name, + isRouterReady: router.isReady, + openConnectModal, + openedConnectModal, + }) + + useEffect(() => { + match(renewState) + .with('connect-user', () => { + openConnectModal?.() + setOpenedConnectModal(!!openConnectModal) + }) + .with('display-extend-names', () => { + showExtendNamesInput(`extend-names-${name}`, { + names: [name], + isSelf: canSelfExtend, + seconds: renewSeconds!, + }) + const params = removeRenewParam({ query: router.query }) + router.replace(`/${name}${params}`) + }) + .with('idle', () => {}) + .exhaustive() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [renewState]) +} diff --git a/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx b/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx index 98297031e..97164798f 100644 --- a/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx +++ b/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx @@ -30,6 +30,7 @@ import { deriveYearlyFee, formatDurationOfDates } from '@app/utils/utils' import { ShortExpiry } from '../../../components/@atoms/ExpiryComponents/ExpiryComponents' import GasDisplay from '../../../components/@atoms/GasDisplay' import { SearchViewLoadingView } from '../SendName/views/SearchView/views/SearchViewLoadingView' +import { validateExtendNamesDuration } from './utils/validateExtendNamesDuration' type View = 'name-list' | 'no-ownership-warning' | 'registration' @@ -161,6 +162,7 @@ const NamesList = ({ names }: NamesListProps) => { type Data = { names: string[] + seconds?: number isSelf?: boolean } @@ -170,10 +172,16 @@ export type Props = { const minSeconds = ONE_DAY -const ExtendNames = ({ data: { names, isSelf }, dispatch, onDismiss }: Props) => { +const ExtendNames = ({ + data: { seconds: defaultSeconds, names, isSelf }, + dispatch, + onDismiss, +}: Props) => { const { t } = useTranslation(['transactionFlow', 'common']) - const [seconds, setSeconds] = useState(ONE_YEAR) + const [seconds, setSeconds] = useState( + validateExtendNamesDuration({ duration: defaultSeconds ?? ONE_YEAR })!, + ) const years = secondsToYears(seconds) const [durationType, setDurationType] = useState<'years' | 'date'>('years') diff --git a/src/transaction-flow/input/ExtendNames/utils/validateExtendNamesDuration.test.ts b/src/transaction-flow/input/ExtendNames/utils/validateExtendNamesDuration.test.ts new file mode 100644 index 000000000..a960f91d5 --- /dev/null +++ b/src/transaction-flow/input/ExtendNames/utils/validateExtendNamesDuration.test.ts @@ -0,0 +1,41 @@ +import { it, describe, expect } from "vitest"; +import { validateExtendNamesDuration } from "./validateExtendNamesDuration"; +import { ONE_DAY, ONE_YEAR } from "@app/utils/time"; + +describe('validateExtendNamesDuration', () => { + it('should return an integer', () => { + expect(validateExtendNamesDuration({ duration: '90000'})).toBe(90000) + }) + + it('should return an integer for a decimal', () => { + expect(validateExtendNamesDuration({duration: '90000.123'})).toBe(90000) + }) + + it('should return minimum duration for a number less than the minimum', () => { + expect(validateExtendNamesDuration({duration: '0'})).toBe(ONE_DAY) + }) + + it('should return null duration for null', () => { + expect(validateExtendNamesDuration({duration: null})).toBe(null) + }) + + it('should return default duration for undefined', () => { + expect(validateExtendNamesDuration({duration: undefined})).toBe(ONE_YEAR) + }) + + it('should return default for a string', () => { + expect(validateExtendNamesDuration({duration: 'abc'})).toBe(ONE_YEAR) + }) + + it('should return default for an empty string', () => { + expect(validateExtendNamesDuration({ duration: ''})).toBe(ONE_YEAR) + }) + + it('should return default for a negative number', () => { + expect(validateExtendNamesDuration({duration: '-123'})).toBe(ONE_YEAR) + }) + + it('should return default for an object ', () => { + expect(validateExtendNamesDuration({ duration: {}})).toBe(ONE_YEAR) + }) +}) \ No newline at end of file diff --git a/src/transaction-flow/input/ExtendNames/utils/validateExtendNamesDuration.ts b/src/transaction-flow/input/ExtendNames/utils/validateExtendNamesDuration.ts new file mode 100644 index 000000000..f6fd0ce6e --- /dev/null +++ b/src/transaction-flow/input/ExtendNames/utils/validateExtendNamesDuration.ts @@ -0,0 +1,17 @@ +import { ONE_DAY, ONE_YEAR } from '@app/utils/time' + +export const validateExtendNamesDuration = ({ + duration, + minDuration = ONE_DAY, + defaultDuration = ONE_YEAR, +}: { + duration?: unknown + minDuration?: number + defaultDuration?: number +}): number | null => { + if (duration === null) return null + const parsedDuration = parseInt(duration as string, 10) + if (Number.isNaN(parsedDuration) || parsedDuration < 0) return defaultDuration + if (parsedDuration < minDuration) return minDuration + return parsedDuration +}