diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index eaf61d5458..12671e8a46 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,10 +1,12 @@ name: Deploy to dev/staging on: + pull_request: + push: branches: - - main - dev + - main jobs: deploy: @@ -46,27 +48,33 @@ jobs: aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ secrets.AWS_DEFAULT_REGION }} + # Staging - name: Deploy to the staging S3 if: github.ref == 'refs/heads/main' - run: | - cd out - - aws s3 sync . s3://${{ secrets.AWS_STAGING_BUCKET_NAME }}/current - aws s3 sync . s3://${{ secrets.AWS_STAGING_BUCKET_NAME }}/current --delete - - for file in $(find . -name '*.html' | sed 's|^\./||'); do - aws s3 cp ${file%} s3://${{ secrets.AWS_STAGING_BUCKET_NAME }}/current/${file%.*} --content-type 'text/html' - done + env: + BUCKET: s3://${{ secrets.AWS_STAGING_BUCKET_NAME }}/current + run: bash ./scripts/github/s3_upload.sh + # Dev - name: Deploy to the dev S3 if: github.ref == 'refs/heads/dev' - run: | - cd out - - aws s3 sync . s3://${{ secrets.AWS_DEVELOPMENT_BUCKET_NAME }} - aws s3 sync . s3://${{ secrets.AWS_DEVELOPMENT_BUCKET_NAME }} --delete + env: + BUCKET: s3://${{ secrets.AWS_DEVELOPMENT_BUCKET_NAME }} + run: bash ./scripts/github/s3_upload.sh - for file in $(find . -name '*.html' | sed 's|^\./||'); do - aws s3 cp ${file%} s3://${{ secrets.AWS_DEVELOPMENT_BUCKET_NAME }}/${file%.*} --content-type 'text/html' - done + # PRs + - name: Deploy PR branch + if: github.ref != 'refs/heads/dev' && github.ref != 'refs/heads/main' + env: + BUCKET: s3://${{ secrets.AWS_REVIEW_BUCKET_NAME }}/webcore/pr${{ github.event.number }} + run: bash ./scripts/github/s3_upload.sh + - name: 'Post the deployment links in the PR' + if: success() && github.ref != 'refs/heads/dev' && github.ref != 'refs/heads/main' + uses: mshick/add-pr-comment@v1 + with: + message: | + ## Branch preview + https://pr${{ github.event.number }}--webcore.review-react-br.5afe.dev + repo-token: ${{ secrets.GITHUB_TOKEN }} + repo-token-user-login: 'github-actions[bot]' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7adb12fa5..6dd00c161b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,20 +45,11 @@ jobs: aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ secrets.AWS_DEFAULT_REGION }} - # Script to upload release files. - # First it uploads new files, so both the previous version and the new one co-exist and there’s no downtime. - # Then it uploads a second time, with --delete, to delete the old files. - # Finally, it uploads HTML files w/o the `.html` extension so that URLs like `/balances` work. + # Script to upload release files - name: 'Upload release build files for production' - run: | - cd out - - aws s3 sync . s3://${{ secrets.AWS_STAGING_BUCKET_NAME }}/releases/${{ github.event.release.tag_name }} - aws s3 sync . s3://${{ secrets.AWS_STAGING_BUCKET_NAME }}/releases/${{ github.event.release.tag_name }} --delete - - for file in $(find . -name '*.html' | sed 's|^\./||'); do - aws s3 cp ${file%} s3://${{ secrets.AWS_STAGING_BUCKET_NAME }}/releases/${{ github.event.release.tag_name }}/${file%.*} --content-type 'text/html' - done + env: + BUCKET: s3://${{ secrets.AWS_STAGING_BUCKET_NAME }}/releases/${{ github.event.release.tag_name }} + run: bash ./scripts/github/s3_upload.sh # Script to prepare production deployments - run: bash ./scripts/github/prepare_production_deployment.sh diff --git a/cypress/e2e/smoke/create_tx.cy.js b/cypress/e2e/create_tx.cy.js similarity index 88% rename from cypress/e2e/smoke/create_tx.cy.js rename to cypress/e2e/create_tx.cy.js index 30b756088c..ab099b0c7d 100644 --- a/cypress/e2e/smoke/create_tx.cy.js +++ b/cypress/e2e/create_tx.cy.js @@ -15,8 +15,13 @@ describe('Queue a transaction on 1/N', () => { }) it('should create and queue a transaction', () => { + // Assert that "New transaction" button is visible + cy.contains('New transaction', { + timeout: 60_000, // `lastWallet` takes a while initialize in CI + }).should('be.visible') + // Open the new transaction modal - cy.contains('New transaction', { timeout: 10000 }).click() + cy.contains('New transaction').click() // Modal is open cy.contains('h2', 'New transaction').should('be.visible') @@ -30,20 +35,21 @@ describe('Queue a transaction on 1/N', () => { // Insert amount cy.get('input[name="amount"]').type(`${sendValue}`) - - cy.contains('Next').click() }) it('should create a queued transaction', () => { - // Spy the /estimations request - cy.intercept('POST', '/**/multisig-transactions/estimations').as('estimations') + // Wait for /estimations response + cy.intercept('POST', '/**/multisig-transactions/estimations').as('EstimationRequest') + + cy.contains('Next').click() + + cy.wait('@EstimationRequest', { + timeout: 30_000, // EstimationRequest takes a while in CI + }) // Alias for New transaction modal cy.contains('h2', 'Review transaction').parents('div').as('modal') - // Wait for /estimations response - cy.wait('@estimations', { timeout: 30_000 }) - // Estimation is loaded cy.get('button[type="submit"]').should('not.be.disabled') @@ -97,17 +103,17 @@ describe('Queue a transaction on 1/N', () => { cy.get('@modal').within(() => { cy.get('input[type="checkbox"]').should('not.exist') }) + }) + it('should click the notification and see the transaction queued', () => { cy.contains('Submit').click() - // Spy the /propose request and give it an alias - cy.intercept('POST', '/**/propose').as('propose') - // Wait for the /propose request - cy.wait('@propose', { timeout: 30_000 }) - }) + cy.intercept('POST', '/**/propose').as('ProposeTx') + cy.wait('@ProposeTx', { + timeout: 30_000, // ProposeTx takes a while in CI + }) - it('should click the notification and see the transaction queued', () => { // Click on the notification cy.contains('View transaction').click() diff --git a/cypress/e2e/safe-apps/apps_list.cy.js b/cypress/e2e/safe-apps/apps_list.cy.js index e72aad43ef..7c63a44a5a 100644 --- a/cypress/e2e/safe-apps/apps_list.cy.js +++ b/cypress/e2e/safe-apps/apps_list.cy.js @@ -8,8 +8,11 @@ describe('The Safe Apps list', () => { describe('When searching apps', () => { it('should filter the list by app name', () => { - cy.findByRole('textbox').type('walletconnect') - cy.findAllByRole('link', { name: /logo/i }).should('have.length', 1) + // Wait for /safe-apps response + cy.intercept('GET', '/**/safe-apps').then(() => { + cy.findByRole('textbox').type('walletconnect') + cy.findAllByRole('link', { name: /logo/i }).should('have.length', 1) + }) }) it('should filter the list by app description', () => { diff --git a/package.json b/package.json index 73439012b2..e385786289 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "homepage": "https://github.com/safe-global/web-core", "license": "MIT", "type": "module", - "version": "1.0.9", + "version": "1.1.0", "private": true, "scripts": { "dev": "next dev", @@ -39,11 +39,11 @@ "@emotion/server": "^11.10.0", "@emotion/styled": "^11.10.0", "@gnosis.pm/safe-apps-sdk": "^7.8.0", - "@gnosis.pm/safe-core-sdk": "^3.1.1", - "@gnosis.pm/safe-deployments": "^1.15.0", - "@gnosis.pm/safe-ethers-lib": "^1.6.1", + "@gnosis.pm/safe-core-sdk": "^3.2.0", + "@gnosis.pm/safe-deployments": "^1.17.0", + "@gnosis.pm/safe-ethers-lib": "^1.7.0", "@gnosis.pm/safe-modules-deployments": "^1.0.0", - "@gnosis.pm/safe-react-gateway-sdk": "^3.4.5", + "@gnosis.pm/safe-react-gateway-sdk": "^3.4.6", "@mui/icons-material": "^5.8.4", "@mui/material": "^5.9.3", "@mui/x-date-pickers": "^5.0.0-beta.6", @@ -65,7 +65,7 @@ "classnames": "^2.3.1", "date-fns": "^2.29.2", "ethereum-blockies-base64": "^1.0.2", - "ethers": "^5.6.8", + "ethers": "5.7.2", "exponential-backoff": "^3.1.0", "fuse.js": "^6.6.2", "js-cookie": "^3.0.1", @@ -84,7 +84,7 @@ "semver": "^7.3.7" }, "devDependencies": { - "@gnosis.pm/safe-core-sdk-types": "^1.6.1", + "@gnosis.pm/safe-core-sdk-types": "^1.7.0", "@next/bundle-analyzer": "^12.2.4", "@sentry/types": "^7.8.1", "@svgr/webpack": "^6.3.1", diff --git a/public/images/common/copy.svg b/public/images/common/copy.svg index 7f66f196d2..54e48ca27f 100644 --- a/public/images/common/copy.svg +++ b/public/images/common/copy.svg @@ -1,3 +1,3 @@ - + diff --git a/public/images/common/lightbulb.svg b/public/images/common/lightbulb.svg new file mode 100644 index 0000000000..1dbf872f3d --- /dev/null +++ b/public/images/common/lightbulb.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/images/sidebar/address-book.svg b/public/images/sidebar/address-book.svg index 2f66e29bcf..09a3d95ac5 100644 --- a/public/images/sidebar/address-book.svg +++ b/public/images/sidebar/address-book.svg @@ -1,3 +1,3 @@ - - + + diff --git a/public/images/sidebar/apps.svg b/public/images/sidebar/apps.svg new file mode 100644 index 0000000000..f8130d6ebb --- /dev/null +++ b/public/images/sidebar/apps.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/sidebar/assets.svg b/public/images/sidebar/assets.svg index 42f2b05264..80991d073d 100644 --- a/public/images/sidebar/assets.svg +++ b/public/images/sidebar/assets.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/public/images/sidebar/copy-bold.svg b/public/images/sidebar/copy-bold.svg index 23aa4862f5..9fd56d42d7 100644 --- a/public/images/sidebar/copy-bold.svg +++ b/public/images/sidebar/copy-bold.svg @@ -1,3 +1,3 @@ - + diff --git a/public/images/sidebar/help-center.svg b/public/images/sidebar/help-center.svg index ccda631860..2197d8e135 100644 --- a/public/images/sidebar/help-center.svg +++ b/public/images/sidebar/help-center.svg @@ -1,5 +1,5 @@ - - - - + + + + diff --git a/public/images/sidebar/home.svg b/public/images/sidebar/home.svg index 2753a34389..201e40c645 100644 --- a/public/images/sidebar/home.svg +++ b/public/images/sidebar/home.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/public/images/sidebar/link.svg b/public/images/sidebar/link.svg new file mode 100644 index 0000000000..a44a861def --- /dev/null +++ b/public/images/sidebar/link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/sidebar/settings.svg b/public/images/sidebar/settings.svg index 1ced55a71c..758c74097c 100644 --- a/public/images/sidebar/settings.svg +++ b/public/images/sidebar/settings.svg @@ -1,3 +1,3 @@ - - + + diff --git a/public/images/sidebar/transactions.svg b/public/images/sidebar/transactions.svg index 283be05294..c047ebfbef 100644 --- a/public/images/sidebar/transactions.svg +++ b/public/images/sidebar/transactions.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/public/images/sidebar/whats-new.svg b/public/images/sidebar/whats-new.svg index e972ac1607..489537ad44 100644 --- a/public/images/sidebar/whats-new.svg +++ b/public/images/sidebar/whats-new.svg @@ -1,7 +1,7 @@ - - - - - - + + + + + + diff --git a/scripts/github/s3_upload.sh b/scripts/github/s3_upload.sh new file mode 100755 index 0000000000..be34c9f500 --- /dev/null +++ b/scripts/github/s3_upload.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +set -ev + +cd out + +# First, upload the new files w/o deleting the old ones +aws s3 sync . $BUCKET + +# Second, upload them again but delete the old files this time +# This allows for a no-downtime deployment +aws s3 sync . $BUCKET --delete + +# Finally, upload all HTML files again but w/o an extention so that URLs like /welcome open the right page +for file in $(find . -name '*.html' | sed 's|^\./||'); do + aws s3 cp ${file%} $BUCKET/${file%.*} --content-type 'text/html' +done + +cd - diff --git a/src/components/address-book/ImportDialog/index.tsx b/src/components/address-book/ImportDialog/index.tsx index dd8f9dde4b..87ad7a8274 100644 --- a/src/components/address-book/ImportDialog/index.tsx +++ b/src/components/address-book/ImportDialog/index.tsx @@ -10,13 +10,13 @@ import { type ReactElement, useState, type MouseEvent, useMemo } from 'react' import ModalDialog from '@/components/common/ModalDialog' import { upsertAddressBookEntry } from '@/store/addressBookSlice' import { useAppDispatch } from '@/store' -import { Box, Grid, IconButton } from '@mui/material' import css from './styles.module.css' import { trackEvent, ADDRESS_BOOK_EVENTS } from '@/services/analytics' import { abCsvReaderValidator, abOnUploadValidator } from './validation' import ErrorMessage from '@/components/tx/ErrorMessage' import { Errors, logError } from '@/services/exceptions' +import FileUpload, { FileTypes, type FileInfo } from '@/components/common/FileUpload' type AddressBookCSVRow = ['address', 'name', 'chainId'] @@ -114,7 +114,7 @@ const ImportDialog = ({ handleClose }: { handleClose: () => void }): ReactElemen > {/* https://github.com/Bunlong/react-papaparse/blob/master/src/useCSVReader.tsx */} {({ getRootProps, acceptedFile, ProgressBar, getRemoveFileProps, Remove }: any) => { - const { onClick, ...removeProps } = getRemoveFileProps() + const { onClick } = getRemoveFileProps() const onRemove = (e: MouseEvent) => { setCsvData(undefined) @@ -122,44 +122,32 @@ const ImportDialog = ({ handleClose }: { handleClose: () => void }): ReactElemen onClick(e) } + const fileInfo: FileInfo | undefined = acceptedFile + ? { + name: acceptedFile.name, + additionalInfo: formatFileSize(acceptedFile.size), + summary: [ + + {`Found ${entryCount} entries on ${chainCount} ${chainCount > 1 ? 'chains' : 'chain'}`} + , + ], + } + : undefined + return ( - `2px dashed ${zoneHover ? palette.primary.main : palette.border.light}`, - }} - > - {acceptedFile ? ( -
- - - {acceptedFile.name} - {formatFileSize(acceptedFile.size)} - - - - - - - - - - - - {entryCount > 0 && ( - - {`Found ${entryCount} entries on ${chainCount} ${chainCount > 1 ? 'chains' : 'chain'}`} - - )} -
- ) : ( - 'Drop your CSV file here or click to upload.' - )} - + ) }} +
+ {error && {error}} diff --git a/src/components/address-book/ImportDialog/styles.module.css b/src/components/address-book/ImportDialog/styles.module.css index 0cd198fa64..4891aa8b6b 100644 --- a/src/components/address-book/ImportDialog/styles.module.css +++ b/src/components/address-book/ImportDialog/styles.module.css @@ -1,11 +1,5 @@ -.dropbox { - align-items: center; - border-radius: 8px; +.horizontalDivider { display: flex; - flex-direction: column; - justify-content: center; - cursor: pointer; - padding: var(--space-2) 0; - margin: var(--space-3) 0; - min-height: 200px; + margin: 24px -24px; + border-top: 2px solid rgba(0, 0, 0, 0.12); } diff --git a/src/components/common/ConnectWallet/WalletDetails.tsx b/src/components/common/ConnectWallet/WalletDetails.tsx index 491b749cbc..e353ae914b 100644 --- a/src/components/common/ConnectWallet/WalletDetails.tsx +++ b/src/components/common/ConnectWallet/WalletDetails.tsx @@ -1,23 +1,12 @@ import { Button, Typography } from '@mui/material' import type { ReactElement } from 'react' -import useOnboard, { connectWallet } from '@/hooks/wallets/useOnboard' -import { OVERVIEW_EVENTS } from '@/services/analytics/events/overview' import KeyholeIcon from '@/components/common/icons/KeyholeIcon' -import { trackEvent } from '@/services/analytics' +import type { ConnectedWallet } from '@/services/onboard' +import useConnectWallet from '@/components/common/ConnectWallet/useConnectWallet' -const WalletDetails = ({ onConnect }: { onConnect?: () => void }): ReactElement => { - const onboard = useOnboard() - - const handleConnect = async () => { - if (!onboard) return - - // We `trackEvent` instead of using `` as it impedes styling - trackEvent(OVERVIEW_EVENTS.OPEN_ONBOARD) - - onConnect?.() - connectWallet(onboard) - } +const WalletDetails = ({ onConnect }: { onConnect?: (wallet?: ConnectedWallet) => void }): ReactElement => { + const handleConnect = useConnectWallet(onConnect) return ( <> @@ -25,7 +14,7 @@ const WalletDetails = ({ onConnect }: { onConnect?: () => void }): ReactElement - diff --git a/src/components/common/ConnectWallet/useConnectWallet.ts b/src/components/common/ConnectWallet/useConnectWallet.ts new file mode 100644 index 0000000000..e1ca2acecc --- /dev/null +++ b/src/components/common/ConnectWallet/useConnectWallet.ts @@ -0,0 +1,25 @@ +import useOnboard, { connectWallet } from '@/hooks/wallets/useOnboard' +import { OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' +import { CodedException } from '@/services/exceptions' +import type { ConnectedWallet } from '@/services/onboard' + +const useConnectWallet = (onConnect?: (wallet?: ConnectedWallet) => void) => { + const onboard = useOnboard() + + const handleConnect = async () => { + if (!onboard) return + + // We `trackEvent` instead of using `` as it impedes styling + trackEvent(OVERVIEW_EVENTS.OPEN_ONBOARD) + + const result = await connectWallet(onboard) + + if (result instanceof CodedException) return + + onConnect?.(result) + } + + return handleConnect +} + +export default useConnectWallet diff --git a/src/components/common/CopyButton/index.tsx b/src/components/common/CopyButton/index.tsx index aad0230c75..98d9e8468b 100644 --- a/src/components/common/CopyButton/index.tsx +++ b/src/components/common/CopyButton/index.tsx @@ -36,19 +36,7 @@ const CopyButton = ({ return ( - {children ?? ( - palette.border.main, - }, - }} - fontSize="small" - /> - )} + {children ?? } ) diff --git a/src/components/common/FileUpload/index.tsx b/src/components/common/FileUpload/index.tsx new file mode 100644 index 0000000000..5589b293b8 --- /dev/null +++ b/src/components/common/FileUpload/index.tsx @@ -0,0 +1,126 @@ +import css from './styles.module.css' +import { Box, Grid, IconButton, Link, SvgIcon, type SvgIconTypeMap, Typography } from '@mui/material' +import HighlightOffIcon from '@mui/icons-material/HighlightOff' +import FileIcon from '@/public/images/settings/data/file.svg' +import type { MouseEventHandler, ReactElement } from 'react' +import type { DropzoneInputProps, DropzoneRootProps } from 'react-dropzone' + +export type FileInfo = { + name: string + additionalInfo?: string + summary: ReactElement[] + error?: string +} + +export enum FileTypes { + JSON = 'JSON', + CSV = 'CSV', +} + +const ColoredFileIcon = ({ color }: { color: SvgIconTypeMap['props']['color'] }) => ( + +) + +const UploadSummary = ({ fileInfo, onRemove }: { fileInfo: FileInfo; onRemove: (() => void) | MouseEventHandler }) => { + return ( + + + + + + + {fileInfo.name} + {fileInfo.additionalInfo && ` - ${fileInfo.additionalInfo}`} + + + + + + + + + +
+ + <> + {fileInfo.summary.map((summaryItem, idx) => ( + + + + + + {summaryItem} + + + ))} + {fileInfo.error && ( + + + + + + + {fileInfo.error} + + + + )} + + + ) +} + +const FileUpload = ({ + getRootProps, + getInputProps, + isDragReject = false, + isDragActive = false, + fileType, + fileInfo, + onRemove, +}: { + isDragReject?: boolean + isDragActive?: boolean + fileType: FileTypes + getInputProps?: (props?: T | undefined) => T + getRootProps: (props?: T | undefined) => T + fileInfo?: FileInfo + onRemove: (() => void) | MouseEventHandler +}) => { + if (fileInfo) { + return + } + return ( + `${isDragReject ? palette.error.light : undefined} !important`, + border: ({ palette }) => + `1px dashed ${ + isDragReject ? palette.error.dark : isDragActive ? palette.primary.main : palette.secondary.dark + }`, + }} + > + {getInputProps && } + + + palette.primary.light }} + /> + + Drag and drop a {fileType} file or{' '} + + choose a file + + + + + ) +} + +export default FileUpload diff --git a/src/components/common/FileUpload/styles.module.css b/src/components/common/FileUpload/styles.module.css new file mode 100644 index 0000000000..1225fc1433 --- /dev/null +++ b/src/components/common/FileUpload/styles.module.css @@ -0,0 +1,21 @@ +.dropbox { + align-items: center; + border-radius: 8px; + display: flex; + flex-direction: column; + justify-content: center; + cursor: pointer; + padding: var(--space-3) var(--space-5); + margin: var(--space-3) 0; + background: var(--color-secondary-background); + color: var(--color-primary-light); + transition: border 0.5s, background 0.5s; +} + +.verticalLine { + display: flex; + height: 18px; + border-right: 1px solid var(--color-primary-main); + margin-left: 7px; + margin-top: -8px; +} diff --git a/src/components/common/Header/styles.module.css b/src/components/common/Header/styles.module.css index 1bcf9bd1ae..aa85b27973 100644 --- a/src/components/common/Header/styles.module.css +++ b/src/components/common/Header/styles.module.css @@ -37,7 +37,8 @@ } .networkSelector { - padding-right: var(--space-1); + padding-right: 0; + padding-left: 0; border-right: none; } diff --git a/src/components/common/NameInput/index.tsx b/src/components/common/NameInput/index.tsx index a86a743e10..4b64d181a9 100644 --- a/src/components/common/NameInput/index.tsx +++ b/src/components/common/NameInput/index.tsx @@ -9,7 +9,7 @@ const NameInput = ({ validate, required = false, ...props -}: Omit & { +}: Omit & { name: string validate?: Validate required?: boolean diff --git a/src/components/common/NetworkSelector/index.tsx b/src/components/common/NetworkSelector/index.tsx index 0ca280bd10..279a754d0d 100644 --- a/src/components/common/NetworkSelector/index.tsx +++ b/src/components/common/NetworkSelector/index.tsx @@ -24,7 +24,7 @@ const NetworkSelector = (): ReactElement => { trackEvent({ ...OVERVIEW_EVENTS.SWITCH_NETWORK, label: selectedChainId }) - const shouldKeepPath = [AppRoutes.load, AppRoutes.open].includes(router.pathname) + const shouldKeepPath = [AppRoutes.load, AppRoutes.open, AppRoutes.newSafe.create].includes(router.pathname) const newRoute = { pathname: shouldKeepPath ? router.pathname : '/', diff --git a/src/components/common/NetworkSelector/styles.module.css b/src/components/common/NetworkSelector/styles.module.css index 7ac11d46cf..a799cf1a32 100644 --- a/src/components/common/NetworkSelector/styles.module.css +++ b/src/components/common/NetworkSelector/styles.module.css @@ -1,3 +1,7 @@ +.select { + height: 100%; +} + .select:after, .select:before { display: none; @@ -6,3 +10,15 @@ .select *:focus { background: inherit; } + +.select :global .MuiSelect-select { + padding-right: 40px !important; + padding-left: 16px; + height: 100%; + display: flex; + align-items: center; +} + +.select :global .MuiSvgIcon-root { + margin-right: var(--space-2); +} diff --git a/src/components/common/PageLayout/SideDrawer.tsx b/src/components/common/PageLayout/SideDrawer.tsx new file mode 100644 index 0000000000..27166ccf47 --- /dev/null +++ b/src/components/common/PageLayout/SideDrawer.tsx @@ -0,0 +1,69 @@ +import { useEffect, type ReactElement } from 'react' +import { IconButton, Drawer, useMediaQuery } from '@mui/material' +import { useTheme } from '@mui/material/styles' +import DoubleArrowRightIcon from '@mui/icons-material/KeyboardDoubleArrowRightRounded' +import DoubleArrowLeftIcon from '@mui/icons-material/KeyboardDoubleArrowLeftRounded' +import { useRouter } from 'next/router' +import classnames from 'classnames' +import { type ParsedUrlQuery } from 'querystring' + +import Sidebar from '@/components/sidebar/Sidebar' +import css from './styles.module.css' +import { AppRoutes } from '@/config/routes' + +type SideDrawerProps = { + isOpen: boolean + onToggle: (isOpen: boolean) => void +} + +const isNewSafeRoute = (pathname: string): boolean => { + return pathname === AppRoutes.newSafe.create +} + +const isAppShareRoute = (pathname: string): boolean => { + return pathname === AppRoutes.share.safeApp +} + +const isSafeAppRoute = (pathname: string, query: ParsedUrlQuery): boolean => { + return pathname === AppRoutes.apps && !!query.appUrl +} + +const SideDrawer = ({ isOpen, onToggle }: SideDrawerProps): ReactElement => { + const { pathname, query } = useRouter() + const { breakpoints } = useTheme() + const isSmallScreen = useMediaQuery(`(max-width: ${breakpoints.values.md}px)`) + const showSidebarToggle = isSafeAppRoute(pathname, query) && !isSmallScreen + + useEffect(() => { + const closeSidebar = + isSmallScreen || isSafeAppRoute(pathname, query) || isAppShareRoute(pathname) || isNewSafeRoute(pathname) + onToggle(!closeSidebar) + }, [isSmallScreen, onToggle, pathname, query]) + + return ( + <> + onToggle(false)} + > + + + + {showSidebarToggle && ( +
+
onToggle(!isOpen)}> + + {isOpen ? : } + +
+
+ )} + + ) +} + +export default SideDrawer diff --git a/src/components/common/PageLayout/index.tsx b/src/components/common/PageLayout/index.tsx index 4f8e2613d5..31d07bc016 100644 --- a/src/components/common/PageLayout/index.tsx +++ b/src/components/common/PageLayout/index.tsx @@ -1,45 +1,28 @@ -import { useEffect, useState, type ReactElement } from 'react' -import cn from 'classnames' -import { Drawer } from '@mui/material' -import { useRouter } from 'next/router' +import { useState, type ReactElement } from 'react' +import classnames from 'classnames' -import Sidebar from '@/components/sidebar/Sidebar' import Header from '@/components/common//Header' import css from './styles.module.css' import SafeLoadingError from '../SafeLoadingError' import Footer from '../Footer' -import { AppRoutes } from '@/config/routes' +import SideDrawer from './SideDrawer' const PageLayout = ({ children }: { children: ReactElement }): ReactElement => { - const router = useRouter() - const [isMobileDrawerOpen, setIsMobileDrawerOpen] = useState(false) - const hideSidebar = router.pathname === AppRoutes.share.safeApp + const [isSidebarOpen, setSidebarOpen] = useState(true) - const onMenuToggle = (): void => { - setIsMobileDrawerOpen((prev) => !prev) + const toggleSidebar = () => { + setSidebarOpen((prev) => !prev) } - const sidebar = - - useEffect(() => { - setIsMobileDrawerOpen(false) - }, [router.pathname, router.query.safe]) - return ( <>
-
+
- {/* Desktop sidebar */} - {!hideSidebar && } - - {/* Mobile sidebar */} - - {sidebar} - + -
+
{children}
diff --git a/src/components/common/PageLayout/styles.module.css b/src/components/common/PageLayout/styles.module.css index d8b3367541..030a6f910a 100644 --- a/src/components/common/PageLayout/styles.module.css +++ b/src/components/common/PageLayout/styles.module.css @@ -13,6 +13,7 @@ min-height: 100vh; display: flex; flex-direction: column; + transition: padding 225ms cubic-bezier(0, 0, 0.2, 1) 0ms; } .mainNoSidebar { @@ -31,24 +32,47 @@ padding: var(--space-3); } -.sidebar { +.sidebarTogglePosition { position: fixed; - height: 100vh; + z-index: 2; left: 0; top: 0; - /* the z-index is required for the sidebar not to be covered by the transaction accordion on the Safe app page */ + /* mimics MUI drawer animation */ + transition: transform 225ms cubic-bezier(0, 0, 0.2, 1) 0ms; +} + +.sidebarTogglePosition.sidebarOpen { + transform: translateX(230px); +} + +.sidebarToggle { + height: 100vh; + width: var(--space-1); + background-color: var(--color-border-light); + transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + cursor: pointer; +} + +.sidebarToggle button { + position: absolute; z-index: 1; - padding-top: var(--header-height); + top: 50%; + left: -3px; + transform: translateY(-50%); + background-color: var(--color-border-light); + clip-path: inset(0 -14px 0 0); + transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; +} + +.sidebarToggle:hover, +.sidebarToggle:hover button { + background-color: var(--color-background-light); } @media (max-width: 900px) { .main { padding-left: 0; } - - .sidebar { - display: none; - } } @media (max-width: 600px) { diff --git a/src/components/common/PairingDetails/PairingDescription.tsx b/src/components/common/PairingDetails/PairingDescription.tsx index f90811d6dc..f6bf5503ce 100644 --- a/src/components/common/PairingDetails/PairingDescription.tsx +++ b/src/components/common/PairingDetails/PairingDescription.tsx @@ -9,7 +9,7 @@ const PairingDescription = (): ReactElement => { return ( <> - Scan this code in the Safe mobile app to sign transactions with your mobile device. + Scan this code in the Safe Mobile app to sign transactions with your mobile device.
Learn more about this feature. diff --git a/src/components/common/ScanQRModal/index.tsx b/src/components/common/ScanQRModal/index.tsx index 40f3d44f79..e2b766d772 100644 --- a/src/components/common/ScanQRModal/index.tsx +++ b/src/components/common/ScanQRModal/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, createRef, useState } from 'react' +import { useState, useRef, useEffect } from 'react' import { Box, Dialog, DialogTitle, IconButton, Button, Divider } from '@mui/material' import QrReader from 'react-qr-reader' import CloseIcon from '@mui/icons-material/Close' @@ -15,19 +15,14 @@ const ScanQRModal = ({ isOpen, onClose, onScan }: Props): React.ReactElement => const [fileUploadModalOpen, setFileUploadModalOpen] = useState(false) const [error, setError] = useState('') const [cameraBlocked, setCameraBlocked] = useState(false) - const scannerRef = createRef() - const openImageDialog = useCallback(() => { - if (!scannerRef.current) return - - scannerRef.current.openImageDialog() - }, [scannerRef]) + const scannerRef = useRef(null) useEffect(() => { if (!fileUploadModalOpen && cameraBlocked && !error) { setFileUploadModalOpen(true) - openImageDialog() + scannerRef.current?.openImageDialog() } - }, [cameraBlocked, openImageDialog, fileUploadModalOpen, setFileUploadModalOpen, error]) + }, [cameraBlocked, fileUploadModalOpen, error]) const onFileScannedError = (error: Error) => { if (error.name === 'NotAllowedError' || error.name === 'PermissionDismissedError') { diff --git a/src/components/create-safe/InfoWidget/index.tsx b/src/components/create-safe/InfoWidget/index.tsx new file mode 100644 index 0000000000..1dfdbf9293 --- /dev/null +++ b/src/components/create-safe/InfoWidget/index.tsx @@ -0,0 +1,82 @@ +import { + Accordion, + AccordionDetails, + AccordionSummary, + Box, + Card, + CardContent, + CardHeader, + IconButton, + SvgIcon, + Typography, +} from '@mui/material' +import type { AlertColor } from '@mui/material' +import type { ReactElement } from 'react' +import LightbulbIcon from '@/public/images/common/lightbulb.svg' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import css from './styles.module.css' +import { CREATE_SAFE_EVENTS, trackEvent } from '@/services/analytics' + +type InfoWidgetProps = { + title: string + steps: { title: string; text: string | ReactElement }[] + variant: AlertColor + startExpanded?: boolean +} + +const InfoWidget = ({ title, steps, variant, startExpanded = false }: InfoWidgetProps): ReactElement | null => { + if (steps.length === 0) { + return null + } + + return ( + palette[variant]?.background, + borderColor: ({ palette }) => palette[variant]?.main, + borderWidth: 1, + }} + > + palette[variant]?.main }}> + + + {title} + + + } + /> + + + {steps.map(({ title, text }) => { + return ( + expanded && trackEvent({ ...CREATE_SAFE_EVENTS.OPEN_HINT, label: title })} + > + palette[variant]?.light } }}> + palette[variant]?.main }} /> + + } + > + {title} + + + {text} + + + ) + })} + + + + ) +} + +export default InfoWidget diff --git a/src/components/create-safe/InfoWidget/styles.module.css b/src/components/create-safe/InfoWidget/styles.module.css new file mode 100644 index 0000000000..f00b245c4e --- /dev/null +++ b/src/components/create-safe/InfoWidget/styles.module.css @@ -0,0 +1,38 @@ +.cardHeader { + padding-bottom: 0px; +} + +.title { + width: fit-content; + padding: 4px var(--space-1); + border-radius: 6px; + display: flex; + align-items: center; + gap: 4px; +} + +.titleIcon { + font-size: 12px; +} + +.tipsList :global .MuiCardContent-root { + padding: 0; +} + +.tipAccordion { + background-color: inherit; + border: none; +} + +.tipAccordion :global .MuiAccordionSummary-root:hover { + background: inherit; +} + +.tipAccordion :global .Mui-expanded.MuiAccordionSummary-root { + background: inherit; + font-weight: bold; +} + +.tipAccordion :global .MuiAccordionDetails-root { + padding-top: 0; +} diff --git a/src/components/create-safe/OverviewWidget/index.tsx b/src/components/create-safe/OverviewWidget/index.tsx deleted file mode 100644 index a25f22b518..0000000000 --- a/src/components/create-safe/OverviewWidget/index.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Card, Typography } from '@mui/material' -import type { ReactElement } from 'react' - -import css from './styles.module.css' - -const LOGO_DIMENSIONS = '22px' - -const OverviewWidget = ({ rows }: { rows?: { title: string; component: ReactElement }[] }): ReactElement => { - return ( - -
- Safe logo - Your Safe preview -
- {rows?.map((row) => ( -
- {row.title} - {row.component} -
- ))} -
- ) -} - -export default OverviewWidget diff --git a/src/components/create-safe/logic/__tests__/index.test.ts b/src/components/create-safe/logic/__tests__/index.test.ts index 841470f125..2d7a27481c 100644 --- a/src/components/create-safe/logic/__tests__/index.test.ts +++ b/src/components/create-safe/logic/__tests__/index.test.ts @@ -64,8 +64,15 @@ describe('checkSafeCreationTx', () => { expect(result).toBe(SafeCreationStatus.REVERTED) }) - it('returns TIMEOUT if transaction couldnt be found within the timout limit', async () => { - waitForTxSpy.mockImplementationOnce(() => Promise.reject(new Error())) + it('returns TIMEOUT if transaction couldnt be found within the timeout limit', async () => { + const mockEthersError = { + ...new Error(), + receipt: { + status: 1, + }, + } + + waitForTxSpy.mockImplementationOnce(() => Promise.reject(mockEthersError)) const result = await checkSafeCreationTx(provider, mockPendingTx, '0x0') @@ -178,4 +185,19 @@ describe('handleSafeCreationError', () => { expect(result).toEqual(SafeCreationStatus.TIMEOUT) }) + + it('returns REVERTED if the tx failed', () => { + const mockEthersError = { + ...new Error(), + code: ErrorCode.UNKNOWN_ERROR, + reason: '' as EthersTxReplacedReason, + receipt: { + status: 0, + } as TransactionReceipt, + } + + const result = handleSafeCreationError(mockEthersError) + + expect(result).toEqual(SafeCreationStatus.REVERTED) + }) }) diff --git a/src/components/create-safe/logic/index.ts b/src/components/create-safe/logic/index.ts index 7253d86b73..6058286e22 100644 --- a/src/components/create-safe/logic/index.ts +++ b/src/components/create-safe/logic/index.ts @@ -22,6 +22,12 @@ import { Errors, logError } from '@/services/exceptions' import { ErrorCode } from '@ethersproject/logger' import { isWalletRejection } from '@/utils/wallets' +export type SafeCreationProps = { + owners: string[] + threshold: number + saltNonce: number +} + /** * Prepare data for creating a Safe for the Core SDK */ @@ -122,12 +128,6 @@ export const getSafeCreationTxInfo = async ( } } -export type SafeCreationProps = { - owners: string[] - threshold: number - saltNonce: number -} - export const estimateSafeCreationGas = async ( chain: ChainInfo, provider: JsonRpcProvider, @@ -172,6 +172,10 @@ export const handleSafeCreationError = (error: EthersError) => { } } + if (didRevert(error.receipt)) { + return SafeCreationStatus.REVERTED + } + return SafeCreationStatus.TIMEOUT } diff --git a/src/components/create-safe/status/__tests__/useSafeCreation.test.ts b/src/components/create-safe/status/__tests__/useSafeCreation.test.ts index 84663728e9..cba89ba2d5 100644 --- a/src/components/create-safe/status/__tests__/useSafeCreation.test.ts +++ b/src/components/create-safe/status/__tests__/useSafeCreation.test.ts @@ -4,11 +4,12 @@ import * as web3 from '@/hooks/wallets/web3' import * as chain from '@/hooks/useChains' import * as wallet from '@/hooks/wallets/useWallet' import * as logic from '@/components/create-safe/logic' -import { Web3Provider } from '@ethersproject/providers' +import { JsonRpcProvider, Web3Provider } from '@ethersproject/providers' import type { ConnectedWallet } from '@/hooks/wallets/useOnboard' import type { ChainInfo } from '@gnosis.pm/safe-react-gateway-sdk' import { BigNumber } from '@ethersproject/bignumber' import { waitFor } from '@testing-library/react' +import type Safe from '@gnosis.pm/safe-core-sdk' const mockSafeInfo = { data: '0x', @@ -32,23 +33,25 @@ describe('useSafeCreation', () => { const mockStatus = SafeCreationStatus.AWAITING const mockSetStatus = jest.fn() + const mockProvider: Web3Provider = new Web3Provider(jest.fn()) + const mockReadOnlyProvider: JsonRpcProvider = new JsonRpcProvider() beforeEach(() => { jest.resetAllMocks() - const mockProvider: Web3Provider = new Web3Provider(jest.fn()) const mockChain = { chainId: '4', } as unknown as ChainInfo jest.spyOn(web3, 'useWeb3').mockImplementation(() => mockProvider) + jest.spyOn(web3, 'useWeb3ReadOnly').mockImplementation(() => mockReadOnlyProvider) jest.spyOn(chain, 'useCurrentChain').mockImplementation(() => mockChain) jest.spyOn(wallet, 'default').mockReturnValue({} as ConnectedWallet) jest.spyOn(logic, 'getSafeCreationTxInfo').mockReturnValue(Promise.resolve(mockSafeInfo)) }) it('should create a safe if there is no txHash and status is AWAITING', async () => { - const createSafeSpy = jest.spyOn(logic, 'createNewSafe') + const createSafeSpy = jest.spyOn(logic, 'createNewSafe').mockReturnValue(Promise.resolve({} as Safe)) renderHook(() => useSafeCreation(mockPendingSafe, mockSetPendingSafe, mockStatus, mockSetStatus)) @@ -147,6 +150,24 @@ describe('useSafeCreation', () => { }) }) + it('should watch a tx even if no wallet is connected', async () => { + jest.spyOn(wallet, 'default').mockReturnValue(null) + const watchSafeTxSpy = jest.spyOn(logic, 'checkSafeCreationTx') + + renderHook(() => + useSafeCreation( + { ...mockPendingSafe, txHash: '0x123', tx: mockSafeInfo }, + mockSetPendingSafe, + mockStatus, + mockSetStatus, + ), + ) + + await waitFor(() => { + expect(watchSafeTxSpy).toHaveBeenCalledTimes(1) + }) + }) + it('should not watch a tx if there is no txHash', async () => { const watchSafeTxSpy = jest.spyOn(logic, 'checkSafeCreationTx') diff --git a/src/components/create-safe/status/useSafeCreation.ts b/src/components/create-safe/status/useSafeCreation.ts index 4353c13d43..e7f18ab750 100644 --- a/src/components/create-safe/status/useSafeCreation.ts +++ b/src/components/create-safe/status/useSafeCreation.ts @@ -7,16 +7,17 @@ import { checkSafeCreationTx, handleSafeCreationError, } from '@/components/create-safe/logic' -import { useWeb3 } from '@/hooks/wallets/web3' +import { useWeb3, useWeb3ReadOnly } from '@/hooks/wallets/web3' import { useCurrentChain } from '@/hooks/useChains' import useWallet from '@/hooks/wallets/useWallet' import type { PendingSafeData, PendingSafeTx } from '@/components/create-safe/types.d' import type { EthersError } from '@/utils/ethers-utils' +import { CREATE_SAFE_EVENTS, trackEvent } from '@/services/analytics' export enum SafeCreationStatus { AWAITING = 'AWAITING', - WALLET_REJECTED = 'WALLET_REJECTED', PROCESSING = 'PROCESSING', + WALLET_REJECTED = 'WALLET_REJECTED', ERROR = 'ERROR', REVERTED = 'REVERTED', TIMEOUT = 'TIMEOUT', @@ -36,10 +37,12 @@ export const useSafeCreation = ( const wallet = useWallet() const provider = useWeb3() + const web3ReadOnly = useWeb3ReadOnly() const chain = useCurrentChain() const createSafeCallback = useCallback( async (txHash: string, tx: PendingSafeTx) => { + trackEvent(CREATE_SAFE_EVENTS.SUBMIT_CREATE_SAFE) setPendingSafe((prev) => (prev ? { ...prev, txHash, tx } : undefined)) }, [setPendingSafe], @@ -72,15 +75,15 @@ export const useSafeCreation = ( }, [chain, createSafeCallback, isCreating, pendingSafe, provider, setStatus, wallet]) const watchSafeTx = useCallback(async () => { - if (!pendingSafe?.tx || !pendingSafe?.txHash || !provider || isWatching) return + if (!pendingSafe?.tx || !pendingSafe?.txHash || !web3ReadOnly || isWatching) return setStatus(SafeCreationStatus.PROCESSING) setIsWatching(true) - const txStatus = await checkSafeCreationTx(provider, pendingSafe.tx, pendingSafe.txHash) + const txStatus = await checkSafeCreationTx(web3ReadOnly, pendingSafe.tx, pendingSafe.txHash) setStatus(txStatus) setIsWatching(false) - }, [isWatching, pendingSafe, provider, setStatus]) + }, [isWatching, pendingSafe, web3ReadOnly, setStatus]) useEffect(() => { if (status !== SafeCreationStatus.AWAITING) return diff --git a/src/components/create-safe/status/useSafeCreationEffects.ts b/src/components/create-safe/status/useSafeCreationEffects.ts index 52cf9fffa6..167ae8963d 100644 --- a/src/components/create-safe/status/useSafeCreationEffects.ts +++ b/src/components/create-safe/status/useSafeCreationEffects.ts @@ -28,12 +28,14 @@ const getRedirect = (chainId: string, safeAddress: string, redirectQuery?: strin // Otherwise, redirect to the provided URL (e.g. from a Safe App) // Track the redirect to Safe App + // TODO: Narrow this down to /apps only if (redirectUrl.includes('apps')) { trackSafeAppEvent({ ...SAFE_APPS_EVENTS.SHARED_APP_OPEN_AFTER_SAFE_CREATION, label: redirectUrl }) } // We're prepending the safe address directly here because the `router.push` doesn't parse // The URL for already existing query params + // TODO: Check if we can accomplish this with URLSearchParams or URL instead const hasQueryParams = redirectUrl.includes('?') const appendChar = hasQueryParams ? '&' : '?' return redirectUrl + `${appendChar}safe=${address}` diff --git a/src/components/create-safe/steps/OwnerRow.tsx b/src/components/create-safe/steps/OwnerRow.tsx index 7a2c959bc9..a7f96ae1b4 100644 --- a/src/components/create-safe/steps/OwnerRow.tsx +++ b/src/components/create-safe/steps/OwnerRow.tsx @@ -94,7 +94,7 @@ export const OwnerRow = ({ {index > 0 && ( <> remove?.(index)} size="small"> - + )} diff --git a/src/components/create-safe/steps/styles.module.css b/src/components/create-safe/steps/styles.module.css index 3bcf9f38f9..350996a9f4 100644 --- a/src/components/create-safe/steps/styles.module.css +++ b/src/components/create-safe/steps/styles.module.css @@ -32,6 +32,10 @@ gap: var(--space-1); } +.pairing > div:first-child { + align-items: center; +} + .pairing :global .MuiTypography-alignCenter { text-align: unset; } diff --git a/src/components/dashboard/CreationDialog/index.tsx b/src/components/dashboard/CreationDialog/index.tsx new file mode 100644 index 0000000000..bf4c2743d8 --- /dev/null +++ b/src/components/dashboard/CreationDialog/index.tsx @@ -0,0 +1,76 @@ +import React, { type ElementType } from 'react' +import { Box, Button, Dialog, DialogContent, Grid, SvgIcon, Typography } from '@mui/material' + +import HomeIcon from '@/public/images/sidebar/home.svg' +import TransactionIcon from '@/public/images/sidebar/transactions.svg' +import AppsIcon from '@/public/images/sidebar/apps.svg' +import SettingsIcon from '@/public/images/sidebar/settings.svg' +import BeamerIcon from '@/public/images/sidebar/whats-new.svg' +import HelpCenterIcon from '@/public/images/sidebar/help-center.svg' +import { useRemoteSafeApps } from '@/hooks/safe-apps/useRemoteSafeApps' +import { useCurrentChain } from '@/hooks/useChains' + +const HintItem = ({ Icon, title, description }: { Icon: ElementType; title: string; description: string }) => { + return ( + + + + + {title} + + + + {description} + + ) +} + +const CreationDialog = () => { + const [open, setOpen] = React.useState(true) + const [remoteSafeApps = []] = useRemoteSafeApps() + const chain = useCurrentChain() + + return ( + + + + Welcome to your Safe! + + + Congratulations on your first step to truly unlock ownership. Enjoy the experience and discover our app. + + + + + + + + + + + + + + + ) +} + +export default CreationDialog diff --git a/src/components/dashboard/FeaturedApps/FeaturedApps.tsx b/src/components/dashboard/FeaturedApps/FeaturedApps.tsx index b21e358c39..4f93a150c6 100644 --- a/src/components/dashboard/FeaturedApps/FeaturedApps.tsx +++ b/src/components/dashboard/FeaturedApps/FeaturedApps.tsx @@ -7,11 +7,7 @@ import NextLink from 'next/link' import { AppRoutes } from '@/config/routes' import { SafeAppsTag } from '@/config/constants' import { useRemoteSafeApps } from '@/hooks/safe-apps/useRemoteSafeApps' - -const StyledImage = styled.img` - width: 64px; - height: 64px; -` +import SafeAppIcon from '@/components/safe-apps/SafeAppIcon' const StyledGrid = styled(Grid)` gap: 24px; @@ -42,7 +38,7 @@ export const FeaturedApps = (): ReactElement | null => { - + diff --git a/src/components/dashboard/Overview/Overview.tsx b/src/components/dashboard/Overview/Overview.tsx index 19513a4847..988b8f3bd5 100644 --- a/src/components/dashboard/Overview/Overview.tsx +++ b/src/components/dashboard/Overview/Overview.tsx @@ -125,7 +125,7 @@ const Overview = (): ReactElement => { - + diff --git a/src/components/dashboard/index.tsx b/src/components/dashboard/index.tsx index 07f5009b28..d73197a1f9 100644 --- a/src/components/dashboard/index.tsx +++ b/src/components/dashboard/index.tsx @@ -4,26 +4,34 @@ import PendingTxsList from '@/components/dashboard/PendingTxs/PendingTxsList' import Overview from '@/components/dashboard/Overview/Overview' import { FeaturedApps } from '@/components/dashboard/FeaturedApps/FeaturedApps' import SafeAppsDashboardSection from '@/components/dashboard/SafeAppsDashboardSection/SafeAppsDashboardSection' +import CreationDialog from '@/components/dashboard/CreationDialog' +import { useRouter } from 'next/router' const Dashboard = (): ReactElement => { + const router = useRouter() + const { showCreationModal = '' } = router.query + return ( - - - - + <> + + + + - - - + + + - - - + + + - - + + + - + {showCreationModal ? : null} + ) } diff --git a/src/components/new-safe/CardStepper/index.tsx b/src/components/new-safe/CardStepper/index.tsx new file mode 100644 index 0000000000..e0a90a9d2b --- /dev/null +++ b/src/components/new-safe/CardStepper/index.tsx @@ -0,0 +1,40 @@ +import { useState } from 'react' +import { Box } from '@mui/system' +import css from './styles.module.css' +import { Card, LinearProgress, CardHeader, Avatar, Typography, CardContent } from '@mui/material' +import type { TxStepperProps } from './useCardStepper' +import { useCardStepper } from './useCardStepper' +import palette from '@/styles/colors' + +export function CardStepper(props: TxStepperProps) { + const [progressColor, setProgressColor] = useState(palette.secondary.main) + const { activeStep, onSubmit, onBack, stepData, setStep } = useCardStepper(props) + const { steps } = props + const currentStep = steps[activeStep] + const progress = ((activeStep + 1) / steps.length) * 100 + + return ( + + + + + {currentStep.title && ( + + {activeStep + 1} + + } + className={css.header} + /> + )} + + {currentStep.render(stepData, onSubmit, onBack, setStep, setProgressColor)} + + + ) +} diff --git a/src/components/new-safe/CardStepper/styles.module.css b/src/components/new-safe/CardStepper/styles.module.css new file mode 100644 index 0000000000..c7cdb6ec38 --- /dev/null +++ b/src/components/new-safe/CardStepper/styles.module.css @@ -0,0 +1,43 @@ +.card { + border: none; +} + +.header { + padding: var(--space-3) var(--space-2); + border-bottom: 1px solid var(--color-border-light); +} + +.header :global .MuiCardHeader-title { + font-weight: 700; +} + +.header :global .MuiCardHeader-subheader { + color: var(--color-text-primary); +} + +.step { + background-color: var(--color-primary-main); + height: 20px; + width: 20px; +} + +.content { + padding: 0 !important; +} + +.actions { + padding: var(--space-3) 52px; +} + +.progress :global .MuiLinearProgress-root::before { + display: none; +} + +@media (max-width: 600px) { + .header { + padding: var(--space-2); + flex-direction: column; + align-items: flex-start; + gap: var(--space-1); + } +} diff --git a/src/components/new-safe/CardStepper/useCardStepper.ts b/src/components/new-safe/CardStepper/useCardStepper.ts new file mode 100644 index 0000000000..c8abd82092 --- /dev/null +++ b/src/components/new-safe/CardStepper/useCardStepper.ts @@ -0,0 +1,88 @@ +import type { Dispatch, ReactElement, SetStateAction } from 'react' +import { useState } from 'react' +import { trackEvent, MODALS_CATEGORY } from '@/services/analytics' + +export type StepRenderProps = { + data: TData + onSubmit: (data: Partial) => void + onBack: (data?: Partial) => void + setStep: (step: number) => void + setProgressColor?: Dispatch> +} + +type Step = { + title: string + subtitle: string + render: ( + data: StepRenderProps['data'], + onSubmit: StepRenderProps['onSubmit'], + onBack: StepRenderProps['onBack'], + setStep: StepRenderProps['setStep'], + setProgressColor: StepRenderProps['setProgressColor'], + ) => ReactElement +} + +export type TxStepperProps = { + steps: Array> + initialData: TData + initialStep?: number + eventCategory?: string + setWidgetStep?: (step: number | SetStateAction) => void + onClose: () => void +} + +export const useCardStepper = ({ + steps, + initialData, + initialStep, + eventCategory = MODALS_CATEGORY, + onClose, + setWidgetStep, +}: TxStepperProps) => { + const [activeStep, setActiveStep] = useState(initialStep || 0) + const [stepData, setStepData] = useState(initialData) + + const handleNext = () => { + setActiveStep((prevActiveStep) => prevActiveStep + 1) + setWidgetStep && setWidgetStep((prevActiveStep) => prevActiveStep + 1) + trackEvent({ category: eventCategory, action: lastStep ? 'Submit' : 'Next', label: activeStep }) + } + + const handleBack = (data?: Partial) => { + setActiveStep((prevActiveStep) => prevActiveStep - 1) + setWidgetStep && setWidgetStep((prevActiveStep) => prevActiveStep - 1) + trackEvent({ category: eventCategory, action: firstStep ? 'Cancel' : 'Back', label: activeStep }) + + if (data) { + setStepData((previous) => ({ ...previous, ...data })) + } + } + + const setStep = (step: number) => { + setActiveStep(step) + setWidgetStep && setWidgetStep(step) + } + + const firstStep = activeStep === 0 + const lastStep = activeStep === steps.length - 1 + + const onBack = firstStep ? onClose : handleBack + + const onSubmit = (data: Partial) => { + if (lastStep) { + onClose() + return + } + setStepData((previous) => ({ ...previous, ...data })) + handleNext() + } + + return { + onBack, + onSubmit, + setStep, + activeStep, + stepData, + firstStep, + } +} diff --git a/src/components/new-safe/CreateSafe/__tests__/useSyncSafeCreationStep.test.ts b/src/components/new-safe/CreateSafe/__tests__/useSyncSafeCreationStep.test.ts new file mode 100644 index 0000000000..7472e03927 --- /dev/null +++ b/src/components/new-safe/CreateSafe/__tests__/useSyncSafeCreationStep.test.ts @@ -0,0 +1,40 @@ +import { renderHook } from '@/tests/test-utils' +import useSyncSafeCreationStep from '@/components/new-safe/CreateSafe/useSyncSafeCreationStep' +import * as wallet from '@/hooks/wallets/useWallet' +import * as localStorage from '@/services/local-storage/useLocalStorage' +import type { ConnectedWallet } from '@/services/onboard' + +describe('useSyncSafeCreationStep', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + it('should go to the first step if no wallet is connected', async () => { + jest.spyOn(wallet, 'default').mockReturnValue(null) + const mockSetStep = jest.fn() + + renderHook(() => useSyncSafeCreationStep(mockSetStep)) + + expect(mockSetStep).toHaveBeenCalledWith(0) + }) + + it('should go to the fourth step if there is a pending safe', async () => { + jest.spyOn(localStorage, 'default').mockReturnValue([{}, jest.fn()]) + jest.spyOn(wallet, 'default').mockReturnValue({ address: '0x1' } as ConnectedWallet) + const mockSetStep = jest.fn() + + renderHook(() => useSyncSafeCreationStep(mockSetStep)) + + expect(mockSetStep).toHaveBeenCalledWith(4) + }) + + it('should not do anything if wallet is connected and there is no pending safe', async () => { + jest.spyOn(localStorage, 'default').mockReturnValue([undefined, jest.fn()]) + jest.spyOn(wallet, 'default').mockReturnValue({ address: '0x1' } as ConnectedWallet) + const mockSetStep = jest.fn() + + renderHook(() => useSyncSafeCreationStep(mockSetStep)) + + expect(mockSetStep).not.toHaveBeenCalled() + }) +}) diff --git a/src/components/new-safe/CreateSafe/index.tsx b/src/components/new-safe/CreateSafe/index.tsx new file mode 100644 index 0000000000..280b83c0e2 --- /dev/null +++ b/src/components/new-safe/CreateSafe/index.tsx @@ -0,0 +1,213 @@ +import { Container, Typography, Grid, Link, SvgIcon } from '@mui/material' +import { useRouter } from 'next/router' + +import useWallet from '@/hooks/wallets/useWallet' +import OverviewWidget from '../OverviewWidget' +import type { NamedAddress } from '@/components/create-safe/types' +import type { TxStepperProps } from '../CardStepper/useCardStepper' +import CreateSafeStep0 from '@/components/new-safe/steps/Step0' +import CreateSafeStep1 from '@/components/new-safe/steps/Step1' +import CreateSafeStep2 from '@/components/new-safe/steps/Step2' +import CreateSafeStep3 from '@/components/new-safe/steps/Step3' +import { CreateSafeStatus } from '@/components/new-safe/steps/Step4' +import useAddressBook from '@/hooks/useAddressBook' +import { CardStepper } from '../CardStepper' +import { AppRoutes } from '@/config/routes' +import { CREATE_SAFE_CATEGORY } from '@/services/analytics' +import type { AlertColor } from '@mui/material' +import type { CreateSafeInfoItem } from '../CreateSafeInfos' +import CreateSafeInfos from '../CreateSafeInfos' +import { type ReactElement, useMemo, useState } from 'react' +import LinkIcon from '@/public/images/sidebar/link.svg' + +export type NewSafeFormData = { + name: string + threshold: number + owners: NamedAddress[] + saltNonce: number + safeAddress?: string +} + +const staticHints: Record< + number, + { title: string; variant: AlertColor; steps: { title: string; text: string | ReactElement }[] } +> = { + 1: { + title: 'Safe creation', + variant: 'info', + steps: [ + { + title: 'Network fee', + text: 'Deploying your Safe requires the payment of the associated network fee with your connected wallet. An estimation will be provided in the last step.', + }, + { + title: 'Address book privacy', + text: 'The name of your Safe will be stored in a local address book on your device and can be changed at a later stage. It will not be shared with us or any third party.', + }, + ], + }, + 2: { + title: 'Safe creation', + variant: 'info', + steps: [ + { + title: 'Flat hierarchy', + text: 'Every owner has the same rights within the Safe and can propose, sign and execute transactions that have the required confirmations.', + }, + { + title: 'Managing Owners', + text: 'You can always change the number of owners and required confirmations in your Safe after creation.', + }, + { + title: 'Safe setup', + text: ( + <> + Not sure how many owners and confirmations you need for your Safe? +
+ + Learn more about setting up your Safe. + + + + ), + }, + ], + }, + 3: { + title: 'Safe creation', + variant: 'info', + steps: [ + { + title: 'Wait for the creation', + text: 'Depending on network usage, it can take some time until the transaction is successfully added to the blockchain and picked up by our services.', + }, + ], + }, + 4: { + title: 'Safe usage', + variant: 'success', + steps: [ + { + title: 'Connect your Safe', + text: 'In our Safe Apps section you can connect your Safe to over 70 dApps directly or via Wallet Connect to interact with any application.', + }, + ], + }, +} + +const CreateSafe = () => { + const router = useRouter() + const wallet = useWallet() + const addressBook = useAddressBook() + const defaultOwnerAddressBookName = wallet?.address ? addressBook[wallet.address] : undefined + const defaultOwner: NamedAddress = { + name: defaultOwnerAddressBookName || wallet?.ens || '', + address: wallet?.address || '', + } + + const [safeName, setSafeName] = useState('') + const [dynamicHint, setDynamicHint] = useState() + const [activeStep, setActiveStep] = useState(0) + + const CreateSafeSteps: TxStepperProps['steps'] = [ + { + title: 'Connect wallet', + subtitle: 'The connected wallet will pay the network fees for the Safe creation.', + render: (data, onSubmit, onBack, setStep) => ( + + ), + }, + { + title: 'Select network and name your Safe', + subtitle: 'Select the network on which to create your Safe', + render: (data, onSubmit, onBack, setStep) => ( + + ), + }, + { + title: 'Owners and confirmations', + subtitle: 'Set the owner wallets of your Safe and how many need to confirm to execute a valid transaction.', + render: (data, onSubmit, onBack, setStep) => ( + + ), + }, + { + title: 'Review', + subtitle: + "You're about to create a new Safe and will have to confirm the transaction with your connected wallet.", + render: (data, onSubmit, onBack, setStep) => ( + + ), + }, + { + title: '', + subtitle: '', + render: (data, onSubmit, onBack, setStep, setProgressColor) => ( + + ), + }, + ] + + const staticHint = useMemo(() => staticHints[activeStep], [activeStep]) + + const initialData: NewSafeFormData = { + name: '', + owners: [defaultOwner], + threshold: 1, + saltNonce: Date.now(), + } + + const onClose = () => { + router.push(AppRoutes.welcome) + } + + return ( + + + + + Create new Safe + + + + + + + + + {activeStep < 3 && } + {wallet?.address && } + + + + + ) +} + +export default CreateSafe diff --git a/src/components/new-safe/CreateSafe/styles.module.css b/src/components/new-safe/CreateSafe/styles.module.css new file mode 100644 index 0000000000..2cae562226 --- /dev/null +++ b/src/components/new-safe/CreateSafe/styles.module.css @@ -0,0 +1,10 @@ +.row { + width: 100%; + padding: var(--space-4) var(--space-7); +} + +@media (max-width: 600px) { + .row { + padding: var(--space-2); + } +} diff --git a/src/components/new-safe/CreateSafe/useSyncSafeCreationStep.ts b/src/components/new-safe/CreateSafe/useSyncSafeCreationStep.ts new file mode 100644 index 0000000000..0ada098a50 --- /dev/null +++ b/src/components/new-safe/CreateSafe/useSyncSafeCreationStep.ts @@ -0,0 +1,25 @@ +import { useEffect } from 'react' +import useLocalStorage from '@/services/local-storage/useLocalStorage' +import type { StepRenderProps } from '@/components/new-safe/CardStepper/useCardStepper' +import type { PendingSafeData } from '@/components/new-safe/steps/Step4' +import type { NewSafeFormData } from '@/components/new-safe/CreateSafe/index' +import { SAFE_PENDING_CREATION_STORAGE_KEY } from '@/components/new-safe/steps/Step4' +import useWallet from '@/hooks/wallets/useWallet' + +const useSyncSafeCreationStep = (setStep: StepRenderProps['setStep']) => { + const [pendingSafe] = useLocalStorage(SAFE_PENDING_CREATION_STORAGE_KEY) + const wallet = useWallet() + + useEffect(() => { + if (!wallet) { + setStep(0) + } + + // Jump to the status screen if there is already a tx submitted + if (pendingSafe) { + setStep(4) + } + }, [wallet, setStep, pendingSafe]) +} + +export default useSyncSafeCreationStep diff --git a/src/components/new-safe/CreateSafeInfos/index.tsx b/src/components/new-safe/CreateSafeInfos/index.tsx new file mode 100644 index 0000000000..821d03fab1 --- /dev/null +++ b/src/components/new-safe/CreateSafeInfos/index.tsx @@ -0,0 +1,46 @@ +import InfoWidget from '@/components/create-safe/InfoWidget' +import { Grid } from '@mui/material' +import type { AlertColor } from '@mui/material' +import { type ReactElement } from 'react' + +export type CreateSafeInfoItem = { + title: string + variant: AlertColor + steps: { title: string; text: string | ReactElement }[] +} + +const CreateSafeInfos = ({ + staticHint, + dynamicHint, +}: { + staticHint?: CreateSafeInfoItem + dynamicHint?: CreateSafeInfoItem +}) => { + if (!staticHint && !dynamicHint) { + return null + } + + return ( + + + {staticHint && ( + + + + )} + {dynamicHint && ( + + + + )} + + + ) +} + +export default CreateSafeInfos diff --git a/src/components/new-safe/NetworkWarning/index.tsx b/src/components/new-safe/NetworkWarning/index.tsx new file mode 100644 index 0000000000..edb1477b36 --- /dev/null +++ b/src/components/new-safe/NetworkWarning/index.tsx @@ -0,0 +1,17 @@ +import { Alert, AlertTitle } from '@mui/material' +import { useCurrentChain } from '@/hooks/useChains' + +const NetworkWarning = () => { + const chain = useCurrentChain() + + if (!chain) return null + + return ( + + Change your wallet network + You are trying to create a Safe on {chain.chainName}. Make sure that your wallet is set to the same network. + + ) +} + +export default NetworkWarning diff --git a/src/components/new-safe/OverviewWidget/index.tsx b/src/components/new-safe/OverviewWidget/index.tsx new file mode 100644 index 0000000000..dace52829a --- /dev/null +++ b/src/components/new-safe/OverviewWidget/index.tsx @@ -0,0 +1,48 @@ +import ChainIndicator from '@/components/common/ChainIndicator' +import WalletInfo from '@/components/common/WalletInfo' +import { useCurrentChain } from '@/hooks/useChains' +import useWallet from '@/hooks/wallets/useWallet' +import { Card, Grid, Typography } from '@mui/material' +import type { ReactElement } from 'react' +import SafeLogo from '@/public/images/logo-no-text.svg' + +import css from './styles.module.css' + +const LOGO_DIMENSIONS = '22px' + +const OverviewWidget = ({ safeName }: { safeName: string }): ReactElement | null => { + const wallet = useWallet() + const chain = useCurrentChain() + const rows = [ + ...(wallet && chain ? [{ title: 'Wallet', component: }] : []), + ...(chain ? [{ title: 'Network', component: }] : []), + ...(safeName !== '' ? [{ title: 'Name', component: {safeName} }] : []), + ] + + return ( + + +
+ + Your Safe preview +
+ {wallet ? ( + rows.map((row) => ( +
+ {row.title} + {row.component} +
+ )) + ) : ( +
+ + Connect your wallet to continue + +
+ )} +
+
+ ) +} + +export default OverviewWidget diff --git a/src/components/create-safe/OverviewWidget/styles.module.css b/src/components/new-safe/OverviewWidget/styles.module.css similarity index 87% rename from src/components/create-safe/OverviewWidget/styles.module.css rename to src/components/new-safe/OverviewWidget/styles.module.css index 98c0c668fc..c7e87b7dbe 100644 --- a/src/components/create-safe/OverviewWidget/styles.module.css +++ b/src/components/new-safe/OverviewWidget/styles.module.css @@ -1,6 +1,6 @@ .card { border: 1px solid var(--color-border-light); - width: 300px; /* TODO: Remove when added to flow */ + width: 100%; } .header { diff --git a/src/components/new-safe/steps/Step0/index.tsx b/src/components/new-safe/steps/Step0/index.tsx new file mode 100644 index 0000000000..ee2864f922 --- /dev/null +++ b/src/components/new-safe/steps/Step0/index.tsx @@ -0,0 +1,61 @@ +import { useEffect } from 'react' +import { Box, Button, Grid, Typography } from '@mui/material' +import useWallet from '@/hooks/wallets/useWallet' +import { useCurrentChain } from '@/hooks/useChains' +import { isPairingSupported } from '@/services/pairing/utils' + +import type { NewSafeFormData } from '@/components/new-safe/CreateSafe' +import type { StepRenderProps } from '@/components/new-safe/CardStepper/useCardStepper' +import useSyncSafeCreationStep from '@/components/new-safe/CreateSafe/useSyncSafeCreationStep' +import layoutCss from '@/components/new-safe/CreateSafe/styles.module.css' +import useLocalStorage from '@/services/local-storage/useLocalStorage' +import { type PendingSafeData, SAFE_PENDING_CREATION_STORAGE_KEY } from '@/components/new-safe/steps/Step4' +import useConnectWallet from '@/components/common/ConnectWallet/useConnectWallet' +import KeyholeIcon from '@/components/common/icons/KeyholeIcon' +import PairingDescription from '@/components/common/PairingDetails/PairingDescription' +import PairingQRCode from '@/components/common/PairingDetails/PairingQRCode' + +const CreateSafeStep0 = ({ onSubmit, setStep }: StepRenderProps) => { + const [pendingSafe] = useLocalStorage(SAFE_PENDING_CREATION_STORAGE_KEY) + const wallet = useWallet() + const chain = useCurrentChain() + const isSupported = isPairingSupported(chain?.disabledWallets) + const handleConnect = useConnectWallet() + useSyncSafeCreationStep(setStep) + + useEffect(() => { + if (!wallet || pendingSafe) return + + onSubmit({ owners: [{ address: wallet.address, name: wallet.ens || '' }] }) + }, [onSubmit, wallet, pendingSafe]) + + return ( + <> + + + + + + + + + + + {isSupported && ( + + + + Connect to Safe mobile + + + + )} + + + + ) +} + +export default CreateSafeStep0 diff --git a/src/components/new-safe/steps/Step1/index.tsx b/src/components/new-safe/steps/Step1/index.tsx new file mode 100644 index 0000000000..88b397c2aa --- /dev/null +++ b/src/components/new-safe/steps/Step1/index.tsx @@ -0,0 +1,120 @@ +import { InputAdornment, Tooltip, SvgIcon, Typography, Link, Box, Divider, Button, Grid } from '@mui/material' +import { FormProvider, useForm } from 'react-hook-form' +import { useMnemonicSafeName } from '@/hooks/useMnemonicName' +import InfoIcon from '@/public/images/notifications/info.svg' +import NetworkSelector from '@/components/common/NetworkSelector' +import type { StepRenderProps } from '../../CardStepper/useCardStepper' +import type { NewSafeFormData } from '../../CreateSafe' +import useSyncSafeCreationStep from '@/components/new-safe/CreateSafe/useSyncSafeCreationStep' + +import css from './styles.module.css' +import layoutCss from '@/components/new-safe/CreateSafe/styles.module.css' +import useIsWrongChain from '@/hooks/useIsWrongChain' +import NetworkWarning from '@/components/new-safe/NetworkWarning' +import NameInput from '@/components/common/NameInput' +import { CREATE_SAFE_EVENTS, trackEvent } from '@/services/analytics' + +type CreateSafeStep1Form = { + name: string +} + +enum CreateSafeStep1Fields { + name = 'name', +} + +const STEP_1_FORM_ID = 'create-safe-step-1-form' + +function CreateSafeStep1({ + data, + onSubmit, + setStep, + setSafeName, +}: StepRenderProps & { setSafeName: (name: string) => void }) { + const fallbackName = useMnemonicSafeName() + const isWrongChain = useIsWrongChain() + useSyncSafeCreationStep(setStep) + + const formMethods = useForm({ + mode: 'all', + defaultValues: { + [CreateSafeStep1Fields.name]: data.name, + }, + }) + + const { + handleSubmit, + formState: { errors, isValid }, + } = formMethods + + const onFormSubmit = (data: Pick) => { + const name = data.name || fallbackName + setSafeName(name) + onSubmit({ ...data, name }) + + if (data.name) { + trackEvent(CREATE_SAFE_EVENTS.NAME_SAFE) + } + } + + const isDisabled = isWrongChain || !isValid + + return ( + +
+ + + + + + + + + ), + }} + /> + + + + + + + + + By continuing, you agree to our{' '} + + terms of use + {' '} + and{' '} + + privacy policy + + . + + + {isWrongChain && } + + + + + + + + +
+ ) +} + +export default CreateSafeStep1 diff --git a/src/components/new-safe/steps/Step1/styles.module.css b/src/components/new-safe/steps/Step1/styles.module.css new file mode 100644 index 0000000000..e273a1c87e --- /dev/null +++ b/src/components/new-safe/steps/Step1/styles.module.css @@ -0,0 +1,22 @@ +.card { + border: none; +} + +.select { + display: flex; + align-items: center; + border-radius: 8px; + border: 1px solid var(--color-border-light); + height: 56px; +} + +.select:hover, +.select:hover .networkSelect { + border-color: var(--color-primary-main); +} + +.networkSelect { + border-left: 1px solid var(--color-border-light); + padding: var(--space-2); + margin-left: var(--space-2); +} diff --git a/src/components/new-safe/steps/Step2/OwnerRow.tsx b/src/components/new-safe/steps/Step2/OwnerRow.tsx new file mode 100644 index 0000000000..e582cd560e --- /dev/null +++ b/src/components/new-safe/steps/Step2/OwnerRow.tsx @@ -0,0 +1,125 @@ +import { useCallback, useEffect, useMemo } from 'react' +import { CircularProgress, FormControl, Grid, IconButton, SvgIcon } from '@mui/material' +import NameInput from '@/components/common/NameInput' +import InputAdornment from '@mui/material/InputAdornment' +import AddressBookInput from '@/components/common/AddressBookInput' +import DeleteIcon from '@/public/images/common/delete.svg' +import { useFormContext, useWatch } from 'react-hook-form' +import { useAddressResolver } from '@/hooks/useAddressResolver' +import EthHashInfo from '@/components/common/EthHashInfo' +import type { NamedAddress } from '@/components/create-safe/types' +import useWallet from '@/hooks/wallets/useWallet' +import { sameAddress } from '@/utils/addresses' + +/** + * TODO: this is a slightly modified copy of the old /create-safe/OwnerRow.tsx + * Once we remove the old safe creation flow we should remove the old file. + */ +export const OwnerRow = ({ + index, + groupName, + removable = true, + remove, + readOnly = false, +}: { + index: number + removable?: boolean + groupName: string + remove?: (index: number) => void + readOnly?: boolean +}) => { + const wallet = useWallet() + const fieldName = `${groupName}.${index}` + const { control, getValues, setValue } = useFormContext() + const owners = useWatch({ + control, + name: groupName, + }) + const owner = useWatch({ + control, + name: fieldName, + }) + + const deps = useMemo(() => { + return Array.from({ length: owners.length }, (_, i) => `${groupName}.${i}`) + }, [owners, groupName]) + + const validateSafeAddress = useCallback( + async (address: string) => { + if (owners.filter((owner: NamedAddress) => sameAddress(owner.address, address)).length > 1) { + return 'Owner is already added' + } + }, + [owners], + ) + + const { ens, name, resolving } = useAddressResolver(owner.address) + + useEffect(() => { + if (ens) { + setValue(`${fieldName}.ens`, ens) + } + + if (name && !getValues(`${fieldName}.name`)) { + setValue(`${fieldName}.name`, name) + } + }, [ens, setValue, getValues, name, fieldName]) + + return ( + + + + + + + ) : null, + }} + /> + + + + {readOnly ? ( + + ) : ( + + + + )} + + {!readOnly && ( + + {removable && ( + <> + remove?.(index)}> + + + + )} + + )} + + ) +} diff --git a/src/components/new-safe/steps/Step2/index.tsx b/src/components/new-safe/steps/Step2/index.tsx new file mode 100644 index 0000000000..f03e83f39c --- /dev/null +++ b/src/components/new-safe/steps/Step2/index.tsx @@ -0,0 +1,182 @@ +import { Button, SvgIcon, MenuItem, Select, Tooltip, Typography, Divider, Box } from '@mui/material' +import { Controller, FormProvider, useFieldArray, useForm } from 'react-hook-form' +import type { ReactElement } from 'react' + +import AddIcon from '@/public/images/common/add.svg' +import InfoIcon from '@/public/images/notifications/info.svg' +import { OwnerRow } from './OwnerRow' +import type { NamedAddress } from '@/components/create-safe/types' +import type { StepRenderProps } from '../../CardStepper/useCardStepper' +import type { NewSafeFormData } from '../../CreateSafe' +import type { CreateSafeInfoItem } from '../../CreateSafeInfos' +import { useSafeSetupHints } from './useSafeSetupHints' +import useSyncSafeCreationStep from '@/components/new-safe/CreateSafe/useSyncSafeCreationStep' +import ArrowBackIcon from '@mui/icons-material/ArrowBack' +import css from './styles.module.css' +import layoutCss from '@/components/new-safe/CreateSafe/styles.module.css' +import NetworkWarning from '@/components/new-safe/NetworkWarning' +import useIsWrongChain from '@/hooks/useIsWrongChain' +import { CREATE_SAFE_EVENTS, trackEvent } from '@/services/analytics' + +enum CreateSafeStep2Fields { + owners = 'owners', + threshold = 'threshold', +} + +export type CreateSafeStep2Form = { + [CreateSafeStep2Fields.owners]: NamedAddress[] + [CreateSafeStep2Fields.threshold]: number +} + +const STEP_2_FORM_ID = 'create-safe-step-2-form' + +const CreateSafeStep2 = ({ + onSubmit, + onBack, + data, + setStep, + setDynamicHint, +}: StepRenderProps & { + setDynamicHint: (hints: CreateSafeInfoItem | undefined) => void +}): ReactElement => { + const isWrongChain = useIsWrongChain() + useSyncSafeCreationStep(setStep) + + const formMethods = useForm({ + mode: 'all', + defaultValues: { + [CreateSafeStep2Fields.owners]: data.owners, + [CreateSafeStep2Fields.threshold]: data.threshold, + }, + }) + + const { handleSubmit, control, watch, formState, getValues, setValue } = formMethods + + const threshold = watch(CreateSafeStep2Fields.threshold) + + const { + fields: ownerFields, + append: appendOwner, + remove, + } = useFieldArray({ control, name: CreateSafeStep2Fields.owners }) + + const removeOwner = (index: number): void => { + // Set threshold if it's greater than the number of owners + setValue(CreateSafeStep2Fields.threshold, Math.min(threshold, ownerFields.length - 1)) + remove(index) + } + + const isDisabled = isWrongChain || !formState.isValid + + useSafeSetupHints(threshold, ownerFields.length, setDynamicHint) + + const handleBack = () => { + const formData = getValues() + onBack(formData) + } + + const onFormSubmit = handleSubmit((data) => { + onSubmit(data) + + trackEvent({ + ...CREATE_SAFE_EVENTS.OWNERS, + label: data.owners.length, + }) + + trackEvent({ + ...CREATE_SAFE_EVENTS.THRESHOLD, + label: data.threshold, + }) + }) + + return ( +
+ + + {ownerFields.map((field, i) => ( + 0} + groupName={CreateSafeStep2Fields.owners} + remove={removeOwner} + /> + ))} + + + + Safe Mobile owner key (optional){' '} + + + + + + + Use your mobile phone as an additional owner key + + + + + + + Threshold + + + + + + + + Any transaction requires the confirmation of: + + + ( + + )} + />{' '} + out of {ownerFields.length} owner(s). + + + {isWrongChain && } + + + + + + + + + +
+ ) +} + +export default CreateSafeStep2 diff --git a/src/components/new-safe/steps/Step2/styles.module.css b/src/components/new-safe/steps/Step2/styles.module.css new file mode 100644 index 0000000000..c4083eb1ab --- /dev/null +++ b/src/components/new-safe/steps/Step2/styles.module.css @@ -0,0 +1,12 @@ +.select { + margin-right: var(--space-1); +} + +.select :global .MuiOutlinedInput-notchedOutline { + border-color: var(--color-border-light); + border-width: 2px; +} + +.select :global .MuiSelect-select { + padding: 12px 14px; +} diff --git a/src/components/new-safe/steps/Step2/useSafeSetupHints.ts b/src/components/new-safe/steps/Step2/useSafeSetupHints.ts new file mode 100644 index 0000000000..dad2b00e39 --- /dev/null +++ b/src/components/new-safe/steps/Step2/useSafeSetupHints.ts @@ -0,0 +1,35 @@ +import { useEffect } from 'react' +import type { CreateSafeInfoItem } from '../../CreateSafeInfos' + +export const useSafeSetupHints = ( + threshold: number, + noOwners: number, + setHint: (hint: CreateSafeInfoItem | undefined) => void, +) => { + useEffect(() => { + const safeSetupWarningSteps: { title: string; text: string }[] = [] + + // 1/n warning + if (threshold === 1) { + safeSetupWarningSteps.push({ + title: `1/${noOwners} policy`, + text: 'We recommend using a threshold higher than one to prevent losing access to your Safe in case an owner key is lost or compromised.', + }) + } + + // n/n warning + if (threshold === noOwners && noOwners > 1) { + safeSetupWarningSteps.push({ + title: `${noOwners}/${noOwners} policy`, + text: 'We recommend using a threshold which is lower than the total number of owners of your Safe in case an owner loses access to their account and needs to be replaced.', + }) + } + + setHint({ title: 'Safe setup', variant: 'warning', steps: safeSetupWarningSteps }) + + // Clear dynamic hints when the step / hook unmounts + return () => { + setHint(undefined) + } + }, [threshold, noOwners, setHint]) +} diff --git a/src/components/new-safe/steps/Step3/index.tsx b/src/components/new-safe/steps/Step3/index.tsx new file mode 100644 index 0000000000..1d2b68bab0 --- /dev/null +++ b/src/components/new-safe/steps/Step3/index.tsx @@ -0,0 +1,174 @@ +import { useMemo, type ReactElement } from 'react' +import { Button, Grid, Typography, Divider, Box } from '@mui/material' +import ChainIndicator from '@/components/common/ChainIndicator' +import EthHashInfo from '@/components/common/EthHashInfo' +import { useCurrentChain } from '@/hooks/useChains' +import useGasPrice from '@/hooks/useGasPrice' +import { useEstimateSafeCreationGas } from '@/components/create-safe/useEstimateSafeCreationGas' +import { formatVisualAmount } from '@/utils/formatters' +import type { StepRenderProps } from '@/components/new-safe/CardStepper/useCardStepper' +import type { NewSafeFormData } from '@/components/new-safe/CreateSafe' +import css from './styles.module.css' +import layoutCss from '@/components/new-safe/CreateSafe/styles.module.css' +import { getFallbackHandlerContractInstance } from '@/services/contracts/safeContracts' +import { computeNewSafeAddress } from '@/components/create-safe/logic' +import useWallet from '@/hooks/wallets/useWallet' +import { useWeb3 } from '@/hooks/wallets/web3' +import useLocalStorage from '@/services/local-storage/useLocalStorage' +import { type PendingSafeData, SAFE_PENDING_CREATION_STORAGE_KEY } from '@/components/new-safe/steps/Step4' +import useSyncSafeCreationStep from '@/components/new-safe/CreateSafe/useSyncSafeCreationStep' +import ArrowBackIcon from '@mui/icons-material/ArrowBack' +import NetworkWarning from '@/components/new-safe/NetworkWarning' +import useIsWrongChain from '@/hooks/useIsWrongChain' +import palette from '@/styles/colors' + +const ReviewRow = ({ name, value }: { name: string; value: ReactElement }) => { + return ( + <> + + {name} + + + {value} + + + ) +} + +const CreateSafeStep3 = ({ data, onSubmit, onBack, setStep }: StepRenderProps) => { + const isWrongChain = useIsWrongChain() + useSyncSafeCreationStep(setStep) + const chain = useCurrentChain() + const wallet = useWallet() + const provider = useWeb3() + const { maxFeePerGas, maxPriorityFeePerGas } = useGasPrice() + const saltNonce = useMemo(() => Date.now(), []) + const [_, setPendingSafe] = useLocalStorage(SAFE_PENDING_CREATION_STORAGE_KEY) + + const safeParams = useMemo(() => { + return { + owners: data.owners.map((owner) => owner.address), + threshold: data.threshold, + saltNonce, + } + }, [data.owners, data.threshold, saltNonce]) + + const { gasLimit } = useEstimateSafeCreationGas(safeParams) + + const totalFee = + gasLimit && maxFeePerGas && maxPriorityFeePerGas + ? formatVisualAmount(maxFeePerGas.add(maxPriorityFeePerGas).mul(gasLimit), chain?.nativeCurrency.decimals) + : '> 0.001' + + const handleBack = () => { + onBack(data) + } + + const createSafe = async () => { + if (!wallet || !provider || !chain) return + + const fallbackHandler = getFallbackHandlerContractInstance(chain.chainId) + + const props = { + safeAccountConfig: { + threshold: data.threshold, + owners: data.owners.map((owner) => owner.address), + fallbackHandler: fallbackHandler.address, + }, + safeDeploymentConfig: { + saltNonce: saltNonce.toString(), + }, + } + + const safeAddress = await computeNewSafeAddress(provider, props) + + setPendingSafe({ ...data, saltNonce, safeAddress }) + onSubmit({ ...data, saltNonce, safeAddress }) + } + + return ( + <> + + + } /> + {data.name}
} /> + + {data.owners.map((owner, index) => ( + + ))} + + } + /> + + {data.threshold} out of {data.owners.length} owner(s) + + } + /> + + + + + + + + + + + + ≈ {totalFee} {chain?.nativeCurrency.symbol} + + + + + You will have to confirm a transaction with your connected wallet. + + + } + /> + + + + {isWrongChain && } + + + + + + + + + + ) +} + +export default CreateSafeStep3 diff --git a/src/components/new-safe/steps/Step3/styles.module.css b/src/components/new-safe/steps/Step3/styles.module.css new file mode 100644 index 0000000000..31a8389b86 --- /dev/null +++ b/src/components/new-safe/steps/Step3/styles.module.css @@ -0,0 +1,5 @@ +.ownersArray { + display: flex; + flex-direction: column; + gap: var(--space-2); +} diff --git a/src/components/new-safe/steps/Step4/LoadingSpinner/index.tsx b/src/components/new-safe/steps/Step4/LoadingSpinner/index.tsx new file mode 100644 index 0000000000..2de0501100 --- /dev/null +++ b/src/components/new-safe/steps/Step4/LoadingSpinner/index.tsx @@ -0,0 +1,77 @@ +import { Box } from '@mui/material' +import css from './styles.module.css' +import classnames from 'classnames' +import { SafeCreationStatus } from '@/components/new-safe/steps/Step4/useSafeCreation' +import { useCallback, useEffect, useRef } from 'react' + +const rectTlEndTransform = 'translateX(0) translateY(20px) scaleY(1.1)' +const rectTrEndTransform = 'translateX(30px) scaleX(2.3)' +const rectBlEndTransform = 'translateX(30px) translateY(60px) scaleX(2.3)' +const rectBrEndTransform = 'translateY(40px) translateX(60px) scaleY(1.1)' + +const moveToEnd = (transformEnd: string, element: HTMLDivElement | null) => { + if (element) { + element.getAnimations().forEach((animation) => { + if ((animation as CSSAnimation).animationName) { + animation.pause() + } + }) + const transformStart = window.getComputedStyle(element).transform + element.getAnimations().forEach((animation) => { + if ((animation as CSSAnimation).animationName) { + animation.cancel() + } + }) + element.animate([{ transform: transformStart }, { transform: transformEnd }], { + duration: 1000, + easing: 'ease-out', + fill: 'forwards', + }) + } +} + +const LoadingSpinner = ({ status }: { status: SafeCreationStatus }) => { + const isError = status >= SafeCreationStatus.WALLET_REJECTED && status <= SafeCreationStatus.TIMEOUT + const isSuccess = status >= SafeCreationStatus.SUCCESS + + const rectTl = useRef(null) + const rectTr = useRef(null) + const rectBl = useRef(null) + const rectBr = useRef(null) + const rectCenter = useRef(null) + + const onFinish = useCallback(() => { + moveToEnd(rectTlEndTransform, rectTl.current) + moveToEnd(rectTrEndTransform, rectTr.current) + moveToEnd(rectBlEndTransform, rectBl.current) + moveToEnd(rectBrEndTransform, rectBr.current) + }, [rectBl, rectBr, rectTl, rectTr]) + + useEffect(() => { + if (isSuccess) { + onFinish() + } + }, [isSuccess, onFinish]) + + return ( + +
+
+
+
+
+ + + + + + + + + + + + ) +} + +export default LoadingSpinner diff --git a/src/components/new-safe/steps/Step4/LoadingSpinner/styles.module.css b/src/components/new-safe/steps/Step4/LoadingSpinner/styles.module.css new file mode 100644 index 0000000000..4dcb5c0b32 --- /dev/null +++ b/src/components/new-safe/steps/Step4/LoadingSpinner/styles.module.css @@ -0,0 +1,123 @@ +.box { + width: 80px; + height: 80px; + margin: auto; + position: relative; + filter: url('#gooey'); +} + +.rectError .rect { + background-color: #ff5f72; + animation-play-state: paused; +} + +.rectSuccess .rectCenter { + visibility: visible; + transform: translateY(30px) translateX(30px) scale(1); +} + +.rect { + position: absolute; + left: 0; + top: 0; + width: 20px; + height: 20px; + background-color: #12ff80; + transition: background-color 0.1s; +} + +.rectTl { + animation: rect-anim-tl ease-in-out 4s infinite; + animation-delay: 0.1s; +} + +.rectTr { + animation: rect-anim-tr ease-in-out 4s infinite; +} + +.rectBl { + animation: rect-anim-bl ease-in-out 4s infinite; +} + +.rectCenter { + visibility: hidden; + animation: none; + transition: transform 1s ease-out; + transform: translateY(30px) translateX(30px) scale(0); +} + +.rectBr { + animation: rect-anim-br ease-in-out 4s infinite; +} + +@keyframes rect-anim-tl { + 0% { + transform: translateX(0) translateY(0) scale(2); + } + 25% { + transform: translateX(50px) translateY(0) scale(1); + } + 50% { + transform: translateX(50px) translateY(50px) scale(2); + } + 75% { + transform: translateX(0) translateY(50px) scale(1); + } + 100% { + transform: translateX(0) translateY(0) scale(2); + } +} + +@keyframes rect-anim-tr { + 0% { + transform: translateX(50px) translateY(0) scale(1); + } + 25% { + transform: translateX(50px) translateY(50px) scale(2); + } + 50% { + transform: translateX(0) translateY(50px) scale(1); + } + 75% { + transform: translateX(0) translateY(0) scale(2); + } + 100% { + transform: translateX(50px) translateY(0) scale(1); + } +} + +@keyframes rect-anim-br { + 0% { + transform: translateX(50px) translateY(50px) scale(2); + } + 25% { + transform: translateX(0) translateY(50px) scale(1); + } + 50% { + transform: translateX(0) translateY(0) scale(2); + } + 75% { + transform: translateX(50px) translateY(0) scale(1); + } + 100% { + transform: translateX(50px) translateY(50px) scale(2); + } +} + +@keyframes rect-anim-bl { + 0% { + transform: translateX(0) translateY(50px) scale(1); + } + 25% { + transform: translateX(0) translateY(0) scale(2); + } + 50% { + transform: translateX(50px) translateY(0) scale(1); + } + 75% { + transform: translateX(50px) translateY(50px) scale(2); + } + 100% { + transform: translateX(0) translateY(50px) scale(1); + } +} diff --git a/src/components/new-safe/steps/Step4/StatusMessage.tsx b/src/components/new-safe/steps/Step4/StatusMessage.tsx new file mode 100644 index 0000000000..675337ab32 --- /dev/null +++ b/src/components/new-safe/steps/Step4/StatusMessage.tsx @@ -0,0 +1,91 @@ +import { Box, Typography } from '@mui/material' +import { SafeCreationStatus } from './useSafeCreation' +import LoadingSpinner from '@/components/new-safe/steps/Step4/LoadingSpinner' + +const getStep = (status: SafeCreationStatus) => { + const ERROR_TEXT = 'Please cancel the process or retry the transaction.' + + switch (status) { + case SafeCreationStatus.AWAITING: + return { + description: 'Waiting for transaction confirmation.', + instruction: 'Please confirm the transaction with your connected wallet.', + } + case SafeCreationStatus.WALLET_REJECTED: + return { + description: 'Transaction was rejected.', + instruction: ERROR_TEXT, + } + case SafeCreationStatus.PROCESSING: + return { + description: 'Transaction is being executed.', + instruction: 'Please do not leave this page.', + } + case SafeCreationStatus.ERROR: + return { + description: 'There was an error.', + instruction: ERROR_TEXT, + } + case SafeCreationStatus.REVERTED: + return { + description: 'Transaction was reverted.', + instruction: ERROR_TEXT, + } + case SafeCreationStatus.TIMEOUT: + return { + description: 'Transaction was not found. Be aware that it might still be processed.', + instruction: ERROR_TEXT, + } + case SafeCreationStatus.SUCCESS: + return { + description: 'Your Safe was successfully created!', + instruction: 'It is now being indexed. Please do not leave this page.', + } + case SafeCreationStatus.INDEXED: + return { + description: 'Your Safe was successfully created!', + instruction: '', + } + case SafeCreationStatus.INDEX_FAILED: + return { + description: 'Your Safe is created and will be picked up by our services shortly.', + instruction: + 'You can already open your Safe. It might take a moment until it becomes fully usable in the interface.', + } + } +} + +const StatusMessage = ({ status, isError }: { status: SafeCreationStatus; isError: boolean }) => { + const stepInfo = getStep(status) + + const color = isError ? 'success' : 'warning' + + return ( + <> + + + + {stepInfo.description} + + + {stepInfo.instruction && ( + ({ + backgroundColor: palette[color].background, + borderColor: palette[color].light, + borderWidth: 1, + borderStyle: 'solid', + borderRadius: '6px', + })} + padding={3} + mt={4} + mb={0} + > + {stepInfo.instruction} + + )} + + ) +} + +export default StatusMessage diff --git a/src/components/new-safe/steps/Step4/StatusStep.tsx b/src/components/new-safe/steps/Step4/StatusStep.tsx new file mode 100644 index 0000000000..7d68272dd2 --- /dev/null +++ b/src/components/new-safe/steps/Step4/StatusStep.tsx @@ -0,0 +1,45 @@ +import type { ReactNode } from 'react' +import { Box, Skeleton, StepLabel, SvgIcon } from '@mui/material' +import css from '@/components/new-safe/steps/Step4/styles.module.css' +import CircleIcon from '@mui/icons-material/Circle' +import CircleOutlinedIcon from '@mui/icons-material/CircleOutlined' +import Identicon from '@/components/common/Identicon' + +const StatusStep = ({ + isLoading, + safeAddress, + children, +}: { + isLoading: boolean + safeAddress?: string + children: ReactNode +}) => { + const Icon = isLoading ? CircleOutlinedIcon : CircleIcon + const color = isLoading ? 'border' : 'primary' + + return ( + } + > + (isLoading ? palette.border.main : palette.text.primary) }} + > + + {safeAddress && !isLoading ? ( + + ) : ( + + )} + + {children} + + + ) +} + +export default StatusStep diff --git a/src/components/new-safe/steps/Step4/StatusStepper.tsx b/src/components/new-safe/steps/Step4/StatusStepper.tsx new file mode 100644 index 0000000000..81f7b77ac3 --- /dev/null +++ b/src/components/new-safe/steps/Step4/StatusStepper.tsx @@ -0,0 +1,67 @@ +import { Box, Step, StepConnector, Stepper, Typography } from '@mui/material' +import css from '@/components/new-safe/steps/Step4/styles.module.css' +import EthHashInfo from '@/components/common/EthHashInfo' +import { SafeCreationStatus } from '@/components/new-safe/steps/Step4/useSafeCreation' +import type { PendingSafeData } from '@/components/new-safe/steps/Step4/index' +import StatusStep from '@/components/new-safe/steps/Step4/StatusStep' + +const StatusStepper = ({ pendingSafe, status }: { pendingSafe: PendingSafeData; status: SafeCreationStatus }) => { + if (!pendingSafe?.safeAddress) return null + + return ( + }> + + + + + Your Safe address + + + + + + + + + + Validating transaction + + {pendingSafe.txHash && ( + + )} + + + + + + + Processing + + + + + + + Safe is ready + + + + + ) +} + +export default StatusStepper diff --git a/src/components/new-safe/steps/Step4/index.tsx b/src/components/new-safe/steps/Step4/index.tsx new file mode 100644 index 0000000000..aa826347ae --- /dev/null +++ b/src/components/new-safe/steps/Step4/index.tsx @@ -0,0 +1,142 @@ +import { useCallback, useEffect, useState } from 'react' +import { Box, Button, Divider, Paper, Tooltip, Typography } from '@mui/material' +import { useRouter } from 'next/router' + +import Track from '@/components/common/Track' +import { CREATE_SAFE_EVENTS } from '@/services/analytics/events/createLoadSafe' +import useLocalStorage from '@/services/local-storage/useLocalStorage' +import StatusMessage from '@/components/new-safe/steps/Step4/StatusMessage' +import useWallet from '@/hooks/wallets/useWallet' +import useIsWrongChain from '@/hooks/useIsWrongChain' +import type { NewSafeFormData } from '@/components/new-safe/CreateSafe' +import type { StepRenderProps } from '@/components/new-safe/CardStepper/useCardStepper' +import type { PendingSafeTx } from '@/components/create-safe/types.d' +import useSafeCreationEffects from '@/components/new-safe/steps/Step4/useSafeCreationEffects' +import { SafeCreationStatus, useSafeCreation } from '@/components/new-safe/steps/Step4/useSafeCreation' +import StatusStepper from '@/components/new-safe/steps/Step4/StatusStepper' +import { trackEvent } from '@/services/analytics' +import useChainId from '@/hooks/useChainId' +import { getRedirect } from '@/components/new-safe/steps/Step4/logic' +import layoutCss from '@/components/new-safe/CreateSafe/styles.module.css' +import { AppRoutes } from '@/config/routes' +import palette from '@/styles/colors' + +export const SAFE_PENDING_CREATION_STORAGE_KEY = 'pendingSafe' + +export type PendingSafeData = NewSafeFormData & { + txHash?: string + tx?: PendingSafeTx +} + +export const CreateSafeStatus = ({ setProgressColor }: StepRenderProps) => { + const [status, setStatus] = useState(SafeCreationStatus.AWAITING) + const [pendingSafe, setPendingSafe] = useLocalStorage(SAFE_PENDING_CREATION_STORAGE_KEY) + const router = useRouter() + const chainId = useChainId() + const wallet = useWallet() + const isWrongChain = useIsWrongChain() + const isConnected = wallet && !isWrongChain + + const { createSafe } = useSafeCreation(pendingSafe, setPendingSafe, status, setStatus) + + useSafeCreationEffects({ + pendingSafe, + setPendingSafe, + status, + setStatus, + }) + + const onClose = useCallback(() => { + setPendingSafe(undefined) + router.push(AppRoutes.welcome) + }, [router, setPendingSafe]) + + const onCreate = useCallback(() => { + setStatus(SafeCreationStatus.AWAITING) + void createSafe() + }, [createSafe, setStatus]) + + const onFinish = useCallback(() => { + trackEvent(CREATE_SAFE_EVENTS.GET_STARTED) + + const { safeAddress } = pendingSafe || {} + + if (safeAddress) { + setPendingSafe(undefined) + router.push(getRedirect(chainId, safeAddress, router.query?.safeViewRedirectURL)) + } + }, [chainId, pendingSafe, router, setPendingSafe]) + + const displaySafeLink = status >= SafeCreationStatus.INDEXED + const isError = status >= SafeCreationStatus.WALLET_REJECTED && status <= SafeCreationStatus.TIMEOUT + + useEffect(() => { + if (!setProgressColor) return + + if (isError) { + setProgressColor(palette.error.main) + } else { + setProgressColor(palette.secondary.main) + } + }, [isError, setProgressColor]) + + return ( + + + + + + {!isError && pendingSafe && ( + <> + + + + + + )} + + {displaySafeLink && ( + <> + + + + + + + + )} + + {isError && ( + <> + + + + + + + + + + + + + + + + + )} + + ) +} diff --git a/src/components/new-safe/steps/Step4/logic/index.ts b/src/components/new-safe/steps/Step4/logic/index.ts new file mode 100644 index 0000000000..8fdee63546 --- /dev/null +++ b/src/components/new-safe/steps/Step4/logic/index.ts @@ -0,0 +1,146 @@ +import type { Web3Provider, JsonRpcProvider } from '@ethersproject/providers' +import type { ChainInfo } from '@gnosis.pm/safe-react-gateway-sdk' +import { getProxyFactoryContractInstance } from '@/services/contracts/safeContracts' +import type { ConnectedWallet } from '@/services/onboard' +import { BigNumber } from '@ethersproject/bignumber' +import { SafeCreationStatus } from '@/components/new-safe/steps/Step4/useSafeCreation' +import { didRevert, type EthersError } from '@/utils/ethers-utils' +import { Errors, logError } from '@/services/exceptions' +import { ErrorCode } from '@ethersproject/logger' +import { isWalletRejection } from '@/utils/wallets' +import type { PendingSafeTx } from '@/components/create-safe/types' +import type { NewSafeFormData } from '@/components/new-safe/CreateSafe' +import type { UrlObject } from 'url' +import chains from '@/config/chains' +import { AppRoutes } from '@/config/routes' +import { SAFE_APPS_EVENTS, trackEvent } from '@/services/analytics' +import type { AppDispatch, AppThunk } from '@/store' +import { showNotification } from '@/store/notificationsSlice' +import { formatError } from '@/hooks/useTxNotifications' +import { encodeSafeCreationTx } from '@/components/create-safe/logic' + +/** + * Encode a Safe creation tx in a way that we can store locally and monitor using _waitForTransaction + */ +export const getSafeCreationTxInfo = async ( + provider: Web3Provider, + params: NewSafeFormData, + chain: ChainInfo, + wallet: ConnectedWallet, +): Promise => { + const proxyContract = getProxyFactoryContractInstance(chain.chainId) + + const data = encodeSafeCreationTx({ + owners: params.owners.map((owner) => owner.address), + threshold: params.threshold, + saltNonce: params.saltNonce, + chain, + }) + + return { + data, + from: wallet.address, + nonce: await provider.getTransactionCount(wallet.address), + to: proxyContract.getAddress(), + value: BigNumber.from(0), + startBlock: await provider.getBlockNumber(), + } +} + +export const handleSafeCreationError = (error: EthersError) => { + logError(Errors._800, error.message) + + if (isWalletRejection(error)) { + return SafeCreationStatus.WALLET_REJECTED + } + + if (error.code === ErrorCode.TRANSACTION_REPLACED) { + if (error.reason === 'cancelled') { + return SafeCreationStatus.ERROR + } else { + return SafeCreationStatus.SUCCESS + } + } + + if (didRevert(error.receipt)) { + return SafeCreationStatus.REVERTED + } + + return SafeCreationStatus.TIMEOUT +} + +export const SAFE_CREATION_ERROR_KEY = 'create-safe-error' +export const showSafeCreationError = (error: EthersError): AppThunk => { + return (dispatch) => { + dispatch( + showNotification({ + message: `Your transaction was unsuccessful. Reason: ${formatError(error)}`, + detailedMessage: error.message, + groupKey: SAFE_CREATION_ERROR_KEY, + variant: 'error', + }), + ) + } +} + +export const checkSafeCreationTx = async ( + provider: JsonRpcProvider, + pendingTx: PendingSafeTx, + txHash: string, + dispatch: AppDispatch, +): Promise => { + const TIMEOUT_TIME = 6.5 * 60 * 1000 // 6.5 minutes + + try { + const receipt = await provider._waitForTransaction(txHash, 1, TIMEOUT_TIME, pendingTx) + + if (didRevert(receipt)) { + return SafeCreationStatus.REVERTED + } + + return SafeCreationStatus.SUCCESS + } catch (err) { + const _err = err as EthersError + + const status = handleSafeCreationError(_err) + + if (status !== SafeCreationStatus.SUCCESS) { + dispatch(showSafeCreationError(_err)) + } + + return status + } +} + +export const getRedirect = ( + chainId: string, + safeAddress: string, + redirectQuery?: string | string[], +): UrlObject | string => { + const redirectUrl = Array.isArray(redirectQuery) ? redirectQuery[0] : redirectQuery + const chainPrefix = Object.keys(chains).find((prefix) => chains[prefix] === chainId) + const address = `${chainPrefix}:${safeAddress}` + + // Should never happen in practice + if (!chainPrefix) return AppRoutes.index + + // Go to the dashboard if no specific redirect is provided + if (!redirectUrl) { + return { pathname: AppRoutes.home, query: { safe: address, showCreationModal: true } } + } + + // Otherwise, redirect to the provided URL (e.g. from a Safe App) + + // Track the redirect to Safe App + // TODO: Narrow this down to /apps only + if (redirectUrl.includes('apps')) { + trackEvent(SAFE_APPS_EVENTS.SHARED_APP_OPEN_AFTER_SAFE_CREATION) + } + + // We're prepending the safe address directly here because the `router.push` doesn't parse + // The URL for already existing query params + // TODO: Check if we can accomplish this with URLSearchParams or URL instead + const hasQueryParams = redirectUrl.includes('?') + const appendChar = hasQueryParams ? '&' : '?' + return redirectUrl + `${appendChar}safe=${address}` +} diff --git a/src/components/new-safe/steps/Step4/styles.module.css b/src/components/new-safe/steps/Step4/styles.module.css new file mode 100644 index 0000000000..ad0717d2b6 --- /dev/null +++ b/src/components/new-safe/steps/Step4/styles.module.css @@ -0,0 +1,18 @@ +.icon { + width: 12px; + height: 12px; +} + +.connector { + margin-left: 6px; + padding: 0; +} + +.connector :global .MuiStepConnector-line { + border-color: var(--color-border-light); +} + +.label { + padding: 0; + gap: var(--space-2); +} diff --git a/src/components/new-safe/steps/Step4/useSafeCreation.ts b/src/components/new-safe/steps/Step4/useSafeCreation.ts new file mode 100644 index 0000000000..009c3531f1 --- /dev/null +++ b/src/components/new-safe/steps/Step4/useSafeCreation.ts @@ -0,0 +1,117 @@ +import type { Dispatch, SetStateAction } from 'react' +import { useCallback, useEffect, useState } from 'react' +import { useWeb3, useWeb3ReadOnly } from '@/hooks/wallets/web3' +import { useCurrentChain } from '@/hooks/useChains' +import useWallet from '@/hooks/wallets/useWallet' +import type { EthersError } from '@/utils/ethers-utils' +import type { PendingSafeData } from '@/components/new-safe/steps/Step4/index' +import type { PendingSafeTx } from '@/components/create-safe/types' +import { + checkSafeCreationTx, + getSafeCreationTxInfo, + handleSafeCreationError, + SAFE_CREATION_ERROR_KEY, + showSafeCreationError, +} from '@/components/new-safe/steps/Step4/logic' +import { useAppDispatch } from '@/store' +import { closeByGroupKey } from '@/store/notificationsSlice' +import { CREATE_SAFE_EVENTS, trackEvent } from '@/services/analytics' +import { createNewSafe, getSafeDeployProps } from '@/components/create-safe/logic' + +export enum SafeCreationStatus { + AWAITING, + PROCESSING, + WALLET_REJECTED, + ERROR, + REVERTED, + TIMEOUT, + SUCCESS, + INDEXED, + INDEX_FAILED, +} + +export const useSafeCreation = ( + pendingSafe: PendingSafeData | undefined, + setPendingSafe: Dispatch>, + status: SafeCreationStatus, + setStatus: Dispatch>, +) => { + const [isCreating, setIsCreating] = useState(false) + const [isWatching, setIsWatching] = useState(false) + const dispatch = useAppDispatch() + + const wallet = useWallet() + const provider = useWeb3() + const web3ReadOnly = useWeb3ReadOnly() + const chain = useCurrentChain() + + const createSafeCallback = useCallback( + async (txHash: string, tx: PendingSafeTx) => { + setStatus(SafeCreationStatus.PROCESSING) + trackEvent(CREATE_SAFE_EVENTS.SUBMIT_CREATE_SAFE) + setPendingSafe((prev) => (prev ? { ...prev, txHash, tx } : undefined)) + }, + [setStatus, setPendingSafe], + ) + + const createSafe = useCallback(async () => { + if (!pendingSafe || !provider || !chain || !wallet || isCreating) return + + setIsCreating(true) + dispatch(closeByGroupKey({ groupKey: SAFE_CREATION_ERROR_KEY })) + + try { + const tx = await getSafeCreationTxInfo(provider, pendingSafe, chain, wallet) + + const safeParams = getSafeDeployProps( + { + threshold: pendingSafe.threshold, + owners: pendingSafe.owners.map((owner) => owner.address), + saltNonce: pendingSafe.saltNonce, + }, + (txHash) => createSafeCallback(txHash, tx), + chain.chainId, + ) + + await createNewSafe(provider, safeParams) + setStatus(SafeCreationStatus.SUCCESS) + } catch (err) { + const _err = err as EthersError + const status = handleSafeCreationError(_err) + + setStatus(status) + + if (status !== SafeCreationStatus.SUCCESS) { + dispatch(showSafeCreationError(_err)) + } + } + + setIsCreating(false) + }, [chain, createSafeCallback, dispatch, isCreating, pendingSafe, provider, setStatus, wallet]) + + const watchSafeTx = useCallback(async () => { + if (!pendingSafe?.tx || !pendingSafe?.txHash || !web3ReadOnly || isWatching) return + + setStatus(SafeCreationStatus.PROCESSING) + setIsWatching(true) + + const txStatus = await checkSafeCreationTx(web3ReadOnly, pendingSafe.tx, pendingSafe.txHash, dispatch) + setStatus(txStatus) + setIsWatching(false) + }, [isWatching, pendingSafe, web3ReadOnly, setStatus, dispatch]) + + useEffect(() => { + if (status !== SafeCreationStatus.AWAITING) return + + if (pendingSafe?.txHash && !isCreating) { + void watchSafeTx() + return + } + + void createSafe() + }, [createSafe, watchSafeTx, isCreating, pendingSafe?.txHash, status]) + + return { + createSafe, + } +} diff --git a/src/components/new-safe/steps/Step4/useSafeCreationEffects.ts b/src/components/new-safe/steps/Step4/useSafeCreationEffects.ts new file mode 100644 index 0000000000..50515a5721 --- /dev/null +++ b/src/components/new-safe/steps/Step4/useSafeCreationEffects.ts @@ -0,0 +1,68 @@ +import type { Dispatch, SetStateAction } from 'react' +import { useEffect } from 'react' +import { pollSafeInfo } from '@/components/create-safe/logic' +import { SafeCreationStatus } from '@/components/new-safe/steps/Step4/useSafeCreation' +import { CREATE_SAFE_EVENTS, trackEvent } from '@/services/analytics' +import { updateAddressBook } from '@/components/create-safe/logic/address-book' +import { useAppDispatch } from '@/store' +import useChainId from '@/hooks/useChainId' +import type { PendingSafeData } from '@/components/new-safe/steps/Step4/index' + +const useSafeCreationEffects = ({ + pendingSafe, + setPendingSafe, + status, + setStatus, +}: { + pendingSafe: PendingSafeData | undefined + setPendingSafe: Dispatch> + status: SafeCreationStatus + setStatus: Dispatch> +}) => { + const dispatch = useAppDispatch() + const chainId = useChainId() + + useEffect(() => { + if (status === SafeCreationStatus.SUCCESS) { + trackEvent(CREATE_SAFE_EVENTS.CREATED_SAFE) + + // Add the Safe and add names to the address book + if (pendingSafe && pendingSafe.safeAddress) { + dispatch( + updateAddressBook( + chainId, + pendingSafe.safeAddress, + pendingSafe.name, + pendingSafe.owners, + pendingSafe.threshold, + ), + ) + } + + // Asynchronously wait for Safe creation + if (pendingSafe?.safeAddress) { + pollSafeInfo(chainId, pendingSafe.safeAddress) + .then(() => setStatus(SafeCreationStatus.INDEXED)) + .catch(() => setStatus(SafeCreationStatus.INDEX_FAILED)) + } + return + } + + if (status === SafeCreationStatus.WALLET_REJECTED) { + trackEvent(CREATE_SAFE_EVENTS.REJECT_CREATE_SAFE) + } + + if ( + status === SafeCreationStatus.WALLET_REJECTED || + status === SafeCreationStatus.ERROR || + status === SafeCreationStatus.REVERTED + ) { + if (pendingSafe?.txHash) { + setPendingSafe((prev) => (prev ? { ...prev, txHash: undefined, tx: undefined } : undefined)) + } + return + } + }, [chainId, dispatch, pendingSafe, setPendingSafe, setStatus, status]) +} + +export default useSafeCreationEffects diff --git a/src/components/safe-apps/AddCustomAppModal/CustomApp.tsx b/src/components/safe-apps/AddCustomAppModal/CustomApp.tsx index b09bb1a0ba..65f2bb08e2 100644 --- a/src/components/safe-apps/AddCustomAppModal/CustomApp.tsx +++ b/src/components/safe-apps/AddCustomAppModal/CustomApp.tsx @@ -7,6 +7,7 @@ import { SAFE_APPS_EVENTS, trackSafeAppEvent } from '@/services/analytics' import CopyButton from '@/components/common/CopyButton' import ShareIcon from '@/public/images/common/share.svg' import css from './styles.module.css' +import SafeAppIcon from '../SafeAppIcon' type CustomAppProps = { safeApp: SafeAppData @@ -20,7 +21,7 @@ const CustomApp = ({ safeApp, shareUrl }: CustomAppProps) => { return (
- {safeApp.name} + {safeApp.name} diff --git a/src/components/safe-apps/AddCustomAppModal/styles.module.css b/src/components/safe-apps/AddCustomAppModal/styles.module.css index a6e00c77de..14973bb764 100644 --- a/src/components/safe-apps/AddCustomAppModal/styles.module.css +++ b/src/components/safe-apps/AddCustomAppModal/styles.module.css @@ -41,10 +41,6 @@ right: 25px; } -.customAppIcon { - width: 48px; -} - .customAppPlaceholderContainer { width: 100%; display: flex; diff --git a/src/components/safe-apps/AppCard/index.tsx b/src/components/safe-apps/AppCard/index.tsx index 96e0365d85..fbc1248566 100644 --- a/src/components/safe-apps/AppCard/index.tsx +++ b/src/components/safe-apps/AppCard/index.tsx @@ -3,7 +3,6 @@ import { useCallback } from 'react' import { useRouter } from 'next/router' import Link from 'next/link' import type { LinkProps } from 'next/link' -import Avatar from '@mui/material/Avatar' import Card from '@mui/material/Card' import CardContent from '@mui/material/CardContent' import CardHeader from '@mui/material/CardHeader' @@ -23,6 +22,7 @@ import { SvgIcon } from '@mui/material' import type { UrlObject } from 'url' import { resolveHref } from 'next/dist/shared/lib/router/router' import { SAFE_APPS_EVENTS, trackSafeAppEvent } from '@/services/analytics' +import SafeAppIcon from '../SafeAppIcon' export type SafeAppCardVariants = 'default' | 'compact' @@ -168,16 +168,7 @@ const CompactAppCard = ({ url, safeApp, onPin, pinned, shareUrl }: CompactSafeAp
{/* App logo */} - + {/* TODO No share button per design. Only info button. Leaving the code for reusing the styles */} {/* Share button */} @@ -224,18 +215,7 @@ const AppCard = ({ safeApp, pinned, onPin, onDelete, variant = 'default' }: AppC return ( - } + avatar={} action={
{/* Share button */} diff --git a/src/components/safe-apps/AppFrame/TransactionQueueBar/styles.module.css b/src/components/safe-apps/AppFrame/TransactionQueueBar/styles.module.css index b213384cfd..c7d08f6d34 100644 --- a/src/components/safe-apps/AppFrame/TransactionQueueBar/styles.module.css +++ b/src/components/safe-apps/AppFrame/TransactionQueueBar/styles.module.css @@ -3,7 +3,9 @@ bottom: 0; right: 0; width: 100%; - z-index: 1; + + /* MUI Drawer z-index default value see: https://mui.com/material-ui/customization/default-theme/?expand-path=$.zIndex */ + z-index: 1200; /*this rule is needed to prevent the bar from being expanded outside the screen without scrolling on mobile devices*/ max-height: 90vh; diff --git a/src/components/safe-apps/AppFrame/index.tsx b/src/components/safe-apps/AppFrame/index.tsx index d0286e8bf0..ac6a75127c 100644 --- a/src/components/safe-apps/AppFrame/index.tsx +++ b/src/components/safe-apps/AppFrame/index.tsx @@ -25,6 +25,7 @@ import useTransactionQueueBarState from '@/components/safe-apps/AppFrame/useTran import { gtmTrackPageview } from '@/services/analytics/gtm' import { getLegacyChainName } from '../utils' import useThirdPartyCookies from './useThirdPartyCookies' +import useAnalyticsFromSafeApp from './useFromAppAnalytics' import useAppIsLoading from './useAppIsLoading' import useAppCommunicator, { CommunicatorMessages } from './useAppCommunicator' import { ThirdPartyCookiesWarning } from './ThirdPartyCookiesWarning' @@ -62,10 +63,11 @@ const AppFrame = ({ appUrl, allowedFeaturesList }: AppFrameProps): ReactElement transactions, } = useTransactionQueueBarState() const queueBarVisible = transactions.results.length > 0 && !queueBarDismissed - const [remoteApp] = useSafeAppFromBackend(appUrl, safe.chainId) + const [remoteApp, , isBackendAppsLoading] = useSafeAppFromBackend(appUrl, safe.chainId) const { safeApp: safeAppFromManifest } = useSafeAppFromManifest(appUrl, safe.chainId) const { thirdPartyCookiesDisabled, setThirdPartyCookiesDisabled } = useThirdPartyCookies() const { iframeRef, appIsLoading, isLoadingSlow, setAppIsLoading } = useAppIsLoading() + useAnalyticsFromSafeApp(iframeRef) const { getPermissions, hasPermission, permissionsRequest, setPermissionsRequest, confirmPermissionRequest } = useSafePermissions() const appName = useMemo(() => (remoteApp ? remoteApp.name : appUrl), [appUrl, remoteApp]) @@ -130,7 +132,7 @@ const AppFrame = ({ appUrl, allowedFeaturesList }: AppFrameProps): ReactElement }, [appUrl, iframeRef, setAppIsLoading, router]) useEffect(() => { - if (!appIsLoading) { + if (!appIsLoading && !isBackendAppsLoading) { trackSafeAppEvent( { ...SAFE_APPS_EVENTS.OPEN_APP, @@ -138,7 +140,7 @@ const AppFrame = ({ appUrl, allowedFeaturesList }: AppFrameProps): ReactElement appName, ) } - }, [appIsLoading, appName]) + }, [appIsLoading, isBackendAppsLoading, appName]) useEffect(() => { const unsubscribe = txSubscribe(TxEvent.SAFE_APPS_REQUEST, async ({ txId, safeAppRequestId }) => { diff --git a/src/components/safe-apps/AppFrame/useFromAppAnalytics.ts b/src/components/safe-apps/AppFrame/useFromAppAnalytics.ts new file mode 100644 index 0000000000..e9f2da31ae --- /dev/null +++ b/src/components/safe-apps/AppFrame/useFromAppAnalytics.ts @@ -0,0 +1,52 @@ +import type { MutableRefObject } from 'react' +import { useCallback, useEffect } from 'react' + +import type { AnalyticsEvent } from '@/services/analytics' +import { EventType, trackSafeAppEvent } from '@/services/analytics' +import { SAFE_APPS_ANALYTICS_CATEGORY } from '@/services/analytics/events/safeApps' + +//TODO: Update apps domain to the new one when decided +const ALLOWED_DOMAINS: RegExp[] = [ + /^http:\/\/localhost:[0-9]{4}$/, + /^https:\/\/safe-apps\.dev\.5afe\.dev$/, + /^https:\/\/apps\.gnosis-safe\.io$/, +] + +const useAnalyticsFromSafeApp = (iframeRef: MutableRefObject): void => { + const isValidMessage = useCallback( + (msg: MessageEvent) => { + const isFromIframe = iframeRef.current?.contentWindow === msg.source + const isCategoryAllowed = msg.data.category === SAFE_APPS_ANALYTICS_CATEGORY + const isDomainAllowed = ALLOWED_DOMAINS.find((regExp) => regExp.test(msg.origin)) !== undefined + + return isFromIframe && isCategoryAllowed && isDomainAllowed + }, + [iframeRef], + ) + + const handleIncomingMessage = useCallback( + (msg: MessageEvent) => { + if (!isValidMessage(msg)) { + return + } + + const { action, label, safeAppName } = msg.data + + trackSafeAppEvent( + { event: EventType.SAFE_APP, category: SAFE_APPS_ANALYTICS_CATEGORY, action, label }, + safeAppName, + ) + }, + [isValidMessage], + ) + + useEffect(() => { + window.addEventListener('message', handleIncomingMessage) + + return () => { + window.removeEventListener('message', handleIncomingMessage) + } + }, [handleIncomingMessage]) +} + +export default useAnalyticsFromSafeApp diff --git a/src/components/safe-apps/SafeAppIcon/index.tsx b/src/components/safe-apps/SafeAppIcon/index.tsx new file mode 100644 index 0000000000..45f1577cd4 --- /dev/null +++ b/src/components/safe-apps/SafeAppIcon/index.tsx @@ -0,0 +1,42 @@ +import { type ReactElement, memo } from 'react' + +const getIframeContent = (src: string): string => { + return `` +} + +const SafeAppIcon = ({ + src, + alt, + width = 40, + height = 40, +}: { + src: string + alt: string + width?: number + height?: number +}): ReactElement => { + return ( +