diff --git a/.github/workflows/pages-deployment.yaml b/.github/workflows/pages-deployment.yaml index b96c3d2e4..6aea45954 100644 --- a/.github/workflows/pages-deployment.yaml +++ b/.github/workflows/pages-deployment.yaml @@ -70,14 +70,16 @@ jobs: SITEMAP_GRAPH_KEY: ${{ secrets.SITEMAP_GRAPH_KEY }} - name: Publish - uses: cloudflare/pages-action@v1 + uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - projectName: ens-app-v3 - directory: out - gitHubToken: ${{ secrets.GITHUB_TOKEN }} - wranglerVersion: '3' + wranglerVersion: 'v3.57.1' + command: pages deploy --project-name=ens-app-v3 + secrets: | + GITHUB_TOKEN + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Submit sitemap if: ${{ github.ref == 'refs/heads/main' }} diff --git a/README.md b/README.md index 84d91b826..af730a927 100644 --- a/README.md +++ b/README.md @@ -123,8 +123,8 @@ have developed a design system in order to ensure consistent styling across the Pages folder has basic route layout and basic react needed for rendering pages. These files should be kept relatively simple -Components that pages consume are kept in the components folder. This folder has a strucutre -that mimicks the strucutre of the pages folder. If a component is only used on a specific page +Components that pages consume are kept in the components folder. This folder has a structure +that mimics the structure of the pages folder. If a component is only used on a specific page then it goes into the corresponding folder in the components folder. If a component is used across multiple pages and other components, @@ -254,7 +254,7 @@ Once exited, you can commit the data to your branch. You do not need to run a se #### Stateless vs Stateful -Our e2e tests are split into two categories, stateless and stateful. Stateless test use the development environment, are faster, and is the general recommended way to write integration tests. Occasionally, you may need to test a feature that requires an external api or service. In this case, you can use the stateful tests. These tests are slower, +Our e2e tests are split into two categories, stateless and stateful. Stateless test use the development environment, are faster, and is the general recommended way to write integration tests. Occasionally, you may need to test a feature that requires an external API or service. In this case, you can use the stateful tests. These tests are slower, #### Running the tests diff --git a/e2e/specs/stateless/box.spec.ts b/e2e/specs/stateless/box.spec.ts index 8d4ac9244..1e06f1a2b 100644 --- a/e2e/specs/stateless/box.spec.ts +++ b/e2e/specs/stateless/box.spec.ts @@ -86,12 +86,7 @@ test('should not direct to the registration page if name is not available', asyn const name = 'google.box' await consoleListener.initialize({ - regex: new RegExp( - `Event triggered on local development.*?(${[ - 'search_selected_box', - 'register_started_box', - ].join('|')})`, - ), + regex: /Event triggered on local development.*?search_selected_box/, }) const homePage = makePageObject('HomePage') diff --git a/e2e/specs/stateless/createSubname.spec.ts b/e2e/specs/stateless/createSubname.spec.ts index ccffaf05e..da2041962 100644 --- a/e2e/specs/stateless/createSubname.spec.ts +++ b/e2e/specs/stateless/createSubname.spec.ts @@ -116,13 +116,19 @@ test('should allow creating a subname', async ({ page, makeName, login, makePage await login.connect() await subnamesPage.getAddSubnameButton.click() - await subnamesPage.getAddSubnameInput.type('test') + await subnamesPage.getAddSubnameInput.fill('test') await subnamesPage.getSubmitSubnameButton.click() + await subnamesPage.addMoreToProfileButton.click() + await page.getByTestId('profile-record-option-name').click() + await page.getByTestId('add-profile-records-button').click() + await page.getByTestId('profile-record-input-input-name').fill('Test Name') + await subnamesPage.getSubmitSubnameProfileButton.click() const transactionModal = makePageObject('TransactionModal') await transactionModal.autoComplete() const subname = `test.${name}` + await subnamesPage.goto(subname) await expect(page).toHaveURL(new RegExp(`/${subname}`), { timeout: 15000 }) }) @@ -150,6 +156,7 @@ test('should allow creating a subnames if the user is the wrapped owner', async await subnamesPage.getAddSubnameButton.click() await subnamesPage.getAddSubnameInput.fill('test') await subnamesPage.getSubmitSubnameButton.click() + await subnamesPage.getSubmitSubnameProfileButton.click() const transactionModal = makePageObject('TransactionModal') await transactionModal.autoComplete() @@ -226,6 +233,7 @@ test('should allow creating an expired wrapped subname', async ({ await subnamesPage.getAddSubnameButton.click() await subnamesPage.getAddSubnameInput.fill('test') await subnamesPage.getSubmitSubnameButton.click() + await subnamesPage.getSubmitSubnameProfileButton.click() await transactionModal.autoComplete() @@ -234,6 +242,7 @@ test('should allow creating an expired wrapped subname', async ({ }) test('should allow creating an expired wrapped subname from the profile page', async ({ + page, makeName, login, makePageObject, @@ -269,7 +278,43 @@ test('should allow creating an expired wrapped subname from the profile page', a await profilePage.getRecreateButton.click() + await page.getByTestId('reclaim-profile-next').click() + await transactionModal.autoComplete() await expect(profilePage.getRecreateButton).toHaveCount(0) }) + +test('should allow skipping records when creating a subname', async ({ + page, + makeName, + login, + makePageObject, +}) => { + test.slow() + const name = await makeName({ + label: 'manager-only', + type: 'legacy', + owner: 'user', + manager: 'user', + }) + + const subnamesPage = makePageObject('SubnamesPage') + + await subnamesPage.goto(name) + await login.connect() + + await subnamesPage.getAddSubnameButton.click() + await subnamesPage.getAddSubnameInput.fill('test') + await subnamesPage.getSubmitSubnameButton.click() + expect(subnamesPage.addMoreToProfileButton).toBeVisible() + await page.getByTestId('create-subname-profile-next').click() + + const transactionModal = makePageObject('TransactionModal') + await transactionModal.autoComplete() + + const subname = `test.${name}` + + await expect(page).toHaveURL(new RegExp(`/${subname}`), { timeout: 15000 }) + await expect(page.getByTestId('profile-empty-banner')).toBeVisible() +}) diff --git a/e2e/specs/stateless/extendNames.spec.ts b/e2e/specs/stateless/extendNames.spec.ts index 51ddd1e73..e0dbea69b 100644 --- a/e2e/specs/stateless/extendNames.spec.ts +++ b/e2e/specs/stateless/extendNames.spec.ts @@ -18,6 +18,7 @@ test('should be able to register multiple names on the address page', async ({ subgraph, makePageObject, makeName, + time, }) => { // Generating names in not neccessary but we want to make sure that there are names to extend await makeName([ @@ -39,7 +40,6 @@ test('should be able to register multiple names on the address page', async ({ await addresPage.goto(address) await login.connect() - await page.pause() await addresPage.selectToggle.click() @@ -65,29 +65,44 @@ test('should be able to register multiple names on the address page', async ({ // warning message await expect(page.getByText('You do not own all these names')).toBeVisible() - await page.getByRole('button', { name: 'I understand' }).click() + await page.locator('button:has-text("I understand")').click() // name list - await addresPage.extendNamesModalNextButton.click() + await page.waitForLoadState('networkidle') + await expect(page.getByText(`Extend ${extendableNameItems.length} Names`)).toBeVisible() + page.locator('button:has-text("Next")').waitFor({ state: 'visible' }) + await page.locator('button:has-text("Next")').click() // check the invoice details - await page.pause() - await expect(page.getByText(`Extend ${extendableNameItems.length} Names`)).toBeVisible() + await page.waitForLoadState('networkidle') await expect(page.getByText('1 year extension', { exact: true })).toBeVisible() - // increment and save await page.getByTestId('plus-minus-control-plus').click() await page.getByTestId('plus-minus-control-plus').click() - await page.getByTestId('extend-names-confirm').click() + await page.waitForLoadState('networkidle') + await expect(page.getByTestId('invoice-item-0-amount')).not.toBeEmpty() + await expect(page.getByTestId('invoice-item-1-amount')).not.toBeEmpty() + await expect(page.getByTestId('invoice-total')).not.toBeEmpty() + + page.locator('button:has-text("Next")').waitFor({ state: 'visible' }) + await page.locator('button:has-text("Next")').click() + await page.waitForLoadState('networkidle') await transactionModal.autoComplete() + await expect(page.getByText('Your "Extend names" transaction was successful')).toBeVisible({ + timeout: 10000, + }) await subgraph.sync() + + // Should be able to remove this after useQuery is fixed. Using to force a refetch. + await time.increaseTime({ seconds: 15 }) await page.reload() - await page.waitForTimeout(3000) + await page.waitForLoadState('networkidle') for (const name of extendableNameItems) { const label = name.replace('.eth', '') await addresPage.search(label) + await expect(addresPage.getNameRow(name)).toBeVisible({ timeout: 5000 }) await expect(await addresPage.getTimestamp(name)).not.toBe(timestampDict[name]) await expect(await addresPage.getTimestamp(name)).toBe(timestampDict[name] + 31536000000 * 3) } @@ -117,7 +132,7 @@ test('should be able to extend a single unwrapped name from profile', async ({ const extendNamesModal = makePageObject('ExtendNamesModal') await test.step('should show warning message', async () => { - await expect(page.getByText('You do not own this name')).toBeVisible() + await expect(page.getByText(`You do not own ${name}`)).toBeVisible() await page.getByRole('button', { name: 'I understand' }).click() }) @@ -130,12 +145,6 @@ test('should be able to extend a single unwrapped name from profile', async ({ }) }) - await test.step('should show the cost comparison data', async () => { - await expect(page.getByTestId('year-marker-0')).toContainText('2% gas') - await expect(page.getByTestId('year-marker-1')).toContainText('1% gas') - await expect(page.getByTestId('year-marker-2')).toContainText('1% gas') - }) - await test.step('should work correctly with plus minus control', async () => { await expect(extendNamesModal.getCounterMinusButton).toBeDisabled() await expect(extendNamesModal.getInvoiceExtensionFee).toContainText('0.0033') @@ -199,12 +208,6 @@ test('should be able to extend a single unwrapped name in grace period from prof await expect(page.getByText('1 year extension', { exact: true })).toBeVisible() }) - await test.step('should show the cost comparison data', async () => { - await expect(page.getByTestId('year-marker-0')).toContainText('2% gas') - await expect(page.getByTestId('year-marker-1')).toContainText('1% gas') - await expect(page.getByTestId('year-marker-2')).toContainText('1% gas') - }) - await test.step('should work correctly with plus minus control', async () => { await expect(extendNamesModal.getCounterMinusButton).toBeDisabled() await expect(extendNamesModal.getInvoiceExtensionFee).toContainText('0.0033') @@ -258,7 +261,7 @@ test('should be able to extend a single unwrapped name in grace period from prof await profilePage.getExtendButton.click() await test.step('should show warning message', async () => { - await expect(page.getByText('You do not own this name')).toBeVisible() + await expect(page.getByText(`You do not own ${name}`)).toBeVisible() await page.getByRole('button', { name: 'I understand' }).click() }) @@ -269,12 +272,6 @@ test('should be able to extend a single unwrapped name in grace period from prof await expect(page.getByText('1 year extension', { exact: true })).toBeVisible() }) - await test.step('should show the cost comparison data', async () => { - await expect(page.getByTestId('year-marker-0')).toContainText('2% gas') - await expect(page.getByTestId('year-marker-1')).toContainText('1% gas') - await expect(page.getByTestId('year-marker-2')).toContainText('1% gas') - }) - await test.step('should work correctly with plus minus control', async () => { await expect(extendNamesModal.getCounterMinusButton).toBeDisabled() await expect(extendNamesModal.getInvoiceExtensionFee).toContainText('0.0033') @@ -490,12 +487,6 @@ test('should be able to extend a name in grace period by a month', async ({ await expect(page.getByText('1 year extension', { exact: true })).toBeVisible() }) - await test.step('should show the cost comparison data', async () => { - await expect(page.getByTestId('year-marker-0')).toContainText('2% gas') - await expect(page.getByTestId('year-marker-1')).toContainText('1% gas') - await expect(page.getByTestId('year-marker-2')).toContainText('1% gas') - }) - await test.step('should be able to pick by date', async () => { const dateSelection = page.getByTestId('date-selection') await expect(dateSelection).toHaveText('Pick by date') @@ -573,12 +564,6 @@ test('should be able to extend a name in grace period by 1 day', async ({ await expect(page.getByText('1 year extension', { exact: true })).toBeVisible() }) - await test.step('should show the cost comparison data', async () => { - await expect(page.getByTestId('year-marker-0')).toContainText('2% gas') - await expect(page.getByTestId('year-marker-1')).toContainText('1% gas') - await expect(page.getByTestId('year-marker-2')).toContainText('1% gas') - }) - await test.step('should be able to pick by date', async () => { const dateSelection = page.getByTestId('date-selection') await expect(dateSelection).toHaveText('Pick by date') @@ -619,3 +604,82 @@ test('should be able to extend a name in grace period by 1 day', async ({ await expect(comparativeTimestamp).toEqual(newTimestamp) }) }) + +test('should be able to extend a single wrapped name using deep link', async ({ + page, + login, + makePageObject, + makeName, +}) => { + const name = await makeName({ + label: 'legacy', + type: 'wrapped', + owner: 'user2', + }) + + const profilePage = makePageObject('ProfilePage') + const transactionModal = makePageObject('TransactionModal') + + const homePage = makePageObject('HomePage') + await homePage.goto() + await login.connect() + await page.goto(`/${name}?renew`) + + const timestamp = await profilePage.getExpiryTimestamp() + + const extendNamesModal = makePageObject('ExtendNamesModal') + await test.step('should show warning message', async () => { + await expect(page.getByText(`You do not own ${name}`)).toBeVisible() + await page.getByRole('button', { name: 'I understand' }).click() + }) + + await test.step('should show 1 year extension', async () => { + await expect(page.getByText('1 year extension', { exact: true })).toBeVisible({ + timeout: 30000, + }) + }) + + await test.step('should be able to add more years', async () => { + await expect(extendNamesModal.getCounterMinusButton).toBeDisabled() + await extendNamesModal.getCounterPlusButton.click() + await expect(page.getByText('2 years extension', { exact: true })).toBeVisible({ + timeout: 30000, + }) + }) + + await test.step('should be able to reduce number of years', async () => { + await extendNamesModal.getCurrencyToggle.click({ force: true }) + await extendNamesModal.getCounterMinusButton.click() + }) + + await test.step('should extend', async () => { + await extendNamesModal.getExtendButton.click() + await transactionModal.autoComplete() + const newTimestamp = await profilePage.getExpiryTimestamp() + expect(newTimestamp).toEqual(timestamp + 31536000000) + }) +}) + +test('should not be able to extend a name which is not registered', async ({ + page, + makePageObject, + login, +}) => { + const name = 'this-name-does-not-exist.eth' + const homePage = makePageObject('HomePage') + await homePage.goto() + await login.connect() + await page.goto(`/${name}?renew`) + await expect(page.getByRole('heading', { name: `Register ${name}` })).toBeVisible() +}) + +test('renew deep link should redirect to registration when not logged in', async ({ + page, + makePageObject, +}) => { + const name = 'this-name-does-not-exist.eth' + const homePage = makePageObject('HomePage') + await homePage.goto() + await page.goto(`/${name}?renew`) + await expect(page.getByRole('heading', { name: `Register ${name}` })).toBeVisible() +}) diff --git a/e2e/specs/stateless/ownership.spec.ts b/e2e/specs/stateless/ownership.spec.ts index 7d379804a..332de2f35 100644 --- a/e2e/specs/stateless/ownership.spec.ts +++ b/e2e/specs/stateless/ownership.spec.ts @@ -1150,7 +1150,7 @@ test.describe('Extend name', () => { await ownershipPage.extendButton.click() await test.step('should show ownership warning', async () => { - await expect(page.getByText('You do not own this name')).toBeVisible() + await expect(page.getByText(`You do not own ${name}`)).toBeVisible() await page.getByRole('button', { name: 'I understand' }).click() }) await test.step('should show the correct price data', async () => { @@ -1160,12 +1160,6 @@ test.describe('Extend name', () => { await expect(page.getByText('1 year extension', { exact: true })).toBeVisible() }) - await test.step('should show the cost comparison data', async () => { - await expect(page.getByTestId('year-marker-0')).toContainText('2% gas') - await expect(page.getByTestId('year-marker-1')).toContainText('1% gas') - await expect(page.getByTestId('year-marker-2')).toContainText('1% gas') - }) - await test.step('should work correctly with plus minus control', async () => { await expect(extendNamesModal.getCounterMinusButton).toBeDisabled() await expect(extendNamesModal.getInvoiceExtensionFee).toContainText('0.0033') diff --git a/e2e/specs/stateless/registerName.spec.ts b/e2e/specs/stateless/registerName.spec.ts index 8b4762455..1b2209a93 100644 --- a/e2e/specs/stateless/registerName.spec.ts +++ b/e2e/specs/stateless/registerName.spec.ts @@ -43,7 +43,7 @@ test.describe.serial('normal registration', () => { await consoleListener.initialize({ regex: new RegExp( `Event triggered on local development.*?(${[ - 'register-override-triggered', + 'register_override_triggered', 'search_selected_eth', 'search_selected_box', 'payment_selected', @@ -56,16 +56,17 @@ test.describe.serial('normal registration', () => { ), }) - await time.sync(500) - + await time.sync() await homePage.goto() await login.connect() - // should redirect to registration page - await homePage.searchInput.fill(name) - await page.locator(`[data-testid="search-result-name"]`, { hasText: name }).waitFor() - await page.locator(`[data-testid="search-result-name"]`, { hasText: 'Available' }).waitFor() - await homePage.searchInput.press('Enter') + await test.step('should redirect to registration page', async () => { + await homePage.searchInput.fill(name) + await page.locator(`[data-testid="search-result-name"]`, { hasText: name }).waitFor() + await page.locator(`[data-testid="search-result-name"]`, { hasText: 'Available' }).waitFor() + await homePage.searchInput.press('Enter') + await expect(page.getByRole('heading', { name: `Register ${name}` })).toBeVisible() + }) await test.step('should fire tracking event: search_selected_eth', async () => { await expect(consoleListener.getMessages()).toHaveLength(1) @@ -75,34 +76,39 @@ test.describe.serial('normal registration', () => { consoleListener.clearMessages() }) - await expect(page.getByRole('heading', { name: `Register ${name}` })).toBeVisible() + await test.step('should have payment choice ethereum checked and show primary name setting as checked', async () => { + await expect(page.getByTestId('payment-choice-ethereum')).toBeChecked() + await expect(registrationPage.primaryNameToggle).toBeChecked() + }) - // should have payment choice ethereum checked and show primary name setting as checked - await expect(page.getByTestId('payment-choice-ethereum')).toBeChecked() - await expect(registrationPage.primaryNameToggle).toBeChecked() - - // should show adjusted gas estimate when primary name setting checked - const estimate = await registrationPage.getGas() - expect(estimate).toBeGreaterThan(0) - await registrationPage.primaryNameToggle.click() - const estimate2 = await registrationPage.getGas() - await expect(estimate2).toBeGreaterThan(0) - expect(estimate2).toBeLessThan(estimate) - await registrationPage.primaryNameToggle.click() - - // should show cost comparison accurately - await expect(registrationPage.yearMarker(0)).toHaveText(/13% gas/) - await expect(registrationPage.yearMarker(1)).toHaveText(/7% gas/) - await expect(registrationPage.yearMarker(2)).toHaveText(/3% gas/) - - // should show correct price for yearly registration - await expect(registrationPage.fee).toHaveText(/0.0033/) - await registrationPage.plusYearButton.click() - await expect(registrationPage.fee).toHaveText(/0.0065/) - await registrationPage.minusYearButton.click() - - // should go to profile editor step - await page.getByTestId('next-button').click() + const estimate = + await test.step('should show adjusted gas estimate when primary name setting checked', async () => { + const estimate1 = await registrationPage.getGas() + expect(estimate1).toBeGreaterThan(0) + await registrationPage.primaryNameToggle.click() + const estimate2 = await registrationPage.getGas() + await expect(estimate2).toBeGreaterThan(0) + expect(estimate2).toBeLessThan(estimate1) + await registrationPage.primaryNameToggle.click() + return estimate1 + }) + + await test.step('should show cost comparison accurately', async () => { + await expect(registrationPage.yearMarker(0)).toHaveText(/13% gas/) + await expect(registrationPage.yearMarker(1)).toHaveText(/7% gas/) + await expect(registrationPage.yearMarker(2)).toHaveText(/3% gas/) + }) + + await test.step('should show correct price for yearly registration', async () => { + await expect(registrationPage.fee).toHaveText(/0.0033/) + await registrationPage.plusYearButton.click() + await expect(registrationPage.fee).toHaveText(/0.0065/) + await registrationPage.minusYearButton.click() + }) + + await test.step('should go to profile editor step', async () => { + await page.getByTestId('next-button').click() + }) await test.step('should fire tracking event: payment_selected', async () => { await expect(consoleListener.getMessages()).toHaveLength(1) @@ -110,28 +116,34 @@ test.describe.serial('normal registration', () => { consoleListener.clearMessages() }) - // should show a confirmation dialog that records are public - await page.getByTestId('show-add-profile-records-modal-button').click() - await page.getByTestId('confirmation-dialog-confirm-button').click() + await test.step('should show a confirmation dialog that records are public', async () => { + await page.getByTestId('show-add-profile-records-modal-button').click() + await page.getByTestId('confirmation-dialog-confirm-button').click() + }) - // should all setting a gener text record - await page.getByTestId('profile-record-option-name').click() - await page.getByTestId('add-profile-records-button').click() - await page.getByTestId('profile-record-input-input-name').fill('Test Name') + await test.step('should allow setting a general text record', async () => { + await page.getByTestId('profile-record-option-name').click() + await page.getByTestId('add-profile-records-button').click() + await page.getByTestId('profile-record-input-input-name').fill('Test Name') + }) - // should show ETH record by default - await expect(page.getByTestId('profile-record-input-input-eth')).toHaveValue( - accounts.getAddress('user'), - ) + await test.step('should show ETH record by default', async () => { + await expect(page.getByTestId('profile-record-input-input-eth')).toHaveValue( + accounts.getAddress('user'), + ) + }) - // should show go to info step and show updated estimate - await expect(page.getByTestId('profile-submit-button')).toHaveText('Next') - await page.getByTestId('profile-submit-button').click() - await expect(registrationPage.gas).not.toHaveText(new RegExp(`${estimate} ETH`)) + await test.step('should show go to info step and show updated estimate', async () => { + await expect(page.getByTestId('profile-submit-button')).toHaveText('Next') + await page.getByTestId('profile-submit-button').click() + await expect(registrationPage.gas).not.toHaveText(new RegExp(`${estimate} ETH`)) + }) - // should go to transactions step and open commit transaction immediately - await expect(page.getByTestId('next-button')).toHaveText('Begin') - await page.getByTestId('next-button').click() + await test.step('should go to transactions step and open commit transaction immediately', async () => { + await expect(page.getByTestId('next-button')).toHaveText('Begin') + await page.getByTestId('next-button').click() + await expect(page.getByTestId('transaction-modal-inner')).toBeVisible() + }) await test.step('should fire tracking event: commit_started', async () => { await expect(consoleListener.getMessages()).toHaveLength(1) @@ -139,17 +151,20 @@ test.describe.serial('normal registration', () => { consoleListener.clearMessages() }) - await transactionModal.closeButton.click() - - await expect( - page.getByText( - 'You will need to complete two transactions to secure your name. The second transaction must be completed within 24 hours of the first.', - ), - ).toBeVisible() - await page.getByTestId('start-timer-button').click() + await test.step('should display the correct message in the registration page', async () => { + await transactionModal.closeButton.click() + await expect( + page.getByText( + 'You will need to complete two transactions to secure your name. The second transaction must be completed within 24 hours of the first.', + ), + ).toBeVisible() + }) - await expect(page.getByText('Open Wallet')).toBeVisible() - await transactionModal.confirm() + await test.step('should be able to confirm commit transaction', async () => { + await page.getByTestId('start-timer-button').click() + await expect(page.getByText('Open Wallet')).toBeVisible() + await transactionModal.confirm() + }) await test.step('should fire tracking event: commit_wallet_opened', async () => { await expect(consoleListener.getMessages()).toHaveLength(1) @@ -157,51 +172,57 @@ test.describe.serial('normal registration', () => { consoleListener.clearMessages() }) - // should show countdown text - await expect( - page.getByText( - 'This wait prevents others from front running your transaction. You will be prompted to complete a second transaction when the timer is complete.', - ), - ).toBeVisible() + await test.step('should show countdown text', async () => { + await expect( + page.getByText( + 'This wait prevents others from front running your transaction. You will be prompted to complete a second transaction when the timer is complete.', + ), + ).toBeVisible() + }) - // should show countdown - await expect(page.getByTestId('countdown-circle')).toBeVisible() - await expect(page.getByTestId('countdown-complete-check')).toBeVisible() - const waitButton = page.getByTestId('wait-button') - await expect(waitButton).toBeVisible() - await expect(waitButton).toBeDisabled() - const startTimerButton = page.getByTestId('start-timer-button') - await expect(startTimerButton).not.toBeVisible() - await testClient.increaseTime({ seconds: 60 }) + await test.step('should show countdown', async () => { + await time.sync() + await expect(page.getByTestId('countdown-circle')).toBeVisible() + await expect(page.getByTestId('countdown-complete-check')).not.toBeVisible() + const waitButton = page.getByTestId('wait-button') + await expect(waitButton).toBeVisible() + await expect(waitButton).toBeDisabled() + }) - // Should show registration text - await expect( - page.getByText( - 'Your name is not registered until you’ve completed the second transaction. You have 23 hours remaining to complete it.', - ), - ).toBeVisible() - await expect(page.getByTestId('finish-button')).toBeEnabled() + await test.step('should show checkmark and registration ready state after countdown is finished', async () => { + await time.increaseTime({ seconds: 60 }) + await expect(page.getByTestId('countdown-complete-check')).toBeVisible() + await expect(page.getByTestId('transactions-subheading')).toHaveText( + /Your name is not registered until you’ve completed the second transaction. You have (23 hours|1 day) remaining to complete it./, + ) + await expect(page.getByTestId('finish-button')).toBeEnabled() + }) - // should save the registration state and the transaction status - await page.reload() - await expect(page.getByTestId('finish-button')).toBeEnabled() + await test.step('should save the registration state and the transaction status', async () => { + await page.reload() + await expect(page.getByTestId('finish-button')).toBeEnabled() + }) - // should allow finalising registration and automatically go to the complete step - await expect( - page.getByText( - 'Your name is not registered until you’ve completed the second transaction. You have 23 hours remaining to complete it.', - ), - ).toBeVisible() - await page.getByTestId('finish-button').click() + await test.step('should be able to start registration step', async () => { + await expect( + page.getByText( + 'Your name is not registered until you’ve completed the second transaction. You have 23 hours remaining to complete it.', + ), + ).toBeVisible() + await page.getByTestId('finish-button').click() + }) await test.step('should fire tracking event: register_started', async () => { await expect(consoleListener.getMessages()).toHaveLength(1) + // We can assume that 'register_override_triggered' was not called because the consoleListener only has one message await expect(consoleListener.getMessages().toString()).toContain('register_started') consoleListener.clearMessages() }) - await expect(page.getByText('Open Wallet')).toBeVisible() - await transactionModal.confirm() + await test.step('should be able to confirm registration transaction', async () => { + await expect(page.getByText('Open Wallet')).toBeVisible() + await transactionModal.confirm() + }) await test.step('should fire tracking event: register_wallet_opened', async () => { await expect(consoleListener.getMessages()).toHaveLength(1) @@ -209,13 +230,32 @@ test.describe.serial('normal registration', () => { consoleListener.clearMessages() }) - // should show the correct details on completion - await expect(page.getByTestId('invoice-item-0-amount')).toHaveText(/0.0032 ETH/) + await test.step('should redirect to completion page ', async () => { + await expect(page.getByText(`You are now the owner of ${name}`)).toBeVisible() - await page.getByTestId('view-name').click() - await expect(page.getByTestId('address-profile-button-eth')).toHaveText( - accounts.getAddress('user', 5), - ) + // calculate date one year from now + const date = new Date() + date.setFullYear(date.getFullYear() + 1) + const formattedDate = date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }) + + // should show the correct details on completion + await expect(page.getByTestId('invoice-item-0-amount')).toHaveText(/0.0032 ETH/) + await expect(page.getByTestId('invoice-item-0')).toHaveText(/1 year registration/) + await expect(page.getByTestId('invoice-item-expiry')).toHaveText(/Name expires/) + await expect(page.getByTestId('invoice-item-expiry-date')).toHaveText(`${formattedDate}`) + }) + + await test.step('confirm that the registration is successful', async () => { + await page.getByTestId('view-name').click() + await expect(page).toHaveURL(`/${name}`) + await expect(page.getByTestId('address-profile-button-eth')).toHaveText( + accounts.getAddress('user', 5), + ) + }) }) test('should not direct to the registration page on search, and show all records from registration', async ({ @@ -225,9 +265,7 @@ test.describe.serial('normal registration', () => { consoleListener, }) => { await consoleListener.initialize({ - regex: new RegExp( - `Event triggered on local development.*?(${['search_selected_eth'].join('|')})`, - ), + regex: /Event triggered on local development.*?search_selected_eth/, }) const homePage = makePageObject('HomePage') @@ -998,3 +1036,119 @@ test('should be able to detect an existing commit created on a private mempool', ) }) }) + +test.describe('Error handling', () => { + test('should be able to detect an existing commit created on a private mempool', async ({ + page, + login, + time, + makePageObject, + }) => { + test.slow() + + const homePage = makePageObject('HomePage') + const registrationPage = makePageObject('RegistrationPage') + const transactionModal = makePageObject('TransactionModal') + + await time.sync() + + await homePage.goto() + await login.connect() + + const name = `expired-commit-${Date.now()}.eth` + // should redirect to registration page + await homePage.searchInput.fill(name) + await homePage.searchInput.press('Enter') + await expect(page.getByRole('heading', { name: `Register ${name}` })).toBeVisible() + + await test.step('pricing page', async () => { + await page.getByTestId('payment-choice-ethereum').check() + await registrationPage.primaryNameToggle.uncheck() + await page.getByTestId('next-button').click() + }) + + await test.step('info page', async () => { + await expect(page.getByTestId('next-button')).toHaveText('Begin') + await page.getByTestId('next-button').click() + }) + + await test.step('transaction: commit', async () => { + await expect(page.getByText('Open Wallet')).toBeVisible() + await transactionModal.confirm() + await expect(page.getByText(`Your "Start timer" transaction was successful`)).toBeVisible() + await time.sync() + await page.waitForTimeout(1000) + await time.increaseTime({ seconds: 60 * 60 * 24 }) + }) + + await expect( + page.getByText('Your registration has expired. You will need to start the process again.'), + ).toBeVisible() + await expect(page.getByRole('button', { name: 'Restart' })).toBeVisible() + await expect(page.getByTestId('finish-button')).toHaveCount(0) + }) + + test('should be able to register name if the commit transaction does not update', async ({ + page, + login, + accounts, + time, + makePageObject, + consoleListener, + }) => { + test.slow() + + const homePage = makePageObject('HomePage') + const registrationPage = makePageObject('RegistrationPage') + const transactionModal = makePageObject('TransactionModal') + + await time.sync() + await consoleListener.initialize({ + regex: /Event triggered on local development.*register_override_triggered/, + }) + await homePage.goto() + await login.connect() + + const name = `stuck-commit-${Date.now()}.eth` + // should redirect to registration page + await homePage.searchInput.fill(name) + await homePage.searchInput.press('Enter') + await expect(page.getByRole('heading', { name: `Register ${name}` })).toBeVisible() + + await test.step('pricing page', async () => { + await page.getByTestId('payment-choice-ethereum').check() + await registrationPage.primaryNameToggle.uncheck() + await page.getByTestId('next-button').click() + }) + + await test.step('info page', async () => { + await expect(page.getByTestId('next-button')).toHaveText('Begin') + await page.getByTestId('next-button').click() + }) + + await test.step('transaction: commit', async () => { + await expect(page.getByText('Open Wallet')).toBeVisible() + await transactionModal.confirm() + await expect(page.getByText(`Your "Start timer" transaction was successful`)).toBeVisible() + await time.increaseTimeByTimestamp({ seconds: 120 }) + }) + + await test.step('transaction: register', async () => { + await expect(page.getByTestId('finish-button')).toBeVisible({ timeout: 10000 }) + await expect(page.getByTestId('finish-button')).toBeEnabled() + + await page.getByTestId('finish-button').click() + await expect(page.getByText('Open Wallet')).toBeVisible() + await transactionModal.confirm() + + await page.getByTestId('view-name').click() + await expect(page.getByTestId('address-profile-button-eth')).toHaveText( + accounts.getAddress('user', 5), + ) + }) + + await test.step('confirm plausible event was fired once', async () => { + expect(consoleListener.getMessages()).toHaveLength(1) + }) + }) +}) diff --git a/package.json b/package.json index 963a5cc1f..5da3d7e20 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "local:remove:ensjs": "node ./scripts/removeWorkspace.mjs @ensdomains/ensjs && yalc remove @ensdomains/ensjs && pnpm install", "local:add:thorin": "yalc add @ensdomains/thorin --workspace && pnpm install", "local:remove:thorin": "node ./scripts/removeWorkspace.mjs @ensdomains/thorin && yalc remove @ensdomains/thorin && pnpm install", + "local:add:ens-test-env": "yalc add @ensdomains/ens-test-env --workspace && pnpm install", + "local:remove:ens-test-env": "node ./scripts/removeWorkspace.mjs @ensdomains/ens-test-env && yalc remove @ensdomains/ens-test-env && pnpm install", "wrangle": "wrangler pages dev ./out --local --log-level none", "wrangle:dev": "wrangler pages dev ./next", "wrangle:list": "wrangler deployments list", @@ -105,7 +107,7 @@ "devDependencies": { "@cloudflare/workers-types": "^3.14.1", "@ensdomains/buffer": "^0.1.1", - "@ensdomains/ens-test-env": "^0.5.0-beta.1", + "@ensdomains/ens-test-env": "0.5.0-beta.2", "@ensdomains/headless-web3-provider": "^1.0.8", "@ethersproject/abi": "^5.4.0", "@ianvs/prettier-plugin-sort-imports": "^4.1.0", @@ -113,7 +115,7 @@ "@nomiclabs/hardhat-ethers": "npm:hardhat-deploy-ethers@^0.3.0-beta.13", "@openzeppelin/contracts": "^4.7.3", "@openzeppelin/test-helpers": "^0.5.16", - "@playwright/test": "^1.36.2", + "@playwright/test": "^1.48.0", "@testing-library/jest-dom": "^6.4.2", "@testing-library/react": "^14.0.0", "@testing-library/react-hooks": "^8.0.1", @@ -204,4 +206,4 @@ } }, "packageManager": "pnpm@9.3.0" -} +} \ No newline at end of file diff --git a/playwright/fixtures/consoleListener.ts b/playwright/fixtures/consoleListener.ts index e83984a59..916fdfeb1 100644 --- a/playwright/fixtures/consoleListener.ts +++ b/playwright/fixtures/consoleListener.ts @@ -28,6 +28,6 @@ export const createConsoleListener = ({ page }: Dependencies) => { page.off('console', filter) }, print: () => console.log(messages), - getMessages: () => messages, + getMessages: (regex?: RegExp) => regex ? messages.filter((message) => regex.test(message)) : messages, } } diff --git a/playwright/fixtures/time.ts b/playwright/fixtures/time.ts index 447ed26bf..17d03971d 100644 --- a/playwright/fixtures/time.ts +++ b/playwright/fixtures/time.ts @@ -1,7 +1,7 @@ /* eslint-disable import/no-extraneous-dependencies */ import { Page } from '@playwright/test' -import { publicClient } from './contracts/utils/addTestContracts' +import { publicClient, testClient } from './contracts/utils/addTestContracts' export type Time = ReturnType @@ -11,33 +11,50 @@ type Dependencies = { export const createTime = ({ page }: Dependencies) => { return { + // Offset is used to set the browser forward in time. This is useful for testing contract where + // the contract relies on block timestamp, but anvil's block timestamp is unpredictable. sync: async (offset = 0) => { - const browserTime = await page.evaluate(() => Math.floor(Date.now() / 1000)) const blockTime = Number((await publicClient.getBlock()).timestamp) - const browserOffset = (blockTime - browserTime + offset) * 1000 - - console.log(`Browser time: ${new Date(Date.now() + browserOffset)}`) - - await page.addInitScript(`{ - // Prevents Date from being extended multiple times - if (Object.getPrototypeOf(Date).name !== 'Date') { - const __DateNow = Date.now - const browserOffset = ${browserOffset}; - Date = class extends Date { - constructor(...args) { - if (args.length === 0) { - super(__DateNow() + browserOffset); - } else { - super(...args); - } - } - - static now() { - return super.now() + browserOffset; - } - } - } - }`) + const time = new Date((blockTime + offset) * 1000) + console.log(`Browser time: ${time}`) + await page.clock.install({ time }) + }, + logBlockTime: async () => { + const blockTime = Number((await publicClient.getBlock()).timestamp) + console.log(`Block time: ${new Date(blockTime * 1000)}`) }, + logBrowserTime: async () => { + const time = await page.evaluate(() => new Date().toString()) + console.log(`Browser time: ${time}`) + }, + syncFixed: async () => { + const blockTime = Number((await publicClient.getBlock()).timestamp) + const time = new Date(blockTime * 1000) + await page.clock.setFixedTime(time) + console.log(`Fixed Browser time: ${time}`, blockTime) + }, + increaseTime: async ({ seconds }: { seconds: number }) => { + await testClient.increaseTime({ seconds }) + await page.clock.fastForward(seconds * 1000) + }, + increaseTimeByTimestamp: async ({ seconds }: { seconds: number }) => { + const tryIncreaseTime = async () => { + try { + const blockTimestamp = Number((await publicClient.getBlock()).timestamp) + await testClient.setNextBlockTimestamp({ timestamp: BigInt(blockTimestamp + seconds) }) + await testClient.mine({ blocks: 1 }) + return true + } catch { + return false + } + } + + let success = false + let attempts = 0 + while (!success && attempts < 3) { + success = await tryIncreaseTime() + attempts += 1 + } + } } } diff --git a/playwright/pageObjects/subnamePage.ts b/playwright/pageObjects/subnamePage.ts index 09714dd79..2f2d7ad61 100644 --- a/playwright/pageObjects/subnamePage.ts +++ b/playwright/pageObjects/subnamePage.ts @@ -5,12 +5,11 @@ export class SubnamesPage { readonly page: Page readonly getAddSubnameButton: Locator - readonly getDisabledAddSubnameButton: Locator - readonly getAddSubnameInput: Locator - readonly getSubmitSubnameButton: Locator + readonly getSubmitSubnameProfileButton: Locator + readonly addMoreToProfileButton: Locator constructor(page: Page) { this.page = page @@ -18,6 +17,8 @@ export class SubnamesPage { this.getDisabledAddSubnameButton = this.page.getByTestId('add-subname-disabled-button') this.getAddSubnameInput = this.page.getByTestId('add-subname-input') this.getSubmitSubnameButton = this.page.getByTestId('create-subname-next') + this.getSubmitSubnameProfileButton = this.page.getByTestId('create-subname-profile-next') + this.addMoreToProfileButton = this.page.getByTestId('show-add-profile-records-modal-button') } async goto(name: string) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0c128dcb7..1e8c72c86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,8 +178,8 @@ importers: specifier: ^0.1.1 version: 0.1.1 '@ensdomains/ens-test-env': - specifier: ^0.5.0-beta.1 - version: 0.5.0-beta.1 + specifier: 0.5.0-beta.2 + version: 0.5.0-beta.2 '@ensdomains/headless-web3-provider': specifier: ^1.0.8 version: 1.0.8(viem@2.19.4(bufferutil@4.0.8)(typescript@5.4.5)(utf-8-validate@5.0.10)(zod@3.23.8)) @@ -202,8 +202,8 @@ importers: specifier: ^0.5.16 version: 0.5.16(bufferutil@4.0.8)(encoding@0.1.13)(utf-8-validate@5.0.10) '@playwright/test': - specifier: ^1.36.2 - version: 1.44.1 + specifier: ^1.48.0 + version: 1.48.0 '@testing-library/jest-dom': specifier: ^6.4.2 version: 6.4.5(@types/jest@29.5.12)(vitest@2.0.5(@types/node@18.19.33)(jsdom@24.1.0(bufferutil@4.0.8)(canvas@2.11.2(encoding@0.1.13))(utf-8-validate@5.0.10))(terser@5.31.5)) @@ -293,7 +293,7 @@ importers: version: 0.3.9 eslint-plugin-import: specifier: ^2.28.1 - version: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.50.0) + version: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0))(eslint@8.50.0) eslint-plugin-jsx-a11y: specifier: ^6.7.1 version: 6.8.0(eslint@8.50.0) @@ -1544,8 +1544,8 @@ packages: '@ensdomains/ens-contracts@1.2.0-beta.0': resolution: {integrity: sha512-mb/1cPtwhShyaP6fWqDix6GfrJwVWlKgCFxzDKmqNGeFQhBOD/ojYGsy96eJ9UlM/7Tsg7w4RAj7xWrOlHtYIA==} - '@ensdomains/ens-test-env@0.5.0-beta.1': - resolution: {integrity: sha512-ppHKJTRQ5vlWSAflv20cNtlhpLyjn375VD9FeeUgl8BTx3IMPwlLvupGQTK2chDln/FJRyp6+wD41uI4oorM9w==} + '@ensdomains/ens-test-env@0.5.0-beta.2': + resolution: {integrity: sha512-+9LdqGSrwiqjs8hcyKF0K0/eF9VMblJEAjigjHeeQTKNAMyP6KD8jau7VbpvvXEz77D+cQX6z6unDPoQK0l5ng==} engines: {node: '>=18'} hasBin: true @@ -2574,9 +2574,9 @@ packages: resolution: {integrity: sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} - '@playwright/test@1.44.1': - resolution: {integrity: sha512-1hZ4TNvD5z9VuhNJ/walIjvMVvYkZKf71axoF/uiAqpntQJXpG64dlXhoDXE3OczPuTuvjf/M5KWFg5VAVUS3Q==} - engines: {node: '>=16'} + '@playwright/test@1.48.0': + resolution: {integrity: sha512-W5lhqPUVPqhtc/ySvZI5Q8X2ztBOUgZ8LbAFy0JQgrXZs2xaILrUcNO3rQjwbLPfGK13+rZsDa1FpG+tqYkT5w==} + engines: {node: '>=18'} hasBin: true '@polka/url@1.0.0-next.25': @@ -7897,14 +7897,14 @@ packages: pkg-types@1.1.1: resolution: {integrity: sha512-ko14TjmDuQJ14zsotODv7dBlwxKhUKQEhuhmbqo1uCi9BB0Z2alo/wAXg6q1dTR5TyuqYyWhjtfe/Tsh+X28jQ==} - playwright-core@1.44.1: - resolution: {integrity: sha512-wh0JWtYTrhv1+OSsLPgFzGzt67Y7BE/ZS3jEqgGBlp2ppp1ZDj8c+9IARNW4dwf1poq5MgHreEM2KV/GuR4cFA==} - engines: {node: '>=16'} + playwright-core@1.48.0: + resolution: {integrity: sha512-RBvzjM9rdpP7UUFrQzRwR8L/xR4HyC1QXMzGYTbf1vjw25/ya9NRAVnXi/0fvFopjebvyPzsmoK58xxeEOaVvA==} + engines: {node: '>=18'} hasBin: true - playwright@1.44.1: - resolution: {integrity: sha512-qr/0UJ5CFAtloI3avF95Y0L1xQo6r3LQArLIg/z/PoGJ6xa+EwzrwO5lpNr/09STxdHuUoP2mvuELJS+hLdtgg==} - engines: {node: '>=16'} + playwright@1.48.0: + resolution: {integrity: sha512-qPqFaMEHuY/ug8o0uteYJSRfMGFikhUysk8ZvAtfKmUK3kc/6oNl/y3EczF8OFGYIi/Ex2HspMfzYArk6+XQSA==} + engines: {node: '>=18'} hasBin: true pngjs@5.0.0: @@ -11764,7 +11764,7 @@ snapshots: '@openzeppelin/contracts': 4.9.6 dns-packet: 5.6.1 - '@ensdomains/ens-test-env@0.5.0-beta.1': + '@ensdomains/ens-test-env@0.5.0-beta.2': dependencies: '@ethersproject/wallet': 5.7.0 ansi-colors: 4.1.3 @@ -12694,7 +12694,7 @@ snapshots: dependencies: '@motionone/types': 10.17.0 '@motionone/utils': 10.17.0 - tslib: 2.6.2 + tslib: 2.6.3 '@motionone/svelte@10.16.4': dependencies: @@ -12986,9 +12986,9 @@ snapshots: '@pkgr/core@0.1.1': {} - '@playwright/test@1.44.1': + '@playwright/test@1.48.0': dependencies: - playwright: 1.44.1 + playwright: 1.48.0 '@polka/url@1.0.0-next.25': {} @@ -15182,7 +15182,7 @@ snapshots: ast-types@0.13.4: dependencies: - tslib: 2.6.2 + tslib: 2.6.3 ast-types@0.15.2: dependencies: @@ -16637,7 +16637,7 @@ snapshots: dependencies: confusing-browser-globals: 1.0.11 eslint: 8.50.0 - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.50.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0))(eslint@8.50.0) object.assign: 4.1.5 object.entries: 1.1.8 semver: 6.3.1 @@ -16648,13 +16648,13 @@ snapshots: '@typescript-eslint/parser': 6.21.0(eslint@8.50.0)(typescript@5.4.5) eslint: 8.50.0 eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.50.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0))(eslint@8.50.0) eslint-config-airbnb@19.0.4(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint-plugin-jsx-a11y@6.8.0(eslint@8.50.0))(eslint-plugin-react-hooks@4.6.2(eslint@8.50.0))(eslint-plugin-react@7.34.1(eslint@8.50.0))(eslint@8.50.0): dependencies: eslint: 8.50.0 eslint-config-airbnb-base: 15.0.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.50.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0))(eslint@8.50.0) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.50.0) eslint-plugin-react: 7.34.1(eslint@8.50.0) eslint-plugin-react-hooks: 4.6.2(eslint@8.50.0) @@ -16668,8 +16668,8 @@ snapshots: '@typescript-eslint/parser': 6.21.0(eslint@8.50.0)(typescript@5.4.5) eslint: 8.50.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.50.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.50.0) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0))(eslint@8.50.0) eslint-plugin-jsx-a11y: 6.8.0(eslint@8.50.0) eslint-plugin-react: 7.34.1(eslint@8.50.0) eslint-plugin-react-hooks: 4.6.2(eslint@8.50.0) @@ -16691,13 +16691,13 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.50.0): + eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0): dependencies: debug: 4.3.4(supports-color@5.5.0) enhanced-resolve: 5.16.1 eslint: 8.50.0 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.50.0))(eslint@8.50.0) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.50.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0))(eslint@8.50.0) + eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0))(eslint@8.50.0) fast-glob: 3.3.2 get-tsconfig: 4.7.5 is-core-module: 2.13.1 @@ -16708,18 +16708,18 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.50.0))(eslint@8.50.0): + eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0))(eslint@8.50.0): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 6.21.0(eslint@8.50.0)(typescript@5.4.5) eslint: 8.50.0 eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.50.0) + eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1)(eslint@8.50.0): + eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0))(eslint@8.50.0): dependencies: array-includes: 3.1.8 array.prototype.findlastindex: 1.2.5 @@ -16729,7 +16729,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.50.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1)(eslint@8.50.0))(eslint@8.50.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@8.50.0)(typescript@5.4.5))(eslint@8.50.0))(eslint@8.50.0))(eslint@8.50.0) hasown: 2.0.2 is-core-module: 2.13.1 is-glob: 4.0.3 @@ -17986,7 +17986,7 @@ snapshots: i18next-browser-languagedetector@7.1.0: dependencies: - '@babel/runtime': 7.24.6 + '@babel/runtime': 7.25.0 i18next-http-backend@1.4.5(encoding@0.1.13): dependencies: @@ -18000,7 +18000,7 @@ snapshots: i18next@23.11.5: dependencies: - '@babel/runtime': 7.24.6 + '@babel/runtime': 7.25.0 iconv-lite@0.4.24: dependencies: @@ -18802,7 +18802,7 @@ snapshots: lower-case@2.0.2: dependencies: - tslib: 2.6.2 + tslib: 2.6.3 lowercase-keys@2.0.0: {} @@ -19931,11 +19931,11 @@ snapshots: mlly: 1.7.0 pathe: 1.1.2 - playwright-core@1.44.1: {} + playwright-core@1.48.0: {} - playwright@1.44.1: + playwright@1.48.0: dependencies: - playwright-core: 1.44.1 + playwright-core: 1.48.0 optionalDependencies: fsevents: 2.3.2 @@ -20652,7 +20652,7 @@ snapshots: rxjs@7.8.1: dependencies: - tslib: 2.6.2 + tslib: 2.6.3 safe-array-concat@1.1.2: dependencies: diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 0169d77dd..e2c54a1af 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -27,6 +27,7 @@ "remove": "Remove", "sign": "Sign", "reset": "Reset", + "restart": "Restart", "transfer": "Transfer", "tryAgain": "Try Again", "done": "Done", @@ -304,7 +305,8 @@ "title": "Confirm Details", "message": "Double check these details before confirming in your wallet.", "waitingForWallet": "Waiting for Wallet", - "openWallet": "Open Wallet" + "openWallet": "Open Wallet", + "insufficientFunds": "Insufficient funds" }, "sent": { "title": "Transaction Sent", diff --git a/public/locales/en/profile.json b/public/locales/en/profile.json index 1aed22857..49baa8dfe 100644 --- a/public/locales/en/profile.json +++ b/public/locales/en/profile.json @@ -393,6 +393,7 @@ "empty": "No subnames have been added", "noResults": "No results", "noMoreResults": "No more results", + "setProfile": "Set Profile", "addSubname": { "title": "Subnames let you create additional names from your existing name.", "learn": "Learn about subnames", diff --git a/public/locales/en/register.json b/public/locales/en/register.json index 3ef9d18e3..cb260cb11 100644 --- a/public/locales/en/register.json +++ b/public/locales/en/register.json @@ -4,12 +4,12 @@ "heading": "Register {{name}}", "invoice": { "timeRegistration": "{{time}} registration", - "registration": "Registration", "estimatedNetworkFee": "Est. network fee", - "networkFee": "Network fee", + "transactionFees": "Transaction fees", "temporaryPremium": "Temporary premium", "total": "Estimated total", - "totalPaid": "Total paid" + "totalPaid": "Total paid", + "expiry": "Name expires" }, "error": { "nameTooLong": "The name you want to register is too long. Please choose a shorter name." @@ -151,7 +151,6 @@ "complete": { "heading": "Congratulations!", "subheading": "You are now the owner of ", - "description": "Your name was successfully registered. You can now view and manage your name.", "registerAnother": "Register another", "viewName": "View name" }, @@ -184,16 +183,18 @@ "heading": "Almost there", "subheading": { "default": "You will need to complete two transactions to secure your name. The second transaction must be completed within 24 hours of the first.", + "commitSent": "Your first transaction is in progress.
The second transaction must be completed within 24 hours of the first.", "commiting": "This wait prevents others from front running your transaction. You will be prompted to complete a second transaction when the timer is complete.", - "commitComplete": "Your name is not registered until you’ve completed the second transaction. You have {{duration}} remaining to complete it.", - "commitCompleteNoDuration": "Your name is not registered until you’ve completed the second transaction.", + "commitComplete": "Your name is not registered until you’ve completed the second transaction.
You have {{duration}} remaining to complete it.", + "commitCompleteNoDuration": "Your name is not registered until you’ve completed the second transaction.", "commitExpired": "Your registration has expired. You will need to start the process again.", "frontRunning": "When someone sees your transaction and registers the name before your transaction can complete." }, "startTimer": "Start timer", "wait": "Wait", + "completeRegistration": "Complete registration", "transactionFailed": "Transaction Failed", - "transactionProgress": "Transaction in progress" + "transactionProgress": "View transaction in progress" }, "cancelRegistration": { "heading": "You will lose your transaction", diff --git a/public/locales/en/transactionFlow.json b/public/locales/en/transactionFlow.json index 910b848d5..6cead2d48 100644 --- a/public/locales/en/transactionFlow.json +++ b/public/locales/en/transactionFlow.json @@ -217,10 +217,10 @@ } }, "extendNames": { - "title_one": "Extend Name", + "title_one": "Extend {{name}}", "title_other": "Extend {{count}} Names", "ownershipWarning": { - "title_one": "You do not own this name", + "title_one": "You do not own {{name}}", "title_other": "You do not own all these names", "description_one": "Extending this name will extend the current owner's registration length. This will not give you ownership of it.", "description_other": "Extending these names will extend the current owner's registration length. This will not give you ownership if you are not already the owner." @@ -231,7 +231,7 @@ "total": "Estimated total" }, "bannerMsg": "Extending for multiple years will save money on network costs by avoiding yearly transactions.", - "gasLimitError": "Insufficient funds" + "gasLimitError": "Not enough ETH in wallet" }, "transferProfile": { "title": "Transfer Profile", @@ -419,7 +419,8 @@ "extendNames": { "actionValue": "Extend registration", "costValue": "{{value}} + fees", - "warning": "Extending this name will not give you ownership of it" + "warning": "Extending this name will not give you ownership of it", + "newExpiry": "New expiry: {{date}}" }, "deleteSubname": { "warning": "Hello out there" diff --git a/public/locales/nl/register.json b/public/locales/nl/register.json index ad1b25255..ed91038a6 100644 --- a/public/locales/nl/register.json +++ b/public/locales/nl/register.json @@ -6,7 +6,7 @@ "yearRegistration": "{{years}} jaar registratie", "registration": "Registratie", "estimatedNetworkFee": "Geschatte netwerk kosten", - "networkFee": "Netwerk kosten", + "transactionFees": "Netwerk kosten", "temporaryPremium": "Tijdelijke premium", "total": "Geschat totaal", "totalPaid": "Totaal te betalen" diff --git a/public/locales/nl/transactionFlow.json b/public/locales/nl/transactionFlow.json index aae480c3f..0c00c01f7 100644 --- a/public/locales/nl/transactionFlow.json +++ b/public/locales/nl/transactionFlow.json @@ -128,7 +128,7 @@ "customPlaceholder": "Vul handmatige resolver adres hier" }, "extendNames": { - "title_one": "Verleng Naam", + "title_one": "Verleng {{name}}", "title_other": "Verleng {{count}} Namen", "invoice": { "extension": "{{count}} jaar verlenging", diff --git a/public/locales/ru/register.json b/public/locales/ru/register.json index 8f99b31e4..420c224c3 100644 --- a/public/locales/ru/register.json +++ b/public/locales/ru/register.json @@ -6,7 +6,7 @@ "timeRegistration": "{{time}} регистрация", "registration": "Регистрация", "estimatedNetworkFee": "Оценочная комиссия сети", - "networkFee": "Комиссия сети", + "transactionFees": "Комиссия транзакции", "temporaryPremium": "Временная премия", "total": "Оценочная общая сумма", "totalPaid": "Всего оплачено" diff --git a/public/locales/ru/transactionFlow.json b/public/locales/ru/transactionFlow.json index e156466f7..29277422a 100644 --- a/public/locales/ru/transactionFlow.json +++ b/public/locales/ru/transactionFlow.json @@ -216,10 +216,10 @@ } }, "extendNames": { - "title_one": "Продлить имя", + "title_one": "Продлить {{name}}", "title_other": "Продлить {{count}} имена", "ownershipWarning": { - "title_one": "Вы не владеете этим именем", + "title_one": "Вы не владеете {{name}}", "title_other": "Вы не владеете всеми этими именами", "description_one": "Продление этого имени увеличит срок регистрации текущего владельца. Это не даст вам права собственности на него.", "description_other": "Продление этих имен увеличит срок регистрации текущего владельца. Это не даст вам права собственности, если вы уже не являетесь владельцем." diff --git a/public/locales/uk/register.json b/public/locales/uk/register.json index a698279fc..c37111418 100644 --- a/public/locales/uk/register.json +++ b/public/locales/uk/register.json @@ -6,7 +6,7 @@ "timeRegistration": "{{time}} реєстрація", "registration": "Реєстрація", "estimatedNetworkFee": "Оцінка плати за мережу", - "networkFee": "Плата за мережу", + "transactionFees": "Плата за транзакцію", "temporaryPremium": "Тимчасовий преміум", "total": "Орієнтовна сума", "totalPaid": "Всього сплачено" diff --git a/public/locales/uk/transactionFlow.json b/public/locales/uk/transactionFlow.json index 8869dfb0b..fc9c24ca3 100644 --- a/public/locales/uk/transactionFlow.json +++ b/public/locales/uk/transactionFlow.json @@ -216,10 +216,10 @@ } }, "extendNames": { - "title_one": "Продовжити ім'я", + "title_one": "Продовжити {{name}}", "title_other": "Продовжити {{count}} імен", "ownershipWarning": { - "title_one": "Ви не володієте цим ім'ям", + "title_one": "Ви не володієте {{name}}", "title_other": "Ви не володієте всіма цими іменами", "description_one": "Продовження цього імені продовжить реєстрацію поточного власника. Це не надасть вам права власності на нього.", "description_other": "Продовження цих імен продовжить реєстрацію поточного власника. Це не надасть вам права власності, якщо ви ще не є власником." diff --git a/public/locales/zh/register.json b/public/locales/zh/register.json index 1ad0b3a61..97f42763d 100644 --- a/public/locales/zh/register.json +++ b/public/locales/zh/register.json @@ -6,7 +6,7 @@ "timeRegistration": "注册{{time}}", "registration": "注册", "estimatedNetworkFee": "预估网络费", - "networkFee": "网络费", + "transactionFees": "网络费", "temporaryPremium": "临时溢价", "total": "预估总额", "totalPaid": "支付总额" @@ -161,10 +161,7 @@ "等待计时器完成 60 秒计时", "完成第二笔交易来获得该名称" ], - "moonpayItems": [ - "创建或登录现有的 Moonpay 账户", - "使用信用卡或借记卡完成单笔交易" - ], + "moonpayItems": ["创建或登录现有的 Moonpay 账户", "使用信用卡或借记卡完成单笔交易"], "setupProfile": "我希望先创建我的个人资料", "paymentMethod": "支付方式", "notEnoughEth": "钱包中的 ETH 不足", diff --git a/src/components/@atoms/NameDetailItem/NameDetailItem.tsx b/src/components/@atoms/NameDetailItem/NameDetailItem.tsx index 151cacf1e..52c1b2776 100644 --- a/src/components/@atoms/NameDetailItem/NameDetailItem.tsx +++ b/src/components/@atoms/NameDetailItem/NameDetailItem.tsx @@ -162,14 +162,24 @@ export const NameDetailItem = ({ data-testid={`name-item-${name}`} className="name-detail-item" onClick={(e: any) => { - e.preventDefault() - if (name !== INVALID_NAME && !disabled) { + if (mode === 'select' && name !== INVALID_NAME && !disabled) { + e.preventDefault() + e.stopPropagation() handleClick() } }} > - + { + e.preventDefault() + e.stopPropagation() + if (name !== INVALID_NAME && !disabled) { + handleClick() + } + }} + > keyframes` + 0% { background-color: ${theme.colors[color]} } + 14.285% { background-color: ${theme.colors.accentPrimary} } + 42.857% { background-color: ${theme.colors.accentPrimary} } + 57.142% { background-color: ${theme.colors[color]} } +` + +const Dot = styled.div( + ({ theme }) => css` + width: ${theme.space['4']}; + height: ${theme.space['4']}; + border-radius: 50%; + `, +) + +const Container = styled.div<{ $animate: boolean; $color: Colors }>( + ({ theme, $animate, $color }) => css` + width: ${theme.space['22.5']}; + height: ${theme.space['4']}; + display: flex; + justify-content: space-between; + padding: 0 ${theme.space['0.25']}; + + > div { + background-color: ${theme.colors[$color]}; + ${$animate && + css` + animation: ${dotOneAnimation(theme, $color)} 1050ms infinite; + `} + } + + > div:nth-child(2) { + animation-delay: 150ms; + } + > div:nth-child(3) { + animation-delay: 300ms; + } + > div:nth-child(4) { + animation-delay: 450ms; + } + `, +) + +export const StatusDots = ({ animate, color }: { animate: boolean; color: Colors }) => { + return ( + + + + + + + ) +} diff --git a/src/components/@molecules/ProfileEditor/Avatar/AvatarButton.tsx b/src/components/@molecules/ProfileEditor/Avatar/AvatarButton.tsx index 31b270ab0..57993b51a 100644 --- a/src/components/@molecules/ProfileEditor/Avatar/AvatarButton.tsx +++ b/src/components/@molecules/ProfileEditor/Avatar/AvatarButton.tsx @@ -88,6 +88,7 @@ export type AvatarClickType = 'upload' | 'nft' type PickedDropdownProps = Pick, 'isOpen' | 'setIsOpen'> type Props = { + disabledUpload?: boolean validated?: boolean dirty?: boolean error?: boolean @@ -100,6 +101,7 @@ type Props = { const AvatarButton = ({ validated, + disabledUpload, dirty, error, src, @@ -137,11 +139,15 @@ const AvatarButton = ({ color: 'black', onClick: handleSelectOption('nft'), }, - { - label: t('input.profileEditor.tabs.avatar.dropdown.uploadImage'), - color: 'black', - onClick: handleSelectOption('upload'), - }, + ...(disabledUpload + ? [] + : [ + { + label: t('input.profileEditor.tabs.avatar.dropdown.uploadImage'), + color: 'black', + onClick: handleSelectOption('upload'), + }, + ]), ...(validated ? [ { diff --git a/src/components/@molecules/TransactionDialogManager/DisplayItems.tsx b/src/components/@molecules/TransactionDialogManager/DisplayItems.tsx index 20d967577..a33e334ee 100644 --- a/src/components/@molecules/TransactionDialogManager/DisplayItems.tsx +++ b/src/components/@molecules/TransactionDialogManager/DisplayItems.tsx @@ -9,7 +9,7 @@ import { AvatarWithZorb, NameAvatar } from '@app/components/AvatarWithZorb' import { usePrimaryName } from '@app/hooks/ensjs/public/usePrimaryName' import { useBeautifiedName } from '@app/hooks/useBeautifiedName' import { TransactionDisplayItem } from '@app/types' -import { shortenAddress } from '@app/utils/utils' +import { formatExpiry, shortenAddress } from '@app/utils/utils' const Container = styled.div( ({ theme }) => css` @@ -204,6 +204,15 @@ const RecordContainer = styled.div( `, ) +const DurationContainer = styled.div( + ({ theme }) => css` + display: flex; + text-align: right; + flex-direction: column; + gap: ${theme.space[1]}; + `, +) + const RecordsValue = ({ value }: { value: [string, string | undefined][] }) => { return ( @@ -222,6 +231,34 @@ const RecordsValue = ({ value }: { value: [string, string | undefined][] }) => { ) } +const DurationValue = ({ value }: { value: string | undefined }) => { + const { t } = useTranslation('transactionFlow') + + if (!value) return null + + const regex = /(\d+)\s*years?\s*(?:,?\s*(\d+)?\s*months?)?/ + const matches = value.match(regex) ?? [] + + const years = parseInt(matches.at(1) ?? '0') + const months = parseInt(matches.at(2) ?? '0') + + const date = new Date() + + if (years > 0) date.setFullYear(date.getFullYear() + years) + if (months > 0) date.setMonth(date.getMonth() + months) + + return ( + + + {value} + + + {t('transaction.extendNames.newExpiry', { date: formatExpiry(date) })} + + + ) +} + const DisplayItemValue = (props: Omit) => { const { value, type } = props as TransactionDisplayItem if (type === 'address') { @@ -239,6 +276,9 @@ const DisplayItemValue = (props: Omit) => { if (type === 'records') { return } + if (type === 'duration') { + return + } return {value} } diff --git a/src/components/@molecules/TransactionDialogManager/stage/TransactionStageModal.tsx b/src/components/@molecules/TransactionDialogManager/stage/TransactionStageModal.tsx index ddaa5b7c4..8ad38f22a 100644 --- a/src/components/@molecules/TransactionDialogManager/stage/TransactionStageModal.tsx +++ b/src/components/@molecules/TransactionDialogManager/stage/TransactionStageModal.tsx @@ -2,6 +2,7 @@ import { queryOptions } from '@tanstack/react-query' import { Dispatch, useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' +import { match, P } from 'ts-pattern' import { BaseError } from 'viem' import { useClient, useConnectorClient, useSendTransaction } from 'wagmi' @@ -282,6 +283,28 @@ function useCreateSubnameRedirect( }, [shouldTrigger, subdomain]) } +const getPreTransactionError = ({ + stage, + transactionError, + requestError, +}: { + stage: TransactionStage + transactionError: Error | null + requestError: Error | null +}) => { + return match({ stage, err: transactionError || requestError }) + .with({ stage: P.union('complete', 'sent') }, () => null) + .with({ err: P.nullish }, () => null) + .with({ err: P.not(P.instanceOf(BaseError)) }, ({ err }) => ({ + message: 'message' in err! ? err.message : 'transaction.error.unknown', + type: 'unknown' as const, + })) + .otherwise(({ err }) => { + const readableError = getReadableError(err) + return readableError || { message: (err as BaseError).shortMessage, type: 'unknown' as const } + }) +} + export const TransactionStageModal = ({ actionName, currentStep, @@ -356,7 +379,13 @@ export const TransactionStageModal = ({ refetchOnMount: 'always', }) - const { data: request, isLoading: requestLoading, error: requestError } = transactionRequestQuery + const { + data: request_, + isLoading: requestLoading, + error: requestError_, + } = transactionRequestQuery + const request = request_?.data + const requestError = request_?.error || requestError_ const isTransactionRequestCachedData = getIsCachedData(transactionRequestQuery) useInvalidateOnBlock({ @@ -388,6 +417,35 @@ export const TransactionStageModal = ({ displayItems.find((i) => i.label === 'subname' && i.type === 'name')?.value, ) + const stepStatus = useMemo(() => { + if (stage === 'complete') { + return 'completed' + } + return 'inProgress' + }, [stage]) + + const initialErrorOptions = useQueryOptions({ + params: { hash: transaction.hash, status: transactionStatus }, + functionName: 'getTransactionError', + queryDependencyType: 'standard', + queryFn: getTransactionErrorQueryFn, + }) + + const preparedErrorOptions = queryOptions({ + queryKey: initialErrorOptions.queryKey, + queryFn: initialErrorOptions.queryFn, + }) + + const { data: attemptedTransactionError } = useQuery({ + ...preparedErrorOptions, + enabled: !!transaction && !!transaction.hash && transactionStatus === 'failed', + }) + + const preTransactionError = useMemo( + () => getPreTransactionError({ stage, transactionError, requestError }), + [stage, transactionError, requestError], + ) + const FilledDisplayItems = useMemo( () => , [displayItems], @@ -477,6 +535,10 @@ export const TransactionStageModal = ({ ) } + + if (preTransactionError?.type === 'insufficientFunds') + return + return ( ) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [button, name, abilities.data]) + }, [button, name, canSelfExtend]) return ( @@ -249,7 +275,7 @@ export const ProfileSnippet = ({ size={{ min: '24', sm: '32' }} label={name} name={name} - noCache={abilities.data.canEdit} + noCache={canEdit} decoding="sync" /> diff --git a/src/components/pages/profile/[name]/Profile.test.tsx b/src/components/pages/profile/[name]/Profile.test.tsx index 70e75d982..f3c75e40a 100644 --- a/src/components/pages/profile/[name]/Profile.test.tsx +++ b/src/components/pages/profile/[name]/Profile.test.tsx @@ -12,7 +12,9 @@ import ProfileContent, { NameAvailableBanner } from './Profile' vi.mock('@app/hooks/useBasicName') vi.mock('@app/hooks/useProfile') vi.mock('@app/hooks/useNameDetails') - +vi.mock('next/navigation', () => ({ + useSearchParams: () => new URLSearchParams(), +})) vi.mock('@app/hooks/useProtectedRoute', () => ({ useProtectedRoute: vi.fn(), })) diff --git a/src/components/pages/profile/[name]/registration/Registration.tsx b/src/components/pages/profile/[name]/registration/Registration.tsx index f7de74a56..48ce3df24 100644 --- a/src/components/pages/profile/[name]/registration/Registration.tsx +++ b/src/components/pages/profile/[name]/registration/Registration.tsx @@ -153,6 +153,7 @@ const Registration = ({ nameDetails, isLoading }: Props) => { eventName: 'payment_selected', customProperties: { duration: seconds, + durationType, paymentMethod: paymentMethodChoice, estimatedTotal, ethPrice, @@ -340,6 +341,7 @@ const Registration = ({ nameDetails, isLoading }: Props) => { name={normalisedName} beautifiedName={beautifiedName} callback={onComplete} + registrationData={item} isMoonpayFlow={item.isMoonpayFlow} /> )) diff --git a/src/components/pages/profile/[name]/registration/steps/Complete.tsx b/src/components/pages/profile/[name]/registration/steps/Complete.tsx index 0901eef0f..bf647e8d7 100644 --- a/src/components/pages/profile/[name]/registration/steps/Complete.tsx +++ b/src/components/pages/profile/[name]/registration/steps/Complete.tsx @@ -1,5 +1,5 @@ import dynamic from 'next/dynamic' -import React, { Fragment, useEffect, useMemo, useState } from 'react' +import { Fragment, useEffect, useMemo, useState } from 'react' import type ConfettiT from 'react-confetti' import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' @@ -9,12 +9,18 @@ import { useAccount } from 'wagmi' import { tokenise } from '@ensdomains/ensjs/utils' import { Button, mq, Typography } from '@ensdomains/thorin' -import { Invoice } from '@app/components/@atoms/Invoice/Invoice' import MobileFullWidth from '@app/components/@atoms/MobileFullWidth' import NFTTemplate from '@app/components/@molecules/NFTTemplate/NFTTemplate' import { Card } from '@app/components/Card' import useWindowSize from '@app/hooks/useWindowSize' import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { dateFromDateDiff } from '@app/utils/date' +import { isMobileDevice } from '@app/utils/device' +import { secondsToDays } from '@app/utils/time' +import { formatDurationOfDates } from '@app/utils/utils' + +import { RegistrationReducerDataItem } from '../types' +import { Invoice } from './Invoice' const StyledCard = styled(Card)( ({ theme }) => css` @@ -39,10 +45,14 @@ const ButtonContainer = styled.div( ({ theme }) => css` width: ${theme.space.full}; display: flex; - flex-direction: row; + flex-direction: column; align-items: center; justify-content: center; gap: ${theme.space['2']}; + + ${mq.sm.min(css` + flex-direction: row; + `)} `, ) @@ -60,6 +70,20 @@ const NFTContainer = styled.div( `, ) +const InvoiceContainer = styled.div( + ({ theme }) => css` + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: ${theme.space['4']}; + ${mq.sm.min(css` + gap: ${theme.space['6']}; + flex-direction: row; + `)} + `, +) + const TitleContainer = styled.div( ({ theme }) => css` display: flex; @@ -148,6 +172,7 @@ const Confetti = dynamic(() => const useEthInvoice = ( name: string, + seconds: number, isMoonpayFlow: boolean, ): { InvoiceFilled?: React.ReactNode; avatarSrc?: string } => { const { t } = useTranslation('register') @@ -208,16 +233,33 @@ const useEthInvoice = ( const registerNetFee = registerGasUsed * registerGasPrice const totalNetFee = commitNetFee && registerNetFee ? commitNetFee + registerNetFee : 0n + const date = dateFromDateDiff({ + startDate: new Date(), + additionalDays: Math.floor(secondsToDays(seconds)), + }) + return ( ) - }, [isLoading, registrationValue, commitReceipt, registerReceipt, t]) + }, [isLoading, registrationValue, commitReceipt, registerReceipt, t, name, seconds]) if (isMoonpayFlow) return { InvoiceFilled: null, avatarSrc } @@ -228,13 +270,14 @@ type Props = { name: string beautifiedName: string callback: (toProfile: boolean) => void + registrationData: RegistrationReducerDataItem isMoonpayFlow: boolean } -const Complete = ({ name, beautifiedName, callback, isMoonpayFlow }: Props) => { +const Complete = ({ name, beautifiedName, callback, isMoonpayFlow, registrationData }: Props) => { const { t } = useTranslation('register') const { width, height } = useWindowSize() - const { InvoiceFilled, avatarSrc } = useEthInvoice(name, isMoonpayFlow) + const { InvoiceFilled, avatarSrc } = useEthInvoice(name, registrationData.seconds, isMoonpayFlow) const nameWithColourEmojis = useMemo(() => { const data = tokenise(beautifiedName) @@ -275,9 +318,6 @@ const Complete = ({ name, beautifiedName, callback, isMoonpayFlow }: Props) => { gravity={0.25} initialVelocityY={20} /> - - - {t('steps.complete.heading')} @@ -285,8 +325,12 @@ const Complete = ({ name, beautifiedName, callback, isMoonpayFlow }: Props) => { {nameWithColourEmojis} - {t('steps.complete.description')} - {InvoiceFilled} + + + + + {InvoiceFilled} + @@ -103,23 +194,51 @@ const Transactions = ({ registrationData, name, callback, onStart }: Props) => { const keySuffix = `${name}-${address}` const commitKey = `commit-${keySuffix}` const registerKey = `register-${keySuffix}` - const { getLatestTransaction, createTransactionFlow, resumeTransactionFlow, cleanupFlow } = - useTransactionFlow() + const { + getSelectedKey, + getLatestTransaction, + createTransactionFlow, + resumeTransactionFlow, + cleanupFlow, + stopCurrentFlow, + } = useTransactionFlow() const commitTx = getLatestTransaction(commitKey) const registerTx = getLatestTransaction(registerKey) const [resetOpen, setResetOpen] = useState(false) - const commitTimestamp = commitTx?.stage === 'complete' ? commitTx?.finaliseTime : undefined - const [commitComplete, setCommitComplete] = useState( - !!commitTimestamp && commitTimestamp + 60000 < Date.now(), - ) - const registrationParams = useRegistrationParams({ name, owner: address!, registrationData, }) + const { isSuccess: isSimulateRegistrationSuccess } = useSimulateRegistration({ + registrationParams, + query: { + enabled: commitTx?.stage === 'sent', + retry: commitTx?.stage === 'sent', + retryDelay: 5_000, + }, + }) + const canRegisterOverride = isSimulateRegistrationSuccess && commitTx?.stage !== 'complete' + + useEffect(() => { + if (canRegisterOverride) { + trackEvent({ eventName: 'register_override_triggered' }) + if (getSelectedKey() === commitKey) stopCurrentFlow() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [canRegisterOverride]) + + const commitTimestamp = match({ commitStage: commitTx?.stage, canRegisterOverride }) + .with({ canRegisterOverride: true }, () => Date.now() - 70_000) + .with({ commitStage: 'complete' }, () => commitTx?.finaliseTime) + .otherwise(() => undefined) + + const [commitComplete, setCommitComplete] = useState( + !!commitTimestamp && commitTimestamp + 60000 < Date.now(), + ) + const commitCouldBeFound = !commitTx?.stage || commitTx.stage === 'confirm' || commitTx.stage === 'failed' useExistingCommitment({ @@ -128,6 +247,23 @@ const Transactions = ({ registrationData, name, callback, onStart }: Props) => { commitKey, }) + const transactionState = match({ + commitComplete, + canRegisterOverride, + commitStage: commitTx?.stage, + registerStage: registerTx?.stage, + }) + .with(PATTERNS.RegistrationComplete, () => 'registrationComplete' as const) + .with(PATTERNS.RegistrationFailed, () => 'registrationFailed' as const) + .with(PATTERNS.RegistrationSent, () => 'registrationSent' as const) + .with(PATTERNS.RegistrationOverriden, () => 'registrationOverriden' as const) + .with(PATTERNS.RegistrationReady, () => 'registrationReady' as const) + .with(PATTERNS.CommitFailed, () => 'commitFailed' as const) + .with(PATTERNS.CommitComplete, () => 'commitComplete' as const) + .with(PATTERNS.CommitSent, () => 'commitSent' as const) + .with(PATTERNS.CommitReady, () => 'commitReady' as const) + .exhaustive() + const makeCommitNameFlow = useCallback(() => { onStart() createTransactionFlow(commitKey, { @@ -155,8 +291,6 @@ const Transactions = ({ registrationData, name, callback, onStart }: Props) => { const showRegisterTransaction = () => { resumeTransactionFlow(registerKey) - - trackEvent({ eventName: 'register_started' }) } const resetTransactions = () => { @@ -226,16 +360,63 @@ const Transactions = ({ registrationData, name, callback, onStart }: Props) => { /> {t('steps.transactions.heading')} - setCommitComplete(true)} - /> - - {match([commitTx, commitComplete, duration]) - .with([{ stage: 'complete' }, false, P._], () => ( + + true) + .otherwise(() => false)} + startTimestamp={commitTimestamp} + size="large" + callback={() => setCommitComplete(true)} + /> + true) + .with( + 'registrationReady', + () => duration !== null, + () => true, + ) + .otherwise(() => false)} + > + true) + .otherwise(() => false)} + color={match(transactionState) + .with( + 'commitReady', + 'commitSent', + 'commitComplete', + 'commitFailed', + () => 'border' as const, + ) + .otherwise(() => 'blueLight' as const)} + /> + + + + {match(transactionState) + .with('registrationComplete', () => '') + .with('registrationOverriden', () => ( + + )) + .with('registrationReady', 'registrationSent', 'registrationFailed', () => + match(duration) + .with(P.not(P.nullish), () => ( + + )) + .with(null, () => t('steps.transactions.subheading.commitExpired')) + .otherwise(() => ( + + )), + ) + .with('commitComplete', () => ( { }} /> )) - .with([{ stage: 'complete' }, true, null], () => - t('steps.transactions.subheading.commitExpired'), - ) - .with([{ stage: 'complete' }, true, P.not(P.nullish)], ([, , d]) => - t('steps.transactions.subheading.commitComplete', { duration: d }), - ) - .with([{ stage: 'complete' }, true, P._], () => - t('steps.transactions.subheading.commitCompleteNoDuration'), - ) - .otherwise(() => t('steps.transactions.subheading.default'))} + .with('commitSent', () => ( + + )) + .with('commitReady', 'commitFailed', () => t('steps.transactions.subheading.default')) + .exhaustive()} - {match([commitComplete, registerTx, commitTx]) - .with([true, { stage: 'failed' }, P._], () => ( + {match(transactionState) + .with('registrationComplete', () => null) + .with('registrationFailed', () => ( <> {ResetBackButton} { /> )) - .with([true, { stage: 'sent' }, P._], () => ( + .with('registrationSent', () => ( )) - .with([true, P._, P._], () => ( + .with( + 'registrationReady', + () => duration === null, + () => ( +
+ +
+ ), + ) + .with('registrationReady', 'registrationOverriden', () => ( <> {ResetBackButton} @@ -284,12 +472,12 @@ const Transactions = ({ registrationData, name, callback, onStart }: Props) => { data-testid="finish-button" onClick={!registerTx ? makeRegisterNameFlow : showRegisterTransaction} > - {t('action.finish', { ns: 'common' })} + {t('steps.transactions.completeRegistration')} )) - .with([false, P._, { stage: 'failed' }], () => ( + .with('commitFailed', () => ( <> {NormalBackButton} { /> )) - .with([false, P._, { stage: 'sent' }], () => ( - - )) - .with([false, P._, { stage: 'complete' }], () => ( + .with('commitComplete', () => ( <> {ResetBackButton} @@ -314,7 +496,13 @@ const Transactions = ({ registrationData, name, callback, onStart }: Props) => { )) - .otherwise(() => ( + .with('commitSent', () => ( + + )) + .with('commitReady', () => ( <> - ))} + )) + .exhaustive()}
) diff --git a/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/ExpirySection/ExpirySection.tsx b/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/ExpirySection/ExpirySection.tsx index 47f366c94..21b3592d5 100644 --- a/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/ExpirySection/ExpirySection.tsx +++ b/src/components/pages/profile/[name]/tabs/OwnershipTab/sections/ExpirySection/ExpirySection.tsx @@ -1,4 +1,3 @@ -import { CalendarEvent, google, ics, office365, outlook, yahoo } from 'calendar-link' import { useState } from 'react' import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' @@ -6,6 +5,7 @@ import styled, { css } from 'styled-components' import { Button, Card, Dropdown, mq } from '@ensdomains/thorin' import { cacheableComponentStyles } from '@app/components/@atoms/CacheableComponent' +import { useCalendarOptions } from '@app/hooks/useCalendarOptions' import { useNameDetails } from '@app/hooks/useNameDetails' import { EarnifiDialog } from '../../../MoreTab/Miscellaneous/EarnifiDialog' @@ -13,41 +13,6 @@ import { ExpiryPanel } from './components/ExpiryPanel' import { useExpiryActions } from './hooks/useExpiryActions' import { useExpiryDetails } from './hooks/useExpiryDetails' -const calendarOptions = [ - { - value: 'google', - label: 'tabs.more.misc.reminderOptions.google', - function: google, - }, - { - value: 'outlook', - label: 'tabs.more.misc.reminderOptions.outlook', - function: outlook, - }, - { - value: 'office365', - label: 'tabs.more.misc.reminderOptions.office365', - function: office365, - }, - { - value: 'yahoo', - label: 'tabs.more.misc.reminderOptions.yahoo', - function: yahoo, - }, - { - value: 'ics', - label: 'tabs.more.misc.reminderOptions.ical', - function: ics, - }, -] - -const makeEvent = (name: string, expiryDate: Date): CalendarEvent => ({ - title: `Renew ${name}`, - start: expiryDate, - duration: [10, 'minute'], - url: window.location.href, -}) - const Header = styled.div(({ theme }) => [ css` padding: ${theme.space['4']}; @@ -124,6 +89,7 @@ export const ExpirySection = ({ name, details }: Props) => { ownerData: details.ownerData, wrapperData: details.wrapperData, }) + const { options: calendarOptions, makeEvent } = useCalendarOptions(`Renew ${name}`) const [showEarnifiDialog, setShowEarnifiDialog] = useState(false) @@ -171,7 +137,7 @@ export const ExpirySection = ({ name, details }: Props) => { label: t(option.label, { ns: 'profile' }), onClick: () => window.open( - option.function(makeEvent(name, action.expiryDate)), + option.function(makeEvent(action.expiryDate)), '_blank', ), })), diff --git a/src/hooks/pages/profile/[name]/profile/useProfileActions/useProfileActions.ts b/src/hooks/pages/profile/[name]/profile/useProfileActions/useProfileActions.ts index a998d9f8b..9a6e638b3 100644 --- a/src/hooks/pages/profile/[name]/profile/useProfileActions/useProfileActions.ts +++ b/src/hooks/pages/profile/[name]/profile/useProfileActions/useProfileActions.ts @@ -134,6 +134,7 @@ export const useProfileActions = ({ name, enabled: enabled_ = true }: Props) => const showUnknownLabelsInput = usePreparedDataInput('UnknownLabels') const showProfileEditorInput = usePreparedDataInput('ProfileEditor') + const showProfileReclaimInput = usePreparedDataInput('ProfileReclaim') const showDeleteEmancipatedSubnameWarningInput = usePreparedDataInput( 'DeleteEmancipatedSubnameWarning', ) @@ -309,15 +310,11 @@ export const useProfileActions = ({ name, enabled: enabled_ = true }: Props) => fullMobileWidth: true, loading: hasGraphErrorLoading, onClick: () => { - createTransactionFlow(`reclaim-${name}`, { - transactions: [ - createTransactionItem('createSubname', { - contract: 'nameWrapper', - label, - parent, - }), - ], - }) + showProfileReclaimInput( + `reclaim-profile-${name}`, + { name, label, parent }, + { disableBackgroundClick: true }, + ) }, }) } @@ -346,8 +343,9 @@ export const useProfileActions = ({ name, enabled: enabled_ = true }: Props) => hasGraphErrorLoading, ownerData?.owner, ownerData?.registrant, - showUnknownLabelsInput, createTransactionFlow, + showUnknownLabelsInput, + showProfileReclaimInput, showProfileEditorInput, showDeleteEmancipatedSubnameWarningInput, showDeleteSubnameNotParentWarningInput, diff --git a/src/hooks/registration/useSimulateRegistration.ts b/src/hooks/registration/useSimulateRegistration.ts new file mode 100644 index 000000000..962e311ea --- /dev/null +++ b/src/hooks/registration/useSimulateRegistration.ts @@ -0,0 +1,37 @@ +import { usePublicClient, useSimulateContract, UseSimulateContractParameters } from 'wagmi' + +import { ethRegistrarControllerRegisterSnippet } from '@ensdomains/ensjs/contracts' +import { makeRegistrationTuple, RegistrationParameters } from '@ensdomains/ensjs/utils' + +import { calculateValueWithBuffer } from '@app/utils/utils' + +import { usePrice } from '../ensjs/public/usePrice' + +type UseSimulateRegistrationParameters = Pick & { + registrationParams: RegistrationParameters +} + +export const useSimulateRegistration = ({ + registrationParams, + query, +}: UseSimulateRegistrationParameters) => { + const client = usePublicClient() + + const { data: price } = usePrice({ + nameOrNames: registrationParams.name, + duration: registrationParams.duration, + }) + + const base = price?.base ?? 0n + const premium = price?.premium ?? 0n + const value = base + premium + + return useSimulateContract({ + abi: ethRegistrarControllerRegisterSnippet, + address: client.chain.contracts.ensEthRegistrarController.address, + functionName: 'register', + args: makeRegistrationTuple(registrationParams), + value: calculateValueWithBuffer(value), + query, + }) +} diff --git a/src/hooks/useCalendarOptions.ts b/src/hooks/useCalendarOptions.ts new file mode 100644 index 000000000..97cfb963f --- /dev/null +++ b/src/hooks/useCalendarOptions.ts @@ -0,0 +1,43 @@ +import { CalendarEvent, google, ics, office365, outlook, yahoo } from 'calendar-link' + +const calendarOptions = [ + { + value: 'google', + label: 'tabs.more.misc.reminderOptions.google', + function: google, + }, + { + value: 'outlook', + label: 'tabs.more.misc.reminderOptions.outlook', + function: outlook, + }, + { + value: 'office365', + label: 'tabs.more.misc.reminderOptions.office365', + function: office365, + }, + { + value: 'yahoo', + label: 'tabs.more.misc.reminderOptions.yahoo', + function: yahoo, + }, + { + value: 'ics', + label: 'tabs.more.misc.reminderOptions.ical', + function: ics, + }, +] + +export function useCalendarOptions(title: string) { + const makeEvent = (expiryDate: Date): CalendarEvent => ({ + title, + start: expiryDate, + duration: [10, 'minute'], + url: window.location.href, + }) + + return { + makeEvent, + options: calendarOptions, + } +} diff --git a/src/hooks/useEventTracker.ts b/src/hooks/useEventTracker.ts index 859b2fc4f..0156ae362 100644 --- a/src/hooks/useEventTracker.ts +++ b/src/hooks/useEventTracker.ts @@ -17,6 +17,7 @@ type PaymentEvent = { customProperties: { ethPrice: bigint duration: number + durationType: 'date' | 'years' estimatedTotal: bigint paymentMethod: PaymentMethod | '' } @@ -42,6 +43,7 @@ type DefaultEvent = { | 'commit_wallet_opened_dns' | 'register_started_dns' | 'register_wallet_opened_dns' + | 'register_override_triggered' customProperties?: never } @@ -79,6 +81,7 @@ export const useEventTracker = () => { 'commit_wallet_opened_dns', 'register_started_dns', 'register_wallet_opened_dns', + 'register_override_triggered', ), }, ({ eventName }) => sendTrackEvent(eventName, chain), diff --git a/src/hooks/useProfileEditorForm.tsx b/src/hooks/useProfileEditorForm.tsx index 69e77dc6a..18c72b518 100644 --- a/src/hooks/useProfileEditorForm.tsx +++ b/src/hooks/useProfileEditorForm.tsx @@ -172,7 +172,7 @@ export const useProfileEditorForm = (existingRecords: ProfileRecord[]) => { SUPPORTED_AVUP_ENDPOINTS.some((endpoint) => avatar?.startsWith(endpoint)) ) if (avatarIsChanged) { - setValue('avatar', avatar, { shouldDirty: true, shouldTouch: true }) + setValue('avatar', avatar || '', { shouldDirty: true, shouldTouch: true }) } } @@ -217,6 +217,7 @@ export const useProfileEditorForm = (existingRecords: ProfileRecord[]) => { const getAvatar = () => getValues('avatar') return { + isDirty: formState.isDirty, records, register, trigger, diff --git a/src/hooks/useQueryOptions.ts b/src/hooks/useQueryOptions.ts index 8a42df9e4..58d630a25 100644 --- a/src/hooks/useQueryOptions.ts +++ b/src/hooks/useQueryOptions.ts @@ -4,6 +4,13 @@ import { useAccount, useChainId, useConfig } from 'wagmi' import { SupportedChain } from '@app/constants/chains' import { ConfigWithEns, CreateQueryKey, QueryDependencyType } from '@app/types' +/* + Query types: + - independent: Query that is not dependent on chain data, specifically chainId and address + - graph: Query that uses the graph + - standard: Query that depends on chain data directly +*/ + export type QueryKeyConfig< TParams extends {}, TFunctionName extends string, diff --git a/src/pages/legacyfavourites.tsx b/src/pages/legacyfavourites.tsx index b14460ab3..aac73c9c0 100644 --- a/src/pages/legacyfavourites.tsx +++ b/src/pages/legacyfavourites.tsx @@ -70,7 +70,7 @@ export default function Page() { name, network: chainId, hasOtherItems: false, - expiryDate: { date: expiry, value: expiry.getTime() }, + expiryDate: { date: expiry, value: expiry?.getTime() }, }} /> ))} diff --git a/src/transaction-flow/TransactionFlowProvider.tsx b/src/transaction-flow/TransactionFlowProvider.tsx index 301ecc0f6..ef3003a73 100644 --- a/src/transaction-flow/TransactionFlowProvider.tsx +++ b/src/transaction-flow/TransactionFlowProvider.tsx @@ -35,6 +35,7 @@ type ProviderValue = { usePreparedDataInput: UsePreparedDataInput createTransactionFlow: CreateTransactionFlow resumeTransactionFlow: (key: string) => void + getSelectedKey: () => string | null getTransactionIndex: (key: string) => number getResumable: (key: string) => boolean getTransactionFlowStage: ( @@ -50,6 +51,7 @@ const TransactionContext = React.createContext({ usePreparedDataInput: () => () => {}, createTransactionFlow: () => {}, resumeTransactionFlow: () => {}, + getSelectedKey: () => null, getTransactionIndex: () => 0, getResumable: () => false, getTransactionFlowStage: () => 'undefined', @@ -83,6 +85,8 @@ export const TransactionFlowProvider = ({ children }: { children: ReactNode }) = }, ) + const getSelectedKey = useCallback(() => state.selectedKey, [state.selectedKey]) + const getTransactionIndex = useCallback( (key: string) => state.items[key]?.currentTransaction || 0, [state.items], @@ -187,6 +191,7 @@ export const TransactionFlowProvider = ({ children }: { children: ReactNode }) = payload: flow, })) as CreateTransactionFlow, resumeTransactionFlow, + getSelectedKey, getTransactionIndex, getTransaction, getResumable, @@ -200,6 +205,7 @@ export const TransactionFlowProvider = ({ children }: { children: ReactNode }) = dispatch, resumeTransactionFlow, getResumable, + getSelectedKey, getTransactionIndex, getLatestTransaction, getTransactionFlowStage, diff --git a/src/transaction-flow/input/CreateSubname-flow.tsx b/src/transaction-flow/input/CreateSubname-flow.tsx index a025c8aed..bcfca0e5c 100644 --- a/src/transaction-flow/input/CreateSubname-flow.tsx +++ b/src/transaction-flow/input/CreateSubname-flow.tsx @@ -1,11 +1,23 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' +import { match } from 'ts-pattern' import { validateName } from '@ensdomains/ensjs/utils' -import { Button, Dialog, Input } from '@ensdomains/thorin' +import { Button, Dialog, Input, mq, PlusSVG } from '@ensdomains/thorin' +import { AvatarClickType } from '@app/components/@molecules/ProfileEditor/Avatar/AvatarButton' +import { AvatarViewManager } from '@app/components/@molecules/ProfileEditor/Avatar/AvatarViewManager' +import { AddProfileRecordView } from '@app/components/pages/profile/[name]/registration/steps/Profile/AddProfileRecordView' +import { CustomProfileRecordInput } from '@app/components/pages/profile/[name]/registration/steps/Profile/CustomProfileRecordInput' +import { ProfileRecordInput } from '@app/components/pages/profile/[name]/registration/steps/Profile/ProfileRecordInput' +import { ProfileRecordTextarea } from '@app/components/pages/profile/[name]/registration/steps/Profile/ProfileRecordTextarea' +import { profileEditorFormToProfileRecords } from '@app/components/pages/profile/[name]/registration/steps/Profile/profileRecordUtils' +import { WrappedAvatarButton } from '@app/components/pages/profile/[name]/registration/steps/Profile/WrappedAvatarButton' +import { ProfileRecord } from '@app/constants/profileRecordOptions' +import { useContractAddress } from '@app/hooks/chain/useContractAddress' import useDebouncedCallback from '@app/hooks/useDebouncedCallback' +import { useProfileEditorForm } from '@app/hooks/useProfileEditorForm' import { useValidateSubnameLabel } from '../../hooks/useValidateSubnameLabel' import { createTransactionItem } from '../transaction' @@ -16,10 +28,29 @@ type Data = { isWrapped: boolean } +type ModalOption = AvatarClickType | 'editor' | 'profile-editor' | 'add-record' + export type Props = { data: Data } & TransactionDialogPassthrough +const ButtonContainer = styled.div( + ({ theme }) => css` + display: flex; + justify-content: center; + padding-bottom: ${theme.space['4']}; + `, +) + +const ButtonWrapper = styled.div(({ theme }) => [ + css` + width: ${theme.space.full}; + `, + mq.xs.min(css` + width: max-content; + `), +]) + const ParentLabel = styled.div( ({ theme }) => css` overflow: hidden; @@ -29,34 +60,112 @@ const ParentLabel = styled.div( `, ) -const CreateSubname = ({ data: { parent, isWrapped }, dispatch, onDismiss }: Props) => { - const { t } = useTranslation('profile') - +const useSubnameLabel = (data: Data) => { const [label, setLabel] = useState('') const [_label, _setLabel] = useState('') - const debouncedSetLabel = useDebouncedCallback(setLabel, 500) - const { valid, error, expiryLabel, isLoading: isUseValidateSubnameLabelLoading, - } = useValidateSubnameLabel({ name: parent, label, isWrapped }) + } = useValidateSubnameLabel({ + name: data.parent, + label, + isWrapped: data.isWrapped, + }) + + const debouncedSetLabel = useDebouncedCallback(setLabel, 500) + + const handleChange = (e: React.ChangeEvent) => { + try { + const normalised = validateName(e.target.value) + _setLabel(normalised) + debouncedSetLabel(normalised) + } catch { + _setLabel(e.target.value) + debouncedSetLabel(e.target.value) + } + } const isLabelsInsync = label === _label const isLoading = isUseValidateSubnameLabelLoading || !isLabelsInsync + return { + valid, + error, + expiryLabel, + isLoading, + label: _label, + debouncedLabel: label, + setLabel: handleChange, + } +} + +const CreateSubname = ({ data: { parent, isWrapped }, dispatch, onDismiss }: Props) => { + const { t } = useTranslation('profile') + const { t: registerT } = useTranslation('register') + + const [view, setView] = useState('editor') + + const { valid, error, expiryLabel, isLoading, debouncedLabel, label, setLabel } = useSubnameLabel( + { + parent, + isWrapped, + }, + ) + + const name = `${debouncedLabel}.${parent}` + + const defaultResolverAddress = useContractAddress({ contract: 'ensPublicResolver' }) + + const { + isDirty, + records, + register, + trigger, + control, + addRecords, + getValues, + removeRecordAtIndex, + setAvatar, + labelForRecord, + secondaryLabelForRecord, + placeholderForRecord, + validatorForRecord, + errorForRecordAtIndex, + isDirtyForRecordAtIndex, + } = useProfileEditorForm([ + { + key: 'eth', + value: '', + type: 'text', + group: 'address', + }, + ]) + const handleSubmit = () => { + const payload = [ + createTransactionItem('createSubname', { + contract: isWrapped ? 'nameWrapper' : 'registry', + label: debouncedLabel, + parent, + }), + ] + + if (isDirty && records.length) { + payload.push( + createTransactionItem('updateProfileRecords', { + name, + records: profileEditorFormToProfileRecords(getValues()), + resolverAddress: defaultResolverAddress, + clearRecords: false, + }) as never, + ) + } dispatch({ name: 'setTransactions', - payload: [ - createTransactionItem('createSubname', { - contract: isWrapped ? 'nameWrapper' : 'registry', - label, - parent, - }), - ], + payload, }) dispatch({ name: 'setFlowStage', @@ -64,48 +173,171 @@ const CreateSubname = ({ data: { parent, isWrapped }, dispatch, onDismiss }: Pro }) } + const [avatarFile, setAvatarFile] = useState() + const [avatarSrc, setAvatarSrc] = useState() + + const handleDeleteRecord = (_: ProfileRecord, index: number) => { + removeRecordAtIndex(index) + process.nextTick(() => trigger()) + } + return ( <> - - - .{parent}} - value={_label} - onChange={(e) => { - try { - const normalised = validateName(e.target.value) - _setLabel(normalised) - debouncedSetLabel(normalised) - } catch { - _setLabel(e.target.value) - debouncedSetLabel(e.target.value) - } - }} - error={ - error - ? t(`details.tabs.subnames.addSubname.dialog.error.${error}`, { date: expiryLabel }) - : undefined - } - /> - - - {t('action.cancel', { ns: 'common' })} - - } - trailing={ - - } - /> + {match(view) + .with('editor', () => ( + <> + + + .{parent}} + value={label} + onChange={setLabel} + error={ + error + ? t(`details.tabs.subnames.addSubname.dialog.error.${error}`, { + date: expiryLabel, + }) + : undefined + } + /> + + + {t('action.cancel', { ns: 'common' })} + + } + trailing={ + + } + /> + + )) + .with('profile-editor', () => ( + <> + + + setAvatar(avatar)} + onAvatarFileChange={(file) => setAvatarFile(file)} + onAvatarSrcChange={(src) => setAvatarSrc(src)} + /> + {records.map((field, index) => + match(field) + .with({ group: 'custom' }, () => ( + handleDeleteRecord(field, index)} + /> + )) + .with({ key: 'description' }, () => ( + handleDeleteRecord(field, index)} + {...register(`records.${index}.value`, { + validate: validatorForRecord(field), + })} + /> + )) + .otherwise(() => ( + handleDeleteRecord(field, index)} + {...register(`records.${index}.value`, { + validate: validatorForRecord(field), + })} + /> + )), + )} + + + + + + + setView('editor')}> + {t('action.cancel', { ns: 'common' })} + + } + trailing={ + + } + /> + + )) + .with('add-record', () => ( + { + addRecords(newRecords) + setView('profile-editor') + }} + onClose={() => setView('profile-editor')} + /> + )) + .with('upload', 'nft', (type) => ( + setView('profile-editor')} + type={type} + handleSubmit={(_, uri, display) => { + setAvatar(uri) + setAvatarSrc(display) + setView('profile-editor') + trigger() + }} + /> + )) + .exhaustive()} ) } diff --git a/src/transaction-flow/input/ExtendNames/ExtendNames-flow.test.tsx b/src/transaction-flow/input/ExtendNames/ExtendNames-flow.test.tsx index 606bdbe4a..986d1aa23 100644 --- a/src/transaction-flow/input/ExtendNames/ExtendNames-flow.test.tsx +++ b/src/transaction-flow/input/ExtendNames/ExtendNames-flow.test.tsx @@ -1,12 +1,12 @@ -import { mockFunction, render, screen, userEvent, waitFor } from '@app/test-utils' +import { mockFunction, render, screen } from '@app/test-utils' import { describe, expect, it, vi } from 'vitest' import { useEstimateGasWithStateOverride } from '@app/hooks/chain/useEstimateGasWithStateOverride' import { usePrice } from '@app/hooks/ensjs/public/usePrice' -import ExtendNames from './ExtendNames-flow' import { makeMockIntersectionObserver } from '../../../../test/mock/makeMockIntersectionObserver' +import ExtendNames from './ExtendNames-flow' vi.mock('@app/hooks/chain/useEstimateGasWithStateOverride') vi.mock('@app/hooks/ensjs/public/usePrice') @@ -28,18 +28,6 @@ vi.mock('@app/components/@atoms/Invoice/Invoice', async () => { Invoice: vi.fn(() =>
Invoice
), } }) -vi.mock( - '@app/components/@atoms/RegistrationTimeComparisonBanner/RegistrationTimeComparisonBanner', - async () => { - const originalModule = await vi.importActual( - '@app/components/@atoms/RegistrationTimeComparisonBanner/RegistrationTimeComparisonBanner', - ) - return { - ...originalModule, - RegistrationTimeComparisonBanner: vi.fn(() =>
RegistrationTimeComparisonBanner
), - } - }, -) makeMockIntersectionObserver() @@ -64,82 +52,6 @@ describe('Extendnames', () => { />, ) }) - it('should go directly to registration if isSelf is true and names.length is 1', () => { - render( - null, - onDismiss: () => null, - }} - />, - ) - expect(screen.getByText('RegistrationTimeComparisonBanner')).toBeVisible() - }) - it('should show warning message before registration if isSelf is false and names.length is 1', async () => { - render( - null, - onDismiss: () => null, - }} - />, - ) - expect(screen.getByText('input.extendNames.ownershipWarning.description.1')).toBeVisible() - await userEvent.click(screen.getByRole('button', { name: 'action.understand' })) - await waitFor(() => expect(screen.getByText('RegistrationTimeComparisonBanner')).toBeVisible()) - }) - it('should show a list of names before registration if isSelf is true and names.length is greater than 1', async () => { - render( - null, - onDismiss: () => null, - }} - />, - ) - expect(screen.getByTestId('extend-names-names-list')).toBeVisible() - await userEvent.click(screen.getByRole('button', { name: 'action.next' })) - await waitFor(() => expect(screen.getByText('RegistrationTimeComparisonBanner')).toBeVisible()) - }) - it('should show a warning then a list of names before registration if isSelf is false and names.length is greater than 1', async () => { - render( - null, - onDismiss: () => null, - }} - />, - ) - expect(screen.getByText('input.extendNames.ownershipWarning.description.2')).toBeVisible() - await userEvent.click(screen.getByRole('button', { name: 'action.understand' })) - expect(screen.getByTestId('extend-names-names-list')).toBeVisible() - await userEvent.click(screen.getByRole('button', { name: 'action.next' })) - await waitFor(() => expect(screen.getByText('RegistrationTimeComparisonBanner')).toBeVisible()) - }) - it('should have RegistrationTimeComparisonBanner greyed out if gas limit estimation is still loading', () => { - mockUseEstimateGasWithStateOverride.mockReturnValueOnce({ - data: { gasEstimate: 21000n, gasCost: 100n }, - gasPrice: 100n, - error: null, - isLoading: true, - }) - render( - null, - onDismiss: () => null, - }} - />, - ) - const optionBar = screen.getByText('RegistrationTimeComparisonBanner') - const { parentElement } = optionBar - expect(parentElement).toHaveStyle('opacity: 0.5') - }) it('should have Invoice greyed out if gas limit estimation is still loading', () => { mockUseEstimateGasWithStateOverride.mockReturnValueOnce({ data: { gasEstimate: 21000n, gasCost: 100n }, diff --git a/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx b/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx index 723d375d6..50343fdf2 100644 --- a/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx +++ b/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx @@ -12,7 +12,6 @@ import { CacheableComponent } from '@app/components/@atoms/CacheableComponent' import { makeCurrencyDisplay } from '@app/components/@atoms/CurrencyText/CurrencyText' import { Invoice, InvoiceItem } from '@app/components/@atoms/Invoice/Invoice' import { PlusMinusControl } from '@app/components/@atoms/PlusMinusControl/PlusMinusControl' -import { RegistrationTimeComparisonBanner } from '@app/components/@atoms/RegistrationTimeComparisonBanner/RegistrationTimeComparisonBanner' import { StyledName } from '@app/components/@atoms/StyledName/StyledName' import { DateSelection } from '@app/components/@molecules/DateSelection/DateSelection' import { useEstimateGasWithStateOverride } from '@app/hooks/chain/useEstimateGasWithStateOverride' @@ -212,7 +211,6 @@ const ExtendNames = ({ data: { names, isSelf }, dispatch, onDismiss }: Props) => const totalRentFee = priceData ? priceData.base + priceData.premium : 0n const yearlyFee = priceData?.base ? deriveYearlyFee({ duration: seconds, price: priceData }) : 0n const previousYearlyFee = usePreviousDistinct(yearlyFee) || 0n - const unsafeDisplayYearlyFee = yearlyFee !== 0n ? yearlyFee : previousYearlyFee const isShowingPreviousYearlyFee = yearlyFee === 0n && previousYearlyFee > 0n const { data: expiryData } = useExpiry({ enabled: names.length === 1, name: names[0] }) const expiryDate = expiryData?.expiry?.date @@ -260,8 +258,6 @@ const ExtendNames = ({ data: { names, isSelf }, dispatch, onDismiss }: Props) => const previousTransactionFee = usePreviousDistinct(transactionFee) || 0n - const unsafeDisplayTransactionFee = - transactionFee !== 0n ? transactionFee : previousTransactionFee const isShowingPreviousTransactionFee = transactionFee === 0n && previousTransactionFee > 0n const items: InvoiceItem[] = [ @@ -281,11 +277,14 @@ const ExtendNames = ({ data: { names, isSelf }, dispatch, onDismiss }: Props) => const { title, alert } = match(view) .with('no-ownership-warning', () => ({ - title: t('input.extendNames.ownershipWarning.title', { count: names.length }), + title: t('input.extendNames.ownershipWarning.title', { + name: names.at(0), + count: names.length, + }), alert: 'warning' as const, })) .otherwise(() => ({ - title: t('input.extendNames.title', { count: names.length }), + title: t('input.extendNames.title', { name: names.at(0), count: names.length }), alert: undefined, })) @@ -366,13 +365,6 @@ const ExtendNames = ({ data: { names, isSelf }, dispatch, onDismiss }: Props) => balance.value < estimatedGasLimit)) && ( {t('input.extendNames.gasLimitError')} )} - {!!unsafeDisplayYearlyFee && !!unsafeDisplayTransactionFee && ( - - )} ))} diff --git a/src/transaction-flow/input/ProfileEditor/ProfileEditor-flow.tsx b/src/transaction-flow/input/ProfileEditor/ProfileEditor-flow.tsx index 394f5e64c..8a8e72bc2 100644 --- a/src/transaction-flow/input/ProfileEditor/ProfileEditor-flow.tsx +++ b/src/transaction-flow/input/ProfileEditor/ProfileEditor-flow.tsx @@ -390,27 +390,13 @@ const ProfileEditor = ({ data = {}, transactions = [], dispatch, onDismiss }: Pr onDismissOverlay={() => setView('editor')} /> )) - .with('upload', () => ( + .with('upload', 'nft', (type) => ( setView('editor')} - type="upload" - handleSubmit={(type: 'upload' | 'nft', uri: string, display?: string) => { - setAvatar(uri) - setAvatarSrc(display) - setView('editor') - trigger() - }} - /> - )) - .with('nft', () => ( - setView('editor')} - type="nft" - handleSubmit={(type: 'upload' | 'nft', uri: string, display?: string) => { + type={type} + handleSubmit={(_, uri, display) => { setAvatar(uri) setAvatarSrc(display) setView('editor') diff --git a/src/transaction-flow/input/ProfileReclaim-flow.tsx b/src/transaction-flow/input/ProfileReclaim-flow.tsx new file mode 100644 index 000000000..a71448ecb --- /dev/null +++ b/src/transaction-flow/input/ProfileReclaim-flow.tsx @@ -0,0 +1,246 @@ +/* eslint-disable no-nested-ternary */ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled, { css } from 'styled-components' +import { match } from 'ts-pattern' + +import { Button, Dialog, mq, PlusSVG } from '@ensdomains/thorin' + +import { AvatarClickType } from '@app/components/@molecules/ProfileEditor/Avatar/AvatarButton' +import { AvatarViewManager } from '@app/components/@molecules/ProfileEditor/Avatar/AvatarViewManager' +import { AddProfileRecordView } from '@app/components/pages/profile/[name]/registration/steps/Profile/AddProfileRecordView' +import { CustomProfileRecordInput } from '@app/components/pages/profile/[name]/registration/steps/Profile/CustomProfileRecordInput' +import { ProfileRecordInput } from '@app/components/pages/profile/[name]/registration/steps/Profile/ProfileRecordInput' +import { ProfileRecordTextarea } from '@app/components/pages/profile/[name]/registration/steps/Profile/ProfileRecordTextarea' +import { + profileEditorFormToProfileRecords, + profileToProfileRecords, +} from '@app/components/pages/profile/[name]/registration/steps/Profile/profileRecordUtils' +import { ProfileRecord } from '@app/constants/profileRecordOptions' +import { useContractAddress } from '@app/hooks/chain/useContractAddress' +import { useProfile } from '@app/hooks/useProfile' +import { useProfileEditorForm } from '@app/hooks/useProfileEditorForm' +import { createTransactionItem } from '@app/transaction-flow/transaction' +import type { TransactionDialogPassthrough } from '@app/transaction-flow/types' + +import { WrappedAvatarButton } from './ProfileEditor/WrappedAvatarButton' + +const ButtonContainer = styled.div( + ({ theme }) => css` + display: flex; + justify-content: center; + padding-bottom: ${theme.space['4']}; + `, +) + +const ButtonWrapper = styled.div(({ theme }) => [ + css` + width: ${theme.space.full}; + `, + mq.xs.min(css` + width: max-content; + `), +]) + +type Data = { + name: string + label: string + parent: string +} + +type ModalOption = AvatarClickType | 'profile-editor' | 'add-record' + +export type Props = { + name?: string + data: Data + onDismiss?: () => void +} & TransactionDialogPassthrough + +const ProfileReclaim = ({ data: { name, label, parent }, dispatch, onDismiss }: Props) => { + const { t } = useTranslation('profile') + const { t: registerT } = useTranslation('register') + + const [view, setView] = useState('profile-editor') + + const { data: profile } = useProfile({ name }) + + const existingRecords = profileToProfileRecords(profile) + + const defaultResolverAddress = useContractAddress({ contract: 'ensPublicResolver' }) + + const { + isDirty, + records, + register, + trigger, + control, + addRecords, + getValues, + removeRecordAtIndex, + setAvatar, + labelForRecord, + secondaryLabelForRecord, + placeholderForRecord, + validatorForRecord, + errorForRecordAtIndex, + isDirtyForRecordAtIndex, + } = useProfileEditorForm(existingRecords) + console.log(existingRecords) + const handleSubmit = () => { + const payload = [ + createTransactionItem('createSubname', { + contract: 'nameWrapper', + label, + parent, + }), + ] + + if (isDirty && records.length) { + payload.push( + createTransactionItem('updateProfileRecords', { + name, + records: profileEditorFormToProfileRecords(getValues()), + resolverAddress: defaultResolverAddress, + clearRecords: false, + }) as never, + ) + } + dispatch({ + name: 'setTransactions', + payload, + }) + dispatch({ + name: 'setFlowStage', + payload: 'transaction', + }) + } + + const [avatarFile, setAvatarFile] = useState() + const [avatarSrc, setAvatarSrc] = useState() + + const handleDeleteRecord = (_: ProfileRecord, index: number) => { + removeRecordAtIndex(index) + process.nextTick(() => trigger()) + } + + return ( + <> + {match(view) + .with('profile-editor', () => ( + <> + + + setAvatar(avatar)} + onAvatarFileChange={(file) => setAvatarFile(file)} + onAvatarSrcChange={(src) => setAvatarSrc(src)} + /> + {records.map((field, index) => + match(field) + .with({ group: 'custom' }, () => ( + handleDeleteRecord(field, index)} + /> + )) + .with({ key: 'description' }, () => ( + handleDeleteRecord(field, index)} + {...register(`records.${index}.value`, { + validate: validatorForRecord(field), + })} + /> + )) + .otherwise(() => ( + handleDeleteRecord(field, index)} + {...register(`records.${index}.value`, { + validate: validatorForRecord(field), + })} + /> + )), + )} + + + + + + + + {t('action.cancel', { ns: 'common' })} + + } + trailing={ + + } + /> + + )) + .with('add-record', () => ( + { + addRecords(newRecords) + setView('profile-editor') + }} + onClose={() => setView('profile-editor')} + /> + )) + .with('upload', 'nft', (type) => ( + setView('profile-editor')} + type={type} + handleSubmit={(_, uri, display) => { + setAvatar(uri) + setAvatarSrc(display) + setView('profile-editor') + trigger() + }} + /> + )) + .exhaustive()} + + ) +} + +export default ProfileReclaim diff --git a/src/transaction-flow/input/index.tsx b/src/transaction-flow/input/index.tsx index 4981b2402..33ce04860 100644 --- a/src/transaction-flow/input/index.tsx +++ b/src/transaction-flow/input/index.tsx @@ -12,6 +12,7 @@ import type { Props as EditResolverProps } from './EditResolver/EditResolver-flo import type { Props as EditRolesProps } from './EditRoles/EditRoles-flow' import type { Props as ExtendNamesProps } from './ExtendNames/ExtendNames-flow' import type { Props as ProfileEditorProps } from './ProfileEditor/ProfileEditor-flow' +import type { Props as ProfileReclaimProps } from './ProfileReclaim-flow' import type { Props as ResetPrimaryNameProps } from './ResetPrimaryName/ResetPrimaryName-flow' import type { Props as RevokePermissionsProps } from './RevokePermissions/RevokePermissions-flow' import type { Props as SelectPrimaryNameProps } from './SelectPrimaryName/SelectPrimaryName-flow' @@ -55,6 +56,7 @@ const EditResolver = dynamicHelper('EditResolver/EditResolver const EditRoles = dynamicHelper('EditRoles/EditRoles') const ExtendNames = dynamicHelper('ExtendNames/ExtendNames') const ProfileEditor = dynamicHelper('ProfileEditor/ProfileEditor') +const ProfileReclaim = dynamicHelper('ProfileReclaim') const ResetPrimaryName = dynamicHelper('ResetPrimaryName/ResetPrimaryName') const RevokePermissions = dynamicHelper( 'RevokePermissions/RevokePermissions', @@ -76,6 +78,7 @@ export const DataInputComponents = { EditRoles, ExtendNames, ProfileEditor, + ProfileReclaim, ResetPrimaryName, RevokePermissions, SelectPrimaryName, diff --git a/src/transaction-flow/reducer.ts b/src/transaction-flow/reducer.ts index 585dbf0c5..59b432db1 100644 --- a/src/transaction-flow/reducer.ts +++ b/src/transaction-flow/reducer.ts @@ -7,6 +7,7 @@ import { TransactionFlowAction, TransactionFlowStage, } from './types' +import { shouldSkipTransactionUpdateDuringTest } from './utils/shouldSkipTransactionUpdateDuringTest' export const initialState: InternalTransactionFlow = { selectedKey: null, @@ -169,6 +170,9 @@ export const reducer = (draft: InternalTransactionFlow, action: TransactionFlowA transaction.stage = 'sent' break } + + if (shouldSkipTransactionUpdateDuringTest(transaction)) break + const stage = status === 'confirmed' ? 'complete' : 'failed' transaction.stage = stage transaction.minedData = minedData diff --git a/src/transaction-flow/transaction/extendNames.ts b/src/transaction-flow/transaction/extendNames.ts index ac2ff598a..786cbd062 100644 --- a/src/transaction-flow/transaction/extendNames.ts +++ b/src/transaction-flow/transaction/extendNames.ts @@ -29,6 +29,7 @@ const displayItems = ( value: t('transaction.extendNames.actionValue', { ns: 'transactionFlow' }), }, { + type: 'duration', label: 'duration', value: formatDurationOfDates({ startDate: startDateTimestamp ? new Date(startDateTimestamp) : undefined, diff --git a/src/transaction-flow/utils/isTransaction.ts b/src/transaction-flow/utils/isTransaction.ts new file mode 100644 index 000000000..a69eae0d2 --- /dev/null +++ b/src/transaction-flow/utils/isTransaction.ts @@ -0,0 +1,10 @@ +import type { TransactionData, TransactionName } from '../transaction' +import type { GenericTransaction } from '../types' + +export const isTransaction = + (name: TName) => + ( + transaction: GenericTransaction>, + ): transaction is GenericTransaction> => { + return transaction?.name === name + } diff --git a/src/transaction-flow/utils/shouldSkipTransactionUpdateDuringTest.ts b/src/transaction-flow/utils/shouldSkipTransactionUpdateDuringTest.ts new file mode 100644 index 000000000..ce23d657e --- /dev/null +++ b/src/transaction-flow/utils/shouldSkipTransactionUpdateDuringTest.ts @@ -0,0 +1,14 @@ +import { TransactionData, TransactionName } from '../transaction' +import { GenericTransaction } from '../types' +import { isTransaction } from './isTransaction' + +// This function is used to skip a transaction update during testing on a local chain environment. +export const shouldSkipTransactionUpdateDuringTest = ( + transaction: GenericTransaction>, +) => { + return ( + process.env.NEXT_PUBLIC_ETH_NODE === 'anvil' && + isTransaction('commitName')(transaction) && + transaction.data?.name?.startsWith('stuck-commit') + ) +} diff --git a/src/types/index.ts b/src/types/index.ts index 7c15146bd..ae8dd814d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -37,7 +37,7 @@ interface TransactionDisplayItemBase { } export interface TransactionDisplayItemSingle extends TransactionDisplayItemBase { - type?: 'name' | 'subname' | 'address' | undefined + type?: 'name' | 'subname' | 'address' | 'duration' | undefined value: string } @@ -56,7 +56,7 @@ export type TransactionDisplayItem = | TransactionDisplayItemList | TransactionDisplayItemRecords -export type TransactionDisplayItemTypes = 'name' | 'address' | 'list' | 'records' +export type TransactionDisplayItemTypes = 'name' | 'address' | 'list' | 'records' | 'duration' export type AvatarEditorType = { avatar?: string diff --git a/src/utils/device.ts b/src/utils/device.ts new file mode 100644 index 000000000..cda56031d --- /dev/null +++ b/src/utils/device.ts @@ -0,0 +1,3 @@ +export function isMobileDevice() { + return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent) +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 09610c3bf..12002b862 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -1,7 +1,19 @@ -import { BaseError, decodeErrorResult, RawContractError } from 'viem' +import { + BaseError, + decodeErrorResult, + EstimateGasExecutionError, + formatEther, + RawContractError, +} from 'viem' import { ethRegistrarControllerErrors, nameWrapperErrors } from '@ensdomains/ensjs/contracts' +type ReadableErrorType = 'insufficientFunds' | 'contract' | 'unknown' +type ReadableError = { + message: string + type: ReadableErrorType +} + export const getViemRevertErrorData = (err: unknown) => { if (!(err instanceof BaseError)) return undefined const error = err.walk() as RawContractError @@ -10,7 +22,27 @@ export const getViemRevertErrorData = (err: unknown) => { export const allContractErrors = [...ethRegistrarControllerErrors, ...nameWrapperErrors] -export const getReadableError = (err: unknown) => { +const insufficientFundsRegex = + /insufficient funds for gas \* price \+ value: address (?
0x[a-fA-F0-9]{40}) have (?\d*) want (?\d*)/ + +const getEstimateGasExecutionErrorMessage = (err: EstimateGasExecutionError) => { + const originError = err.walk() + const data = insufficientFundsRegex.exec(originError.message) + if (data?.groups) { + const { requiredBalance } = data.groups + return { + message: `Wallet balance too low. Minimum required balance: ${formatEther( + BigInt(requiredBalance), + )} ETH`, + type: 'insufficientFunds', + } as const + } + + return null +} + +export const getReadableError = (err: unknown): ReadableError | null => { + if (err instanceof EstimateGasExecutionError) return getEstimateGasExecutionErrorMessage(err) const data = getViemRevertErrorData(err) if (!data) return null const decodedError = decodeErrorResult({ @@ -18,5 +50,8 @@ export const getReadableError = (err: unknown) => { data, }) if (!decodedError) return null - return decodedError.errorName + return { + message: decodedError.errorName, + type: 'contract', + } as const } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 7dccb6c6c..022b8d89b 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -55,11 +55,13 @@ export const formatDurationOfDates = ({ startDate, endDate, postFix = '', + shortYears, t, }: { startDate?: Date endDate?: Date postFix?: string + shortYears?: boolean t: TFunction }) => { if (!startDate || !endDate) return t('unit.invalid_date', { ns: 'common' }) @@ -69,7 +71,7 @@ export const formatDurationOfDates = ({ if (isNegative) return t('unit.invalid_date', { ns: 'common' }) const diffEntries = [ - ['years', diff.years], + [shortYears ? 'yrs' : 'years', diff.years], ['months', diff.months], ['days', diff.days], ] as [string, number][] diff --git a/wrangler.toml b/wrangler.toml index 6506c7060..0f88f5659 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1 +1,3 @@ -# compatibility_flags = [ "streams_enable_constructors" ] // Prevents wrangle from launching \ No newline at end of file +# compatibility_flags = [ "streams_enable_constructors" ] // Prevents wrangle from launching +name = "ens-app-v3" +pages_build_output_dir = "out"