From 03135e8e7b5eced8992d2da172229583042f8c49 Mon Sep 17 00:00:00 2001 From: Usame Algan <5880855+usame-algan@users.noreply.github.com> Date: Thu, 24 Nov 2022 17:25:10 +0100 Subject: [PATCH] [Epic] Safe Creation (#988) * feat: new creation design + step 1 * fix: widget width * refactor: combine components * fix: rename folder + remove CSS module * fix: missing padding * fix: Reuse NetworkSelector component * [Safe Creation] Owner step (#989) * feat: basic step 2 implementation Co-authored-by: iamacook * feat: creation info widget (#960) * feat: creation info widget * fix: Remove demo page Co-authored-by: Usame Algan * Safe creation stepper (#992) A new CardStepper component which controls the steps for new safe creation which can - go back and forth - jump to a specific step - set an initial step - set initial values - renders a progress bar - step data is stored in an object with a generic type - the update functions (onBack, onSubmit) use Partials of that type such that each step does not need to submit the full data change /demo route to /create-safe Co-authored-by: Usame Algan * Review step redesign (#993) * feat: new create safe review step * calculate total gas fee * fill the form values * refactor review rows * fix: implement Figma designs * fix: background-color on network fee * avoid infinite rerendering * integrate review step in new Stepper * style: visual tweaks * fix: Create safe layout adjustments (#997) * fix: Create safe layout adjustments * fix: overview widget margin top * fix: Move overview widget to top on mobile * feat: AB testing system (#1013) * feat: AB testing system * fix: remove reset function + test `AbTest` value * Refactor: sync useLocalStorage across components and tabs (#977) * Refactor: external store in useLocalStorage * Comment * Sync separately * Use the storage event for syncing * Rm comment * Restore prev behavior wrt undefined * Simplify the undefined check * Allow undefined and rm initialValue * Don't set undefined * Local storage to return null when not found * Initial value * Rm initial value again * usePendingCreation * Rm redundant code * Refactor: external store in useLocalStorage (#1014) Refactor: external store in useLocalStorage * feat: create `useABTesting` hook for setting event * fix: reorganise structure Co-authored-by: katspaugh <381895+katspaugh@users.noreply.github.com> * feat: add AB testing to Safe creation (#1017) * [Create Safe] feat: Connect wallet step (#1006) * fix: Add connect wallet step to safe creation * fix: Remove unused method * fix: Reuse components, adjust modal zIndex * fix: Only run onConnect callback if there was no error when connecting a wallet * fix: Set wallet owner in form on connect * fix: Check for connected wallet * style: Use onboard zindex variable * [Create Safe] add overview and CreateSafeInfo widget (#1002) * add overview and CreateSafeInfo widget - displays safe name and connected wallet during owner setup - displays hints for each step + dynamic hints during owner and threshold setup - state is pulled up into the CreateSafe component and update functions passed into the owner step * step static tips are collapsed by default * navigate tips logic * reverse expanded logic * restyle InfoWidget * remove the add mobile owner row * remove flickering when opening the accordion * fix keys * add hover style to the Accordion expand icon * Accordion summary bold when expanded * style: PR comments * fix empty form data name * simplify map key * link to Safe setup article * change allOwners logic * mv sx to css modules * add current chain in Overview widget Co-authored-by: Diogo Soares * [Create Safe] feat: Status view redesign (#1018) * feat: Implement create safe status redesign * style: Add animated loading spinner * fix: Simplify conditions * add: animate into css safe logo * fix: use numeric enum for simpler conditions, adjust animated logo sizes * fix: Display dialog after safe creation * fix: Feedback * fix: Better name for query param Co-authored-by: schmanu * [Safe creation] Creation links tooltips (#1061) * add link to tip * add missing tooltips * update copy * use SvgIcon to inherit viewBox * fix: inherit `fill` Co-authored-by: iamacook * fix: textual inconsistencies + add hint (#1065) * [Safe Creation] Layout adjustments (#1063) * style: Safe Creation layout * fix: Align threshold text * [Create Safe] Handle wallet connection (#1087) * fix: Handle wallet connection when creating safe * fix: Only watch threshold * refactor: Rename useSetCreationStep to useSyncSafeCreationStep * fix: Use formState.isValid * fix: Add tests for useSyncSafeCreationStep * fix: Show error notifications in safe creation (#1135) * fix: Show error notifications in safe creation * fix: Add closeByGroupKey action to notification slice * fix: Safe creation issues (#1180) * fix: Navigate back to the welcome page on cancel * fix: Reuse NameInput for new form * fix: Use currentColor for icons * fix: check for pending safe creation in first step (#1190) * fix: reduce threshold when removing owner (#1181) * fix: reduce threshold when removing owner * fix: use enum for all field names * fix: reduce code * Safe creation text updates (#1198) * fix: Update text and layout for step 0 * fix: Update text for step 2 and 3, show copy and explorer icons for addresses * fix: Update text for status messages * fix: Update hint texts * fix: Update creation modal text, add dynamic values * fix: QR reader crashes app (#1200) * fix: Safe Creation color issues (#1204) * fix: Safe Creation color issues * chore: Add TODO * fix: Remove static colors and use palette directly * fix: Add overview widget to step 1 in safe creation (#1210) * fix: Add missing safe creation events (#1212) * fix: Add missing safe creation events * fix: Add new events for hints * fix: Watch safe creation tx even without a connected wallet (#1215) * fix: Watch safe creation tx even without a connected wallet * fix it in the old safe creation flow too * fix: failing test * fix: Add additional test * fix: Only watch safe creation tx once (#1223) * fix: Only watch safe creation tx once * fix: Detect if tx reverted when ethers throws error * refactor: Remove unused code that was duplicated (#1230) * refactor: Remove unused code that was duplicated * fix: Revert ab test removal * refactor: Remove unused StepCard component * fix: Update links to safe.global * fix: Set link to safe creation for ab testing * fix: Hide sidebar on safe creation * fix: Revert ab test change * fix: Center QR code reload icon * fix: Safe cration text changes (#1239) * fix: Safe cration text changes * fix: Adjust safe indexed message * fix: Adjust safe indexed message Co-authored-by: iamacook Co-authored-by: Manuel Gellfart Co-authored-by: Diogo Soares <32431609+DiogoSoaress@users.noreply.github.com> Co-authored-by: katspaugh <381895+katspaugh@users.noreply.github.com> Co-authored-by: Diogo Soares --- public/images/common/lightbulb.svg | 10 + public/images/sidebar/address-book.svg | 4 +- public/images/sidebar/apps.svg | 6 + public/images/sidebar/assets.svg | 6 +- public/images/sidebar/copy.svg | 3 + public/images/sidebar/help-center.svg | 8 +- public/images/sidebar/home.svg | 6 +- public/images/sidebar/link.svg | 4 + public/images/sidebar/qr.svg | 10 + public/images/sidebar/settings.svg | 4 +- public/images/sidebar/transactions.svg | 6 +- public/images/sidebar/whats-new.svg | 12 +- .../common/ConnectWallet/WalletDetails.tsx | 21 +- .../common/ConnectWallet/useConnectWallet.ts | 25 ++ .../common/Header/styles.module.css | 3 +- src/components/common/NameInput/index.tsx | 2 +- .../common/NetworkSelector/index.tsx | 2 +- .../common/NetworkSelector/styles.module.css | 16 ++ .../common/PageLayout/SideDrawer.tsx | 7 +- .../PairingDetails/PairingDescription.tsx | 2 +- src/components/common/ScanQRModal/index.tsx | 13 +- .../create-safe/InfoWidget/index.tsx | 82 +++++++ .../create-safe/InfoWidget/styles.module.css | 38 ++++ .../create-safe/OverviewWidget/index.tsx | 25 -- .../create-safe/logic/__tests__/index.test.ts | 26 ++- src/components/create-safe/logic/index.ts | 16 +- .../status/__tests__/useSafeCreation.test.ts | 27 ++- .../create-safe/status/useSafeCreation.ts | 13 +- .../status/useSafeCreationEffects.ts | 2 + src/components/create-safe/steps/OwnerRow.tsx | 2 +- .../create-safe/steps/styles.module.css | 4 + .../dashboard/CreationDialog/index.tsx | 76 +++++++ src/components/dashboard/index.tsx | 34 +-- src/components/new-safe/CardStepper/index.tsx | 40 ++++ .../new-safe/CardStepper/styles.module.css | 43 ++++ .../new-safe/CardStepper/useCardStepper.ts | 88 ++++++++ .../__tests__/useSyncSafeCreationStep.test.ts | 40 ++++ src/components/new-safe/CreateSafe/index.tsx | 213 ++++++++++++++++++ .../new-safe/CreateSafe/styles.module.css | 10 + .../CreateSafe/useSyncSafeCreationStep.ts | 25 ++ .../new-safe/CreateSafeInfos/index.tsx | 46 ++++ .../new-safe/NetworkWarning/index.tsx | 17 ++ .../new-safe/OverviewWidget/index.tsx | 48 ++++ .../OverviewWidget/styles.module.css | 2 +- src/components/new-safe/steps/Step0/index.tsx | 61 +++++ src/components/new-safe/steps/Step1/index.tsx | 120 ++++++++++ .../new-safe/steps/Step1/styles.module.css | 22 ++ .../new-safe/steps/Step2/OwnerRow.tsx | 125 ++++++++++ src/components/new-safe/steps/Step2/index.tsx | 182 +++++++++++++++ .../new-safe/steps/Step2/styles.module.css | 12 + .../new-safe/steps/Step2/useSafeSetupHints.ts | 35 +++ src/components/new-safe/steps/Step3/index.tsx | 174 ++++++++++++++ .../new-safe/steps/Step3/styles.module.css | 5 + .../steps/Step4/LoadingSpinner/index.tsx | 77 +++++++ .../Step4/LoadingSpinner/styles.module.css | 123 ++++++++++ .../new-safe/steps/Step4/StatusMessage.tsx | 91 ++++++++ .../new-safe/steps/Step4/StatusStep.tsx | 45 ++++ .../new-safe/steps/Step4/StatusStepper.tsx | 67 ++++++ src/components/new-safe/steps/Step4/index.tsx | 142 ++++++++++++ .../new-safe/steps/Step4/logic/index.ts | 146 ++++++++++++ .../new-safe/steps/Step4/styles.module.css | 18 ++ .../new-safe/steps/Step4/useSafeCreation.ts | 117 ++++++++++ .../steps/Step4/useSafeCreationEffects.ts | 68 ++++++ .../sidebar/SidebarHeader/index.tsx | 2 +- .../sidebar/SidebarHeader/styles.module.css | 4 - src/components/welcome/index.tsx | 3 +- src/config/routes.ts | 3 + src/hooks/useTxNotifications.ts | 2 +- src/hooks/wallets/useOnboard.ts | 3 +- src/pages/_app.tsx | 3 + src/pages/new-safe/create.tsx | 18 ++ src/pages/open.tsx | 18 ++ .../analytics/events/createLoadSafe.ts | 4 + src/services/analytics/gtm.ts | 9 + src/services/pairing/QRModal.tsx | 3 +- src/services/pairing/styles.module.css | 3 + src/services/tracking/abTesting.ts | 13 ++ src/services/tracking/useABTesting.ts | 30 +++ src/store/notificationsSlice.ts | 7 +- src/styles/colors.ts | 2 +- src/styles/onboard.css | 4 +- src/styles/theme.ts | 9 +- src/styles/vars.css | 2 +- 83 files changed, 2735 insertions(+), 124 deletions(-) create mode 100644 public/images/common/lightbulb.svg create mode 100644 public/images/sidebar/apps.svg create mode 100644 public/images/sidebar/copy.svg create mode 100644 public/images/sidebar/link.svg create mode 100644 public/images/sidebar/qr.svg create mode 100644 src/components/common/ConnectWallet/useConnectWallet.ts create mode 100644 src/components/create-safe/InfoWidget/index.tsx create mode 100644 src/components/create-safe/InfoWidget/styles.module.css delete mode 100644 src/components/create-safe/OverviewWidget/index.tsx create mode 100644 src/components/dashboard/CreationDialog/index.tsx create mode 100644 src/components/new-safe/CardStepper/index.tsx create mode 100644 src/components/new-safe/CardStepper/styles.module.css create mode 100644 src/components/new-safe/CardStepper/useCardStepper.ts create mode 100644 src/components/new-safe/CreateSafe/__tests__/useSyncSafeCreationStep.test.ts create mode 100644 src/components/new-safe/CreateSafe/index.tsx create mode 100644 src/components/new-safe/CreateSafe/styles.module.css create mode 100644 src/components/new-safe/CreateSafe/useSyncSafeCreationStep.ts create mode 100644 src/components/new-safe/CreateSafeInfos/index.tsx create mode 100644 src/components/new-safe/NetworkWarning/index.tsx create mode 100644 src/components/new-safe/OverviewWidget/index.tsx rename src/components/{create-safe => new-safe}/OverviewWidget/styles.module.css (87%) create mode 100644 src/components/new-safe/steps/Step0/index.tsx create mode 100644 src/components/new-safe/steps/Step1/index.tsx create mode 100644 src/components/new-safe/steps/Step1/styles.module.css create mode 100644 src/components/new-safe/steps/Step2/OwnerRow.tsx create mode 100644 src/components/new-safe/steps/Step2/index.tsx create mode 100644 src/components/new-safe/steps/Step2/styles.module.css create mode 100644 src/components/new-safe/steps/Step2/useSafeSetupHints.ts create mode 100644 src/components/new-safe/steps/Step3/index.tsx create mode 100644 src/components/new-safe/steps/Step3/styles.module.css create mode 100644 src/components/new-safe/steps/Step4/LoadingSpinner/index.tsx create mode 100644 src/components/new-safe/steps/Step4/LoadingSpinner/styles.module.css create mode 100644 src/components/new-safe/steps/Step4/StatusMessage.tsx create mode 100644 src/components/new-safe/steps/Step4/StatusStep.tsx create mode 100644 src/components/new-safe/steps/Step4/StatusStepper.tsx create mode 100644 src/components/new-safe/steps/Step4/index.tsx create mode 100644 src/components/new-safe/steps/Step4/logic/index.ts create mode 100644 src/components/new-safe/steps/Step4/styles.module.css create mode 100644 src/components/new-safe/steps/Step4/useSafeCreation.ts create mode 100644 src/components/new-safe/steps/Step4/useSafeCreationEffects.ts create mode 100644 src/pages/new-safe/create.tsx create mode 100644 src/services/pairing/styles.module.css create mode 100644 src/services/tracking/abTesting.ts create mode 100644 src/services/tracking/useABTesting.ts 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.svg b/public/images/sidebar/copy.svg new file mode 100644 index 0000000000..4aae41b27f --- /dev/null +++ b/public/images/sidebar/copy.svg @@ -0,0 +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/qr.svg b/public/images/sidebar/qr.svg new file mode 100644 index 0000000000..b20cb787a9 --- /dev/null +++ b/public/images/sidebar/qr.svg @@ -0,0 +1,10 @@ + + + + + + + + + + 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/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 -
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..2d11475be9 --- /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/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..a2d2d0f81b --- /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' }) + } + + const handleBack = (data?: Partial) => { + setActiveStep((prevActiveStep) => prevActiveStep - 1) + setWidgetStep && setWidgetStep((prevActiveStep) => prevActiveStep - 1) + trackEvent({ category: eventCategory, action: firstStep ? 'Cancel' : 'Back' }) + + 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/sidebar/SidebarHeader/index.tsx b/src/components/sidebar/SidebarHeader/index.tsx index b0c6d50549..217a7e464f 100644 --- a/src/components/sidebar/SidebarHeader/index.tsx +++ b/src/components/sidebar/SidebarHeader/index.tsx @@ -103,7 +103,7 @@ const SafeHeader = (): ReactElement => { - + diff --git a/src/components/sidebar/SidebarHeader/styles.module.css b/src/components/sidebar/SidebarHeader/styles.module.css index 100b9dbb02..ab42716092 100644 --- a/src/components/sidebar/SidebarHeader/styles.module.css +++ b/src/components/sidebar/SidebarHeader/styles.module.css @@ -32,10 +32,6 @@ background-color: var(--color-secondary-background); } -.iconButton svg path { - fill: var(--color-primary-main); -} - .address { width: 100%; overflow: hidden; diff --git a/src/components/welcome/index.tsx b/src/components/welcome/index.tsx index 89b2be5346..99160f9e6c 100644 --- a/src/components/welcome/index.tsx +++ b/src/components/welcome/index.tsx @@ -3,6 +3,7 @@ import { Button, Divider, Grid, Paper, Typography } from '@mui/material' import { useRouter } from 'next/router' import { CREATE_SAFE_EVENTS, LOAD_SAFE_EVENTS } from '@/services/analytics/events/createLoadSafe' import Track from '../common/Track' +import { AppRoutes } from '@/config/routes' const NewSafe = () => { const router = useRouter() @@ -28,7 +29,7 @@ const NewSafe = () => { for creating your new Safe. - diff --git a/src/config/routes.ts b/src/config/routes.ts index 8c02f450b2..2e82d827c8 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -12,6 +12,9 @@ export const AppRoutes = { nfts: '/balances/nfts', index: '/balances', }, + newSafe: { + create: '/new-safe/create', + }, settings: { spendingLimits: '/settings/spending-limits', setup: '/settings/setup', diff --git a/src/hooks/useTxNotifications.ts b/src/hooks/useTxNotifications.ts index 02bfcacaf7..7b4d4cb7a5 100644 --- a/src/hooks/useTxNotifications.ts +++ b/src/hooks/useTxNotifications.ts @@ -37,7 +37,7 @@ enum Variant { } // Format the error message -const formatError = (error: Error & { reason?: string }): string => { +export const formatError = (error: Error & { reason?: string }): string => { let { reason } = error if (!reason) return '' if (!reason.endsWith('.')) reason += '.' diff --git a/src/hooks/wallets/useOnboard.ts b/src/hooks/wallets/useOnboard.ts index 2a15d9d7f5..2aa2908800 100644 --- a/src/hooks/wallets/useOnboard.ts +++ b/src/hooks/wallets/useOnboard.ts @@ -86,7 +86,7 @@ export const connectWallet = (onboard: OnboardAPI, options?: Parameters { const newWallet = getConnectedWallet(wallets) @@ -95,6 +95,7 @@ export const connectWallet = (onboard: OnboardAPI, options?: Parameters logError(Errors._302, (e as Error).message)) diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 8b194b5646..7499d9fb87 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -31,6 +31,8 @@ import useBeamer from '@/hooks/useBeamer' import ErrorBoundary from '@/components/common/ErrorBoundary' import createEmotionCache from '@/utils/createEmotionCache' import MetaTags from '@/components/common/MetaTags' +import useABTesting from '@/services/tracking/useABTesting' +import { AbTest } from '@/services/tracking/abTesting' import PsaBanner from '@/components/common/PsaBanner' const GATEWAY_URL = IS_PRODUCTION || cgwDebugStorage.get() ? GATEWAY_URL_PRODUCTION : GATEWAY_URL_STAGING @@ -50,6 +52,7 @@ const InitApp = (): null => { useTxPendingStatuses() useTxTracking() useBeamer() + useABTesting(AbTest.SAFE_CREATION) return null } diff --git a/src/pages/new-safe/create.tsx b/src/pages/new-safe/create.tsx new file mode 100644 index 0000000000..e158c36424 --- /dev/null +++ b/src/pages/new-safe/create.tsx @@ -0,0 +1,18 @@ +import Head from 'next/head' +import type { NextPage } from 'next' + +import CreateSafe from '@/components/new-safe/CreateSafe' + +const Open: NextPage = () => { + return ( +
+ + Safe – Create Safe + + + +
+ ) +} + +export default Open diff --git a/src/pages/open.tsx b/src/pages/open.tsx index eb7824e4a5..5ca00b96ec 100644 --- a/src/pages/open.tsx +++ b/src/pages/open.tsx @@ -1,8 +1,26 @@ import type { NextPage } from 'next' import Head from 'next/head' import CreateSafe from '@/components/create-safe' +import { useRouter } from 'next/router' +import useABTesting from '@/services/tracking/useABTesting' +import { AbTest } from '@/services/tracking/abTesting' +import { useLayoutEffect } from 'react' +import { AppRoutes } from '@/config/routes' const Open: NextPage = () => { + const shouldUseNewCreation = useABTesting(AbTest.SAFE_CREATION) + const router = useRouter() + + useLayoutEffect(() => { + if (shouldUseNewCreation) { + router.replace(AppRoutes.newSafe.create) + } + }, [router, shouldUseNewCreation]) + + if (shouldUseNewCreation) { + return <> + } + return (
diff --git a/src/services/analytics/events/createLoadSafe.ts b/src/services/analytics/events/createLoadSafe.ts index e210008f20..98689a083d 100644 --- a/src/services/analytics/events/createLoadSafe.ts +++ b/src/services/analytics/events/createLoadSafe.ts @@ -55,6 +55,10 @@ export const CREATE_SAFE_EVENTS = { action: 'Open Safe', category: CREATE_SAFE_CATEGORY, }, + OPEN_HINT: { + action: 'Open Hint', + category: CREATE_SAFE_CATEGORY, + }, } export const LOAD_SAFE_CATEGORY = 'load-safe' diff --git a/src/services/analytics/gtm.ts b/src/services/analytics/gtm.ts index d63cd79a9e..1e1cf86d02 100644 --- a/src/services/analytics/gtm.ts +++ b/src/services/analytics/gtm.ts @@ -19,6 +19,8 @@ import { import type { AnalyticsEvent, EventLabel, SafeAppSDKEvent } from './types' import { EventType } from './types' import { SAFE_APPS_SDK_CATEGORY } from './events' +import { getAbTest } from '../tracking/abTesting' +import type { AbTest } from '../tracking/abTesting' type GTMEnvironment = 'LIVE' | 'LATEST' | 'DEVELOPMENT' type GTMEnvironmentArgs = Required> @@ -70,6 +72,7 @@ export const gtmClear = TagManager.disable type GtmEvent = { event: EventType chainId: string + abTest?: AbTest } type ActionGtmEvent = GtmEvent & { @@ -104,6 +107,12 @@ export const gtmTrack = (eventData: AnalyticsEvent): void => { gtmEvent.eventLabel = eventData.label } + const abTest = getAbTest() + + if (abTest) { + gtmEvent.abTest = abTest + } + gtmSend(gtmEvent) } diff --git a/src/services/pairing/QRModal.tsx b/src/services/pairing/QRModal.tsx index 365e7809ff..e882f1eb4f 100644 --- a/src/services/pairing/QRModal.tsx +++ b/src/services/pairing/QRModal.tsx @@ -7,6 +7,7 @@ import PairingDescription from '@/components/common/PairingDetails/PairingDescri import { StoreHydrator } from '@/store' import { AppProviders } from '@/pages/_app' import { PAIRING_MODULE_LABEL } from '@/services/pairing/module' +import css from './styles.module.css' const WRAPPER_ID = 'safe-mobile-qr-modal' const QR_CODE_SIZE = 200 @@ -56,7 +57,7 @@ const Modal = ({ uri, cb }: { uri: string; cb: () => void }) => { return ( - + {PAIRING_MODULE_LABEL} { + _abTest = abTest +} + +export const getAbTest = (): AbTest | null => { + return _abTest +} diff --git a/src/services/tracking/useABTesting.ts b/src/services/tracking/useABTesting.ts new file mode 100644 index 0000000000..90e0de9074 --- /dev/null +++ b/src/services/tracking/useABTesting.ts @@ -0,0 +1,30 @@ +import { useEffect, useMemo } from 'react' + +import useLocalStorage from '@/services/local-storage/useLocalStorage' +import { setAbTest } from './abTesting' +import type { AbTest } from './abTesting' + +const useABTesting = (abTest: AbTest): boolean => { + // Fallback AB test value if no `localStorage` exists + const coinToss = useMemo(() => { + return Math.random() >= 0.5 + }, []) + + const [isB = coinToss, setIsB] = useLocalStorage(`AB_${abTest}`) + + // Save fallback value to `localStorage` if no cache exists + useEffect(() => { + setIsB((prev) => prev ?? coinToss) + }, [coinToss, isB, setIsB]) + + // Store AB test value in GTM + useEffect(() => { + if (isB) { + setAbTest(abTest) + } + }, [abTest, isB]) + + return isB +} + +export default useABTesting diff --git a/src/store/notificationsSlice.ts b/src/store/notificationsSlice.ts index bf976cfb40..823f7fb512 100644 --- a/src/store/notificationsSlice.ts +++ b/src/store/notificationsSlice.ts @@ -31,6 +31,11 @@ export const notificationsSlice = createSlice({ return notification.id === payload.id ? { ...notification, isDismissed: true } : notification }) }, + closeByGroupKey: (state, { payload }: PayloadAction<{ groupKey: string }>): NotificationState => { + return state.map((notification) => { + return notification.groupKey === payload.groupKey ? { ...notification, isDismissed: true } : notification + }) + }, deleteNotification: (state, { payload }: PayloadAction) => { return state.filter((notification) => notification.id !== payload.id) }, @@ -45,7 +50,7 @@ export const notificationsSlice = createSlice({ }, }) -export const { closeNotification, deleteNotification, deleteAllNotifications, readNotification } = +export const { closeNotification, closeByGroupKey, deleteNotification, deleteAllNotifications, readNotification } = notificationsSlice.actions export const showNotification = (payload: Omit): AppThunk => { diff --git a/src/styles/colors.ts b/src/styles/colors.ts index 4eff2aae30..00b41a720f 100644 --- a/src/styles/colors.ts +++ b/src/styles/colors.ts @@ -42,7 +42,7 @@ const palette = { dark: '#CD674E', main: '#FF8061', light: '#FFB7A6', - background: '#EFFFF4', + background: '#FFF0ED', }, background: { default: '#F4F4F4', diff --git a/src/styles/onboard.css b/src/styles/onboard.css index f26882f2b7..1efa2cc2f0 100644 --- a/src/styles/onboard.css +++ b/src/styles/onboard.css @@ -72,8 +72,8 @@ --onboard-border-radius-3: 8px; /* Z-index */ - --onboard-modal-z-index: 1201; - --onboard-account-select-modal-z-index: 1201; + --onboard-modal-z-index: 1301; + --onboard-account-select-modal-z-index: 1301; } #walletconnect-qrcode-modal { diff --git a/src/styles/theme.ts b/src/styles/theme.ts index 0f72f5a981..59ee3c309e 100644 --- a/src/styles/theme.ts +++ b/src/styles/theme.ts @@ -59,7 +59,7 @@ const initTheme = (darkMode: boolean) => { }, spacing: base, shape: { - borderRadius: '6px', + borderRadius: 6, }, shadows: [ 'none', @@ -495,6 +495,13 @@ const initTheme = (darkMode: boolean) => { }), }, }, + MuiLinearProgress: { + styleOverrides: { + root: ({ theme }) => ({ + backgroundColor: theme.palette.border.light, + }), + }, + }, }, }) } diff --git a/src/styles/vars.css b/src/styles/vars.css index 54f81e18ad..a37695c4e6 100644 --- a/src/styles/vars.css +++ b/src/styles/vars.css @@ -29,7 +29,7 @@ --color-warning-dark: #cd674e; --color-warning-main: #ff8061; --color-warning-light: #ffb7a6; - --color-warning-background: #effff4; + --color-warning-background: #fff0ed; --color-background-default: #f4f4f4; --color-background-main: #f4f4f4; --color-background-paper: #ffffff;