mirror of
https://github.com/status-im/status-web.git
synced 2026-08-31 06:11:10 +00:00
add import wallet flow (#693)
Co-authored-by: Jakub Kotula <520927+jkbktl@users.noreply.github.com> Co-authored-by: Felicio <felicio@users.noreply.github.com>
This commit is contained in:
co-authored by
Jakub Kotula
Felicio
parent
d354913420
commit
0a14da44e7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@status-im/components': minor
|
||||
'@status-im/wallet': minor
|
||||
'wallet': minor
|
||||
---
|
||||
|
||||
add import wallet flow
|
||||
Vendored
+11
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"npm.packageManager": "pnpm",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit"
|
||||
},
|
||||
"eslint.useESLintClass": true,
|
||||
"eslint.workingDirectories": [
|
||||
"./packages/colors",
|
||||
@@ -17,6 +20,14 @@
|
||||
"#comment": "See https://github.com/microsoft/vscode-eslint/issues/1161 for reason (i.e. multiple .eslintrc config files)"
|
||||
}
|
||||
],
|
||||
"eslint.validate": [
|
||||
// "markdown",
|
||||
// "mdx",
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact"
|
||||
],
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
||||
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
"@trpc/server": "10.45.2",
|
||||
"@trustwallet/wallet-core": "^4.2.10",
|
||||
"@wxt-dev/storage": "^1.1.0",
|
||||
"@zxcvbn-ts/core": "^3.0.4",
|
||||
"@zxcvbn-ts/language-common": "^3.0.4",
|
||||
"@zxcvbn-ts/language-en": "^3.0.2",
|
||||
"bitcoinjs-lib": "^6.1.7",
|
||||
"bitcore-lib": "^10.8.0",
|
||||
"buffer": "^6.0.3",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
@@ -82,7 +82,7 @@ test('should add wallet', async () => {
|
||||
})
|
||||
|
||||
expect(addedWallet.mnemonic).toBeDefined()
|
||||
}, 6000)
|
||||
}, 7000)
|
||||
|
||||
test('should import wallet', async () => {
|
||||
await createAPI()
|
||||
@@ -98,7 +98,7 @@ test('should import wallet', async () => {
|
||||
})
|
||||
|
||||
expect(importedWallet.mnemonic).toBe(mnemonic)
|
||||
}, 6000)
|
||||
}, 7000)
|
||||
|
||||
test('should get wallet', async () => {
|
||||
await createAPI()
|
||||
@@ -119,4 +119,4 @@ test('should get wallet', async () => {
|
||||
})
|
||||
|
||||
expect(returnedWallet.mnemonic).toBe(mnemonic)
|
||||
}, 6000)
|
||||
}, 7000)
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Link,
|
||||
// Navigate,
|
||||
Outlet,
|
||||
redirect,
|
||||
} from '@tanstack/react-router'
|
||||
import { TanStackRouterDevtools } from '@tanstack/router-devtools'
|
||||
|
||||
@@ -19,6 +20,7 @@ import { TanStackRouterDevtools } from '@tanstack/router-devtools'
|
||||
// import { QueryClientProvider } from '../../../portfolio/src/app/_providers/query-client-provider'
|
||||
// import { StatusProvider } from '../../../portfolio/src/app/_providers/status-provider'
|
||||
import { WagmiProvider } from '../../../portfolio/src/app/_providers/wagmi-provider'
|
||||
import { apiClient } from '../providers/api-client'
|
||||
import { WalletProvider } from '../providers/wallet-context'
|
||||
|
||||
// import { Inter } from 'next/font/google'
|
||||
@@ -34,6 +36,26 @@ import type { QueryClient } from '@tanstack/react-query'
|
||||
export const Route = createRootRouteWithContext<{
|
||||
queryClient: QueryClient
|
||||
}>()({
|
||||
beforeLoad: async ({ location }) => {
|
||||
const wallets = await apiClient.wallet.all.query()
|
||||
const hasWallets = wallets && wallets.length > 0
|
||||
|
||||
if (location.pathname === '/') {
|
||||
if (hasWallets) {
|
||||
throw redirect({ to: '/portfolio' })
|
||||
} else {
|
||||
throw redirect({ to: '/onboarding' })
|
||||
}
|
||||
}
|
||||
|
||||
if (location.pathname.startsWith('/portfolio') && !hasWallets) {
|
||||
throw redirect({ to: '/onboarding' })
|
||||
}
|
||||
|
||||
if (location.pathname.startsWith('/onboarding') && hasWallets) {
|
||||
throw redirect({ to: '/portfolio' })
|
||||
}
|
||||
},
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
@@ -52,7 +74,6 @@ export const Route = createRootRouteWithContext<{
|
||||
})
|
||||
|
||||
function RootComponent() {
|
||||
const pathname = window.location.pathname
|
||||
return (
|
||||
<>
|
||||
{/* <div className="min-h-screen bg-neutral-100 text-white-100">
|
||||
@@ -79,7 +100,7 @@ function RootComponent() {
|
||||
{/* <ConnectKitProvider> */}
|
||||
<WalletProvider>
|
||||
<div className="flex min-h-[56px] items-center px-2">
|
||||
<Navbar pathname={pathname} />
|
||||
<Navbar />
|
||||
</div>
|
||||
<div className="px-1">
|
||||
<div className="flex-1 flex-col 2md:flex xl:pb-1">
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
import { apiClient } from '../providers/api-client'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: RouteComponent,
|
||||
@@ -11,12 +9,6 @@ export const Route = createFileRoute('/')({
|
||||
},
|
||||
],
|
||||
}),
|
||||
beforeLoad: async () => {
|
||||
const wallets = await apiClient.wallet.all.query()
|
||||
if (wallets && wallets.length > 0) {
|
||||
throw redirect({ to: '/portfolio' })
|
||||
} else throw redirect({ to: '/onboarding' })
|
||||
},
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
|
||||
@@ -1,21 +1,33 @@
|
||||
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
||||
|
||||
import { apiClient } from '../../providers/api-client'
|
||||
import { BlurredCircle } from '@status-im/wallet/components'
|
||||
import { createFileRoute, Outlet } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/onboarding')({
|
||||
component: RouteComponent,
|
||||
beforeLoad: async () => {
|
||||
const wallets = await apiClient.wallet.all.query()
|
||||
if (wallets && wallets.length > 0) {
|
||||
throw redirect({ to: '/portfolio' })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<div className="m-auto min-h-[650px] w-full max-w-[440px] rounded-[24px] border border-neutral-5 p-5 shadow-2">
|
||||
<Outlet />
|
||||
<div className="relative flex h-screen min-h-[650px] bg-neutral-5">
|
||||
<BlurredCircle
|
||||
color="purple"
|
||||
className="absolute left-1/4 top-1/4 z-0 translate-y-[-100px]"
|
||||
/>
|
||||
<BlurredCircle
|
||||
color="sky"
|
||||
className="absolute left-2/4 top-1/4 z-0 translate-y-[-130px]"
|
||||
/>
|
||||
<BlurredCircle
|
||||
color="yellow"
|
||||
className="absolute left-1/4 top-2/4 z-0 translate-y-[-50px]"
|
||||
/>
|
||||
<BlurredCircle
|
||||
color="orange"
|
||||
className="absolute left-2/4 top-2/4 z-0 translate-y-[-80px]"
|
||||
/>
|
||||
|
||||
<div className="relative z-10 m-auto flex min-h-[650px] w-full max-w-[440px] flex-col rounded-[24px] border border-neutral-5 bg-white-100 p-5 shadow-2">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useTransition } from 'react'
|
||||
|
||||
import { Button, Input } from '@status-im/components'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Button, Text } from '@status-im/components'
|
||||
import { ArrowLeftIcon } from '@status-im/icons/20'
|
||||
import {
|
||||
CreatePasswordForm,
|
||||
type CreatePasswordFormValues,
|
||||
ImportRecoveryPhraseForm,
|
||||
type ImportRecoveryPhraseFormValues,
|
||||
} from '@status-im/wallet/components'
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||
|
||||
import { useImportWallet } from '../../hooks/use-import-wallet'
|
||||
|
||||
import type { SubmitHandler } from 'react-hook-form'
|
||||
|
||||
export const Route = createFileRoute('/onboarding/import')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
@@ -27,112 +35,115 @@ function RouteComponent() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex h-full flex-1">
|
||||
{onboardingState.type === 'import-wallet' && (
|
||||
<ImportWallet
|
||||
onNext={mnemonic =>
|
||||
onNext={(mnemonic: string) => {
|
||||
setOnboardingState({ type: 'create-password', mnemonic })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{onboardingState.type === 'create-password' && (
|
||||
<CreatePassword mnemonic={onboardingState.mnemonic} />
|
||||
<CreatePassword
|
||||
mnemonic={onboardingState.mnemonic}
|
||||
onBack={() =>
|
||||
setOnboardingState({
|
||||
type: 'import-wallet',
|
||||
mnemonic: '',
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ImportWallet({ onNext }: { onNext: (mnemonic: string) => void }) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
mnemonic: '',
|
||||
},
|
||||
})
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const onSubmit = handleSubmit(data => {
|
||||
onNext(data.mnemonic)
|
||||
})
|
||||
const onSubmit: SubmitHandler<
|
||||
ImportRecoveryPhraseFormValues
|
||||
> = async data => {
|
||||
try {
|
||||
startTransition(async () => {
|
||||
onNext(data.mnemonic)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<textarea
|
||||
className="h-32 rounded-12 border border-neutral-80 bg-neutral-90 p-2 text-white-100 placeholder:text-neutral-40 dark:border-neutral-60 dark:bg-neutral-100"
|
||||
placeholder="Enter your recovery phrase"
|
||||
{...register('mnemonic', {
|
||||
required: 'Recovery phrase is required',
|
||||
})}
|
||||
/>
|
||||
{errors.mnemonic && (
|
||||
<p className="text-13 text-danger-50">{errors.mnemonic.message}</p>
|
||||
)}
|
||||
<Button variant="danger" onClick={onSubmit}>
|
||||
Continue
|
||||
</Button>
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="pb-4">
|
||||
<Button
|
||||
href="/onboarding"
|
||||
variant="grey"
|
||||
icon={<ArrowLeftIcon color="$neutral-100" />}
|
||||
aria-label="Back"
|
||||
size="32"
|
||||
/>
|
||||
</div>
|
||||
<Text size={27} weight="semibold">
|
||||
Import via recovery phrase
|
||||
</Text>
|
||||
<Text size={15} color="$neutral-50" className="mb-4">
|
||||
Type or paste your 12, 15, 18, 21 or 24 words Ethereum recovery phrase
|
||||
</Text>
|
||||
|
||||
<ImportRecoveryPhraseForm onSubmit={onSubmit} loading={isPending} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CreatePassword({ mnemonic }: { mnemonic: string }) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch,
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
},
|
||||
})
|
||||
|
||||
function CreatePassword({
|
||||
mnemonic,
|
||||
onBack,
|
||||
}: {
|
||||
mnemonic: string
|
||||
onBack: () => void
|
||||
}) {
|
||||
const { importWalletAsync } = useImportWallet()
|
||||
const navigate = useNavigate()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const onSubmit = handleSubmit(async data => {
|
||||
await importWalletAsync({
|
||||
mnemonic,
|
||||
password: data.password,
|
||||
})
|
||||
})
|
||||
const handleSubmit: SubmitHandler<CreatePasswordFormValues> = async data => {
|
||||
try {
|
||||
startTransition(async () => {
|
||||
await importWalletAsync({
|
||||
mnemonic,
|
||||
password: data.password,
|
||||
})
|
||||
navigate({ to: '/portfolio' })
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* @ts-expect-error: fixme: Types of property 'onChange' are incompatible. */}
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="New password"
|
||||
{...register('password', {
|
||||
required: 'Password is required',
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: 'Password must be at least 8 characters',
|
||||
},
|
||||
})}
|
||||
<div>
|
||||
<Button
|
||||
onClick={onBack}
|
||||
variant="grey"
|
||||
icon={<ArrowLeftIcon color="$neutral-100" />}
|
||||
aria-label="Back"
|
||||
size="32"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h1 className="text-27 font-600">Create password</h1>
|
||||
<div className="text-15 text-neutral-50">
|
||||
To unlock the extension and sign transactions, the password is stored
|
||||
only on your device. Status can't recover it.
|
||||
</div>
|
||||
|
||||
<CreatePasswordForm
|
||||
onSubmit={handleSubmit}
|
||||
loading={isPending}
|
||||
confirmButtonLabel="Import Wallet"
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-13 text-danger-50">{errors.password.message}</p>
|
||||
)}
|
||||
{/* @ts-expect-error: fixme: Types of property 'onChange' are incompatible. */}
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Confirm password"
|
||||
{...register('confirmPassword', {
|
||||
required: 'Please confirm your password',
|
||||
validate: value =>
|
||||
value === watch('password') || 'Passwords do not match',
|
||||
})}
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-13 text-danger-50">
|
||||
{errors.confirmPassword.message}
|
||||
</p>
|
||||
)}
|
||||
<Button variant="danger" onClick={onSubmit}>
|
||||
Import Wallet
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ function RouteComponent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
<div className="flex h-full flex-1">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<Button
|
||||
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
import { AssetsList, PinExtension } from '@status-im/wallet/components'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import { usePinExtension } from '@/hooks/use-pin-extension'
|
||||
|
||||
import { apiClient } from '../../providers/api-client'
|
||||
import { useWallet } from '../../providers/wallet-context'
|
||||
|
||||
// import { DetailDrawer } from '../../../../portfolio/src/app/[address]/@detail/_drawer'
|
||||
@@ -14,12 +13,6 @@ import { useWallet } from '../../providers/wallet-context'
|
||||
|
||||
export const Route = createFileRoute('/portfolio/')({
|
||||
component: RouteComponent,
|
||||
beforeLoad: async () => {
|
||||
const wallets = await apiClient.wallet.all.query()
|
||||
if (!wallets || wallets.length === 0) {
|
||||
throw redirect({ to: '/onboarding' })
|
||||
}
|
||||
},
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
|
||||
@@ -10,8 +10,6 @@ export default {
|
||||
// todo: extend @status-im/components too
|
||||
presets: [config],
|
||||
|
||||
darkMode: 'media',
|
||||
|
||||
future: {
|
||||
hoverOnlyWhenSupported: true,
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ export { Switch } from './switch'
|
||||
export { Tabs } from './tabs'
|
||||
export { Tag } from './tag'
|
||||
export { Text } from './text'
|
||||
export { Textarea } from './textarea'
|
||||
export { Toast, ToastContainer, useToast } from './toast'
|
||||
export { Tooltip } from './tooltip'
|
||||
export type * from './types'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { Textarea, type TextareaProps } from './textarea'
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Textarea } from './textarea'
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react'
|
||||
|
||||
type Component = typeof Textarea
|
||||
|
||||
const meta: Meta<Component> = {
|
||||
title: 'Components/Textarea',
|
||||
component: Textarea,
|
||||
args: {
|
||||
placeholder: 'Type something...',
|
||||
},
|
||||
|
||||
render: props => (
|
||||
<div className="flex w-[300px] flex-col gap-3">
|
||||
<h3 className="mb-2 text-15 font-medium">Basic Textarea</h3>
|
||||
<Textarea {...props} />
|
||||
|
||||
<h3 className="mb-2 mt-4 text-15 font-medium">Clearable Textarea</h3>
|
||||
<Textarea {...props} clearable />
|
||||
|
||||
<h3 className="mb-2 mt-4 text-15 font-medium">Special States</h3>
|
||||
<Textarea {...props} isDisabled />
|
||||
<Textarea {...props} isInvalid />
|
||||
|
||||
<h3 className="mb-2 mt-4 text-15 font-medium">
|
||||
Textarea with Label and Meta
|
||||
</h3>
|
||||
<Textarea {...props} label="Label" />
|
||||
<Textarea {...props} label="Label" maxLength={280} />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
type Story = StoryObj<Component>
|
||||
|
||||
export const Light: Story = {
|
||||
args: {},
|
||||
}
|
||||
|
||||
export const Dark: Story = {
|
||||
parameters: {
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default meta
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef } from 'react'
|
||||
|
||||
import { ClearIcon } from '@status-im/icons/20'
|
||||
import { cva } from 'cva'
|
||||
import * as Aria from 'react-aria-components'
|
||||
|
||||
import type { VariantProps } from 'cva'
|
||||
|
||||
type Variants = VariantProps<typeof textareaStyles>
|
||||
|
||||
type Props = Aria.TextFieldProps & {
|
||||
size?: Variants['size']
|
||||
label?: string
|
||||
placeholder?: string
|
||||
clearable?: boolean
|
||||
}
|
||||
|
||||
const Textarea = (props: Props, ref: React.Ref<HTMLTextAreaElement>) => {
|
||||
const {
|
||||
size = '40',
|
||||
label,
|
||||
placeholder,
|
||||
inputMode,
|
||||
clearable = false,
|
||||
maxLength,
|
||||
...fieldProps
|
||||
} = props
|
||||
|
||||
return (
|
||||
<Aria.TextField {...fieldProps} className="grid gap-2">
|
||||
{(label || maxLength) && (
|
||||
<div className="grid grid-cols-2">
|
||||
<Aria.Label className="text-13 font-medium text-neutral-50 dark:text-neutral-40">
|
||||
{label}
|
||||
</Aria.Label>
|
||||
|
||||
{maxLength && (
|
||||
<Aria.Label className="text-right text-13 font-regular text-neutral-50 dark:text-neutral-40">
|
||||
{fieldProps.value?.length}/{maxLength}
|
||||
</Aria.Label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<Aria.TextArea
|
||||
ref={ref}
|
||||
inputMode={inputMode}
|
||||
placeholder={placeholder}
|
||||
className={textareaStyles({ size, clearable })}
|
||||
/>
|
||||
{clearable && (
|
||||
<Aria.Button
|
||||
className={clearableButtonStyles({ size })}
|
||||
aria-label="Clear input"
|
||||
onPress={() => props.onChange?.('')}
|
||||
>
|
||||
<ClearIcon />
|
||||
</Aria.Button>
|
||||
)}
|
||||
</div>
|
||||
</Aria.TextField>
|
||||
)
|
||||
}
|
||||
|
||||
const textareaStyles = cva({
|
||||
base: [
|
||||
'block h-32 w-full min-w-0 flex-1 border border-neutral-20 bg-white-100 px-4 py-2 text-15 text-neutral-100 placeholder-neutral-40 max-sm:text-[1rem]',
|
||||
'outline-none focus:border-neutral-40',
|
||||
'disabled:border-neutral-20 disabled:opacity-[.3]',
|
||||
'invalid:border-danger-50/40',
|
||||
'resize-none',
|
||||
|
||||
// dark
|
||||
'dark:border-neutral-70 dark:bg-neutral-95 dark:text-white-100 dark:placeholder-neutral-50',
|
||||
'dark:invalid:border-danger-50/40',
|
||||
],
|
||||
variants: {
|
||||
size: {
|
||||
'40': 'rounded-12 px-3',
|
||||
'32': 'rounded-10 px-2',
|
||||
},
|
||||
clearable: { true: '' },
|
||||
},
|
||||
})
|
||||
|
||||
const clearableButtonStyles = cva({
|
||||
base: [
|
||||
'absolute top-2 size-5 text-neutral-40 dark:text-neutral-60',
|
||||
'outline-none',
|
||||
],
|
||||
variants: {
|
||||
size: {
|
||||
'40': 'right-3',
|
||||
'32': 'right-2',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const _Textarea = forwardRef(Textarea)
|
||||
|
||||
export { _Textarea as Textarea }
|
||||
export type { Props as TextareaProps }
|
||||
@@ -84,7 +84,8 @@
|
||||
"superjson": "^2.2.1",
|
||||
"ts-pattern": "^5.3.1",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^4.3.7"
|
||||
"zustand": "^4.3.7",
|
||||
"@scure/bip39": "^1.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/devtools": "^4.3.1",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
|
||||
type Props = {
|
||||
color: string
|
||||
} & Pick<ComponentProps<'div'>, 'className'>
|
||||
|
||||
const BlurredCircle = ({ color, className }: Props) => {
|
||||
return (
|
||||
<div data-customisation={color} className={className}>
|
||||
<div className="size-[468px] rounded-full bg-customisation-50 opacity-[6%] blur-[100px]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { BlurredCircle }
|
||||
@@ -54,11 +54,13 @@ type FormValues = z.infer<typeof createPasswordSchema>
|
||||
type CreatePasswordFormProps = {
|
||||
onSubmit: (data: FormValues) => void
|
||||
loading: boolean
|
||||
confirmButtonLabel?: string
|
||||
}
|
||||
|
||||
const CreatePasswordForm = ({
|
||||
onSubmit,
|
||||
loading = false,
|
||||
confirmButtonLabel = 'Continue',
|
||||
}: CreatePasswordFormProps) => {
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(createPasswordSchema),
|
||||
@@ -179,7 +181,7 @@ const CreatePasswordForm = ({
|
||||
{loading ? (
|
||||
<LoadingIcon className="animate-spin text-white-100" />
|
||||
) : (
|
||||
'Continue'
|
||||
confirmButtonLabel
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { validateMnemonic } from '@scure/bip39'
|
||||
import { wordlist as english } from '@scure/bip39/wordlists/english'
|
||||
import { Button } from '@status-im/components'
|
||||
import { AlertIcon, LoadingIcon } from '@status-im/icons/20'
|
||||
import { FormProvider, useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { RecoveryPhraseTextarea } from '../recovery-phrase-textarea'
|
||||
|
||||
const mnemonicSchema = z.object({
|
||||
mnemonic: z.string().refine(value => validateMnemonic(value, english), {
|
||||
message: 'Invalid phrase. Check word count and spelling.',
|
||||
}),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof mnemonicSchema>
|
||||
|
||||
type ImportRecoveryPhraseFormProps = {
|
||||
onSubmit: (data: FormValues) => void
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const ImportRecoveryPhraseForm = ({
|
||||
onSubmit,
|
||||
loading = false,
|
||||
}: ImportRecoveryPhraseFormProps) => {
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(mnemonicSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
mnemonic: '',
|
||||
},
|
||||
})
|
||||
|
||||
const {
|
||||
formState: { errors },
|
||||
} = form
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex h-full flex-1 flex-col justify-between"
|
||||
>
|
||||
<RecoveryPhraseTextarea
|
||||
label="Recovery phrase"
|
||||
placeholder="Recovery phrase"
|
||||
name="mnemonic"
|
||||
/>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-6">
|
||||
{errors.mnemonic && (
|
||||
<p className="flex items-center gap-1 text-danger-50">
|
||||
<AlertIcon />
|
||||
<span className="text-13">{errors.mnemonic.message}</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={!form.formState.isValid}
|
||||
>
|
||||
{loading ? (
|
||||
<LoadingIcon className="animate-spin text-white-100" />
|
||||
) : (
|
||||
'Continue'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export { ImportRecoveryPhraseForm }
|
||||
export type { FormValues as ImportRecoveryPhraseFormValues }
|
||||
@@ -3,6 +3,7 @@ export * from '../utils/variants'
|
||||
export { AccountMenu } from './account-menu'
|
||||
export { type Account, Address, type AddressProps } from './address'
|
||||
export { AssetsList } from './assets-list'
|
||||
export { BlurredCircle } from './blurred-circle'
|
||||
export {
|
||||
CreatePasswordForm,
|
||||
type CreatePasswordFormValues,
|
||||
@@ -10,12 +11,17 @@ export {
|
||||
export { CurrencyAmount } from './currency-amount'
|
||||
export { DeleteAddressAlert } from './delete-address-alert'
|
||||
export { Image, type ImageProps } from './image'
|
||||
export {
|
||||
ImportRecoveryPhraseForm,
|
||||
type ImportRecoveryPhraseFormValues,
|
||||
} from './import-recovery-phrase-form'
|
||||
export { Logo, type LogoProps } from './logo'
|
||||
export { Navbar } from './nav-bar'
|
||||
export { NetworkExplorerLogo } from './network-explorer-logo'
|
||||
export { NetworkLogo } from './network-logo'
|
||||
export { PercentageChange } from './percentage-change'
|
||||
export { PinExtension } from './pin-extension'
|
||||
export { RecoveryPhraseTextarea } from './recovery-phrase-textarea'
|
||||
export { SettingsPopover } from './settings-popover'
|
||||
export {
|
||||
ShortenAddress,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Textarea } from '@status-im/components'
|
||||
import { useController } from 'react-hook-form'
|
||||
|
||||
import type { UseControllerProps } from 'react-hook-form'
|
||||
|
||||
export type Props = React.ComponentPropsWithoutRef<'textarea'> &
|
||||
UseControllerProps & {
|
||||
label: string
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
function RecoveryPhraseTextarea(props: Props) {
|
||||
const { label, placeholder } = props
|
||||
const { field, fieldState } = useController(props)
|
||||
const invalid = fieldState.invalid
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder={placeholder}
|
||||
data-invalid={invalid}
|
||||
aria-label={label}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { RecoveryPhraseTextarea }
|
||||
Generated
+206
-239
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user