@zerodev/wallet-react-ui 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -0
- package/dist/_types/auth/authStoreSlice.d.ts +33 -0
- package/dist/_types/auth/authStoreSlice.d.ts.map +1 -0
- package/dist/_types/auth/components/BlobAnimation/index.d.ts +4 -0
- package/dist/_types/auth/components/BlobAnimation/index.d.ts.map +1 -0
- package/dist/_types/auth/components/CodeInput/index.d.ts +11 -0
- package/dist/_types/auth/components/CodeInput/index.d.ts.map +1 -0
- package/dist/_types/auth/hooks/useAuth.d.ts +18 -0
- package/dist/_types/auth/hooks/useAuth.d.ts.map +1 -0
- package/dist/_types/auth/index.d.ts +4 -0
- package/dist/_types/auth/index.d.ts.map +1 -0
- package/dist/_types/auth/pages/EmailVerification.d.ts +2 -0
- package/dist/_types/auth/pages/EmailVerification.d.ts.map +1 -0
- package/dist/_types/auth/pages/ErrorScreen.d.ts +9 -0
- package/dist/_types/auth/pages/ErrorScreen.d.ts.map +1 -0
- package/dist/_types/auth/pages/OtpInput.d.ts +2 -0
- package/dist/_types/auth/pages/OtpInput.d.ts.map +1 -0
- package/dist/_types/auth/pages/SignUp.d.ts +2 -0
- package/dist/_types/auth/pages/SignUp.d.ts.map +1 -0
- package/dist/_types/auth/pages/Verifying.d.ts +2 -0
- package/dist/_types/auth/pages/Verifying.d.ts.map +1 -0
- package/dist/_types/auth/pages/WalletSelection.d.ts +2 -0
- package/dist/_types/auth/pages/WalletSelection.d.ts.map +1 -0
- package/dist/_types/auth/types.d.ts +12 -0
- package/dist/_types/auth/types.d.ts.map +1 -0
- package/dist/_types/auth/utils/url.d.ts +3 -0
- package/dist/_types/auth/utils/url.d.ts.map +1 -0
- package/dist/_types/connector.d.ts +17 -0
- package/dist/_types/connector.d.ts.map +1 -0
- package/dist/_types/index.d.ts +10 -0
- package/dist/_types/index.d.ts.map +1 -0
- package/dist/_types/shared/components/PoweredBy/index.d.ts +6 -0
- package/dist/_types/shared/components/PoweredBy/index.d.ts.map +1 -0
- package/dist/_types/shared/components/Screen/EllipticalRadial.d.ts +23 -0
- package/dist/_types/shared/components/Screen/EllipticalRadial.d.ts.map +1 -0
- package/dist/_types/shared/components/Screen/MultiRadialBackground.d.ts +4 -0
- package/dist/_types/shared/components/Screen/MultiRadialBackground.d.ts.map +1 -0
- package/dist/_types/shared/components/Screen/index.d.ts +9 -0
- package/dist/_types/shared/components/Screen/index.d.ts.map +1 -0
- package/dist/_types/shared/components/SignUpFooter/index.d.ts +8 -0
- package/dist/_types/shared/components/SignUpFooter/index.d.ts.map +1 -0
- package/dist/_types/shared/components/StatusScreen/index.d.ts +10 -0
- package/dist/_types/shared/components/StatusScreen/index.d.ts.map +1 -0
- package/dist/_types/shared/components/TopNav/index.d.ts +10 -0
- package/dist/_types/shared/components/TopNav/index.d.ts.map +1 -0
- package/dist/_types/shared/hooks/useKitStore.d.ts +11 -0
- package/dist/_types/shared/hooks/useKitStore.d.ts.map +1 -0
- package/dist/_types/shared/utils/common.d.ts +5 -0
- package/dist/_types/shared/utils/common.d.ts.map +1 -0
- package/dist/_types/shared/utils/store.d.ts +4 -0
- package/dist/_types/shared/utils/store.d.ts.map +1 -0
- package/dist/_types/store.d.ts +25 -0
- package/dist/_types/store.d.ts.map +1 -0
- package/dist/_types/types.d.ts +38 -0
- package/dist/_types/types.d.ts.map +1 -0
- package/dist/blob.webm +0 -0
- package/dist/error.webp +0 -0
- package/dist/index.cjs +4 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.mjs +1374 -0
- package/dist/index.mjs.map +1 -0
- package/dist/loading.webp +0 -0
- package/dist/send.webp +0 -0
- package/dist/styles.css +2 -0
- package/dist/success.webp +0 -0
- package/package.json +96 -0
- package/src/assets.d.ts +14 -0
- package/src/auth/authStoreSlice.ts +180 -0
- package/src/auth/components/BlobAnimation/index.tsx +24 -0
- package/src/auth/components/CodeInput/index.tsx +118 -0
- package/src/auth/hooks/useAuth.ts +32 -0
- package/src/auth/index.tsx +108 -0
- package/src/auth/pages/EmailVerification.tsx +76 -0
- package/src/auth/pages/ErrorScreen.tsx +53 -0
- package/src/auth/pages/OtpInput.tsx +124 -0
- package/src/auth/pages/SignUp.tsx +303 -0
- package/src/auth/pages/Verifying.tsx +108 -0
- package/src/auth/pages/WalletSelection.tsx +53 -0
- package/src/auth/types.ts +23 -0
- package/src/auth/utils/url.ts +12 -0
- package/src/connector.ts +182 -0
- package/src/index.ts +25 -0
- package/src/shared/components/PoweredBy/index.tsx +26 -0
- package/src/shared/components/Screen/EllipticalRadial.tsx +65 -0
- package/src/shared/components/Screen/MultiRadialBackground.tsx +323 -0
- package/src/shared/components/Screen/index.tsx +65 -0
- package/src/shared/components/SignUpFooter/index.tsx +66 -0
- package/src/shared/components/StatusScreen/index.tsx +49 -0
- package/src/shared/components/TopNav/index.tsx +45 -0
- package/src/shared/hooks/useKitStore.ts +20 -0
- package/src/shared/utils/common.ts +26 -0
- package/src/shared/utils/store.ts +9 -0
- package/src/store.ts +44 -0
- package/src/types.ts +48 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// ?no-inline forces Vite to emit blob.webm as a separate file (dist/blob.webm)
|
|
2
|
+
// instead of base64-inlining ~700KB into the JS bundle. Requires base:'./' in
|
|
3
|
+
// vite.config.ts so the emitted URL is package-relative, not root-absolute.
|
|
4
|
+
import blobWebm from '../../assets/blob.webm?no-inline'
|
|
5
|
+
|
|
6
|
+
// VP9-alpha WebM. Renders the animated blob on browsers that support WebM with
|
|
7
|
+
// an alpha channel (Chrome, Firefox, Edge). Safari plays the WebM container but
|
|
8
|
+
// not VP9 alpha, so the blob is simply not shown there — an acceptable
|
|
9
|
+
// degradation for a decorative element.
|
|
10
|
+
export function BlobAnimation({ className }: { className?: string }) {
|
|
11
|
+
return (
|
|
12
|
+
<video
|
|
13
|
+
className={className}
|
|
14
|
+
style={{ aspectRatio: '1 / 1' }}
|
|
15
|
+
src={blobWebm}
|
|
16
|
+
autoPlay
|
|
17
|
+
loop
|
|
18
|
+
muted
|
|
19
|
+
playsInline
|
|
20
|
+
aria-hidden="true"
|
|
21
|
+
tabIndex={-1}
|
|
22
|
+
/>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { cn, Text, Wrapper } from '@zerodev/react-ui'
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
const MIN_LENGTH = 4
|
|
5
|
+
const MAX_LENGTH = 8
|
|
6
|
+
const DEFAULT_LENGTH = 6
|
|
7
|
+
|
|
8
|
+
function clampLength(length: number): number {
|
|
9
|
+
return Math.max(MIN_LENGTH, Math.min(MAX_LENGTH, length))
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface CharBoxProps {
|
|
13
|
+
char: string
|
|
14
|
+
isFocused: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function CharBox({ char, isFocused }: CharBoxProps) {
|
|
18
|
+
return (
|
|
19
|
+
<Wrapper
|
|
20
|
+
data-testid="code-input-box"
|
|
21
|
+
data-active={isFocused || undefined}
|
|
22
|
+
className={cn(
|
|
23
|
+
'zd:h-16 zd:w-14 zd:rounded-lg zd:flex zd:items-center zd:justify-center',
|
|
24
|
+
isFocused && 'zd:border-[1.5px] zd:border-greyScale',
|
|
25
|
+
)}
|
|
26
|
+
>
|
|
27
|
+
<Text className="zd:text-h2">{char}</Text>
|
|
28
|
+
</Wrapper>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CodeInputProps {
|
|
33
|
+
onChange?: (code: string) => void
|
|
34
|
+
onComplete?: (code: string) => void
|
|
35
|
+
disabled?: boolean
|
|
36
|
+
error?: boolean
|
|
37
|
+
autoFocus?: boolean
|
|
38
|
+
length?: number
|
|
39
|
+
'data-testid'?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function CodeInput({
|
|
43
|
+
onChange,
|
|
44
|
+
onComplete,
|
|
45
|
+
disabled = false,
|
|
46
|
+
error = false,
|
|
47
|
+
autoFocus = false,
|
|
48
|
+
length = DEFAULT_LENGTH,
|
|
49
|
+
'data-testid': testId,
|
|
50
|
+
}: CodeInputProps) {
|
|
51
|
+
const codeLength = clampLength(length)
|
|
52
|
+
const charBoxes = useMemo(
|
|
53
|
+
() =>
|
|
54
|
+
Array.from({ length: codeLength }, (_, i) => ({
|
|
55
|
+
id: `char-${i}`,
|
|
56
|
+
index: i,
|
|
57
|
+
})),
|
|
58
|
+
[codeLength],
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const [code, setCode] = useState('')
|
|
62
|
+
const [isFocused, setIsFocused] = useState(false)
|
|
63
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (autoFocus && !disabled) {
|
|
67
|
+
inputRef.current?.focus()
|
|
68
|
+
}
|
|
69
|
+
}, [autoFocus, disabled])
|
|
70
|
+
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
setCode((prev) => prev.slice(0, codeLength))
|
|
73
|
+
}, [codeLength])
|
|
74
|
+
|
|
75
|
+
const handleChange = (text: string) => {
|
|
76
|
+
const sanitized = text.slice(0, codeLength).toUpperCase()
|
|
77
|
+
setCode(sanitized)
|
|
78
|
+
onChange?.(sanitized)
|
|
79
|
+
|
|
80
|
+
if (sanitized.length === codeLength) {
|
|
81
|
+
// Makes sure onComplete is run on the next render cycle after the last char is rendered
|
|
82
|
+
setTimeout(() => {
|
|
83
|
+
onComplete?.(sanitized)
|
|
84
|
+
inputRef.current?.blur()
|
|
85
|
+
}, 0)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
className="zd:flex zd:flex-row zd:items-center zd:justify-between zd:gap-2 zd:w-full zd:cursor-text"
|
|
93
|
+
onClick={() => inputRef.current?.focus()}
|
|
94
|
+
disabled={disabled}
|
|
95
|
+
data-testid={testId}
|
|
96
|
+
>
|
|
97
|
+
<input
|
|
98
|
+
ref={inputRef}
|
|
99
|
+
value={code}
|
|
100
|
+
onChange={(e) => handleChange(e.target.value)}
|
|
101
|
+
onFocus={() => setIsFocused(true)}
|
|
102
|
+
onBlur={() => setIsFocused(false)}
|
|
103
|
+
maxLength={codeLength}
|
|
104
|
+
disabled={disabled}
|
|
105
|
+
className="zd:absolute zd:opacity-0 zd:pointer-events-none"
|
|
106
|
+
style={{ position: 'absolute', opacity: 0 }}
|
|
107
|
+
aria-label="Verification code"
|
|
108
|
+
/>
|
|
109
|
+
{charBoxes.map((box) => (
|
|
110
|
+
<CharBox
|
|
111
|
+
key={box.id}
|
|
112
|
+
char={code[box.index] ?? ''}
|
|
113
|
+
isFocused={!error && isFocused && box.index === code.length}
|
|
114
|
+
/>
|
|
115
|
+
))}
|
|
116
|
+
</button>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useStore } from 'zustand'
|
|
2
|
+
import { useKitStore } from '../../shared/hooks/useKitStore'
|
|
3
|
+
|
|
4
|
+
export function useAuth() {
|
|
5
|
+
const store = useKitStore()
|
|
6
|
+
|
|
7
|
+
const step = useStore(store, (state) => state.auth.step)
|
|
8
|
+
const stepHistory = useStore(store, (state) => state.auth.stepHistory)
|
|
9
|
+
const email = useStore(store, (state) => state.auth.email)
|
|
10
|
+
const otpId = useStore(store, (state) => state.auth.otpId)
|
|
11
|
+
const otpEncryptionTargetBundle = useStore(
|
|
12
|
+
store,
|
|
13
|
+
(state) => state.auth.otpEncryptionTargetBundle,
|
|
14
|
+
)
|
|
15
|
+
const enabledMethods = useStore(store, (state) => state.auth.enabledMethods)
|
|
16
|
+
const authConfig = useStore(store, (state) => state.auth.config)
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
step,
|
|
20
|
+
email,
|
|
21
|
+
otpId,
|
|
22
|
+
otpEncryptionTargetBundle,
|
|
23
|
+
enabledMethods,
|
|
24
|
+
config: authConfig,
|
|
25
|
+
goToStep: store.getState().auth.goToStep,
|
|
26
|
+
goBack: stepHistory.length > 0 ? store.getState().auth.goBack : null,
|
|
27
|
+
reset: store.getState().auth.reset,
|
|
28
|
+
setEmail: store.getState().auth.setEmail,
|
|
29
|
+
setOtpSession: store.getState().auth.setOtpSession,
|
|
30
|
+
clearOtpSession: store.getState().auth.clearOtpSession,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { type ReactNode, useEffect } from 'react'
|
|
2
|
+
import { useStore } from 'zustand'
|
|
3
|
+
import { Screen } from '../shared/components/Screen'
|
|
4
|
+
import { StatusScreen } from '../shared/components/StatusScreen'
|
|
5
|
+
import { TopNav } from '../shared/components/TopNav'
|
|
6
|
+
import { useKitStore } from '../shared/hooks/useKitStore'
|
|
7
|
+
import { useAuth } from './hooks/useAuth'
|
|
8
|
+
import { EmailVerification } from './pages/EmailVerification'
|
|
9
|
+
import { ErrorScreen } from './pages/ErrorScreen'
|
|
10
|
+
import { OtpInput } from './pages/OtpInput'
|
|
11
|
+
import { SignUp } from './pages/SignUp'
|
|
12
|
+
import { Verifying } from './pages/Verifying'
|
|
13
|
+
import { WalletSelection } from './pages/WalletSelection'
|
|
14
|
+
import type { AuthStep } from './types'
|
|
15
|
+
import { hasMagicLinkCodeInUrl, stripMagicLinkCodeFromUrl } from './utils/url'
|
|
16
|
+
|
|
17
|
+
const TITLE_BY_STEP: Partial<Record<AuthStep, string>> = {
|
|
18
|
+
'wallet-selection': 'Choose your wallet',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function OAuthCallback() {
|
|
22
|
+
return (
|
|
23
|
+
<div className="zd:flex zd:flex-1 zd:items-center zd:justify-center">
|
|
24
|
+
<StatusScreen imageName="loading" title="Authenticating...">
|
|
25
|
+
Please wait while we complete the OAuth authentication.
|
|
26
|
+
</StatusScreen>
|
|
27
|
+
</div>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function PasskeyPrompt() {
|
|
32
|
+
return (
|
|
33
|
+
<div className="zd:flex zd:flex-1 zd:items-center zd:justify-center">
|
|
34
|
+
<StatusScreen imageName="loading" title="Passkey authentication">
|
|
35
|
+
Please authenticate with your passkey.
|
|
36
|
+
</StatusScreen>
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function renderStep(step: AuthStep | null): ReactNode {
|
|
42
|
+
switch (step) {
|
|
43
|
+
case 'sign-up':
|
|
44
|
+
return <SignUp />
|
|
45
|
+
case 'email-verification':
|
|
46
|
+
return <EmailVerification />
|
|
47
|
+
case 'otp-input':
|
|
48
|
+
return <OtpInput />
|
|
49
|
+
case 'verifying-otp':
|
|
50
|
+
return <Verifying />
|
|
51
|
+
case 'oauth-in-progress':
|
|
52
|
+
return <OAuthCallback />
|
|
53
|
+
case 'passkey-prompt':
|
|
54
|
+
return <PasskeyPrompt />
|
|
55
|
+
case 'wallet-selection':
|
|
56
|
+
return <WalletSelection />
|
|
57
|
+
case 'error':
|
|
58
|
+
return <ErrorScreen />
|
|
59
|
+
default:
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function AuthFlow({
|
|
65
|
+
onClose: userOnClose,
|
|
66
|
+
}: {
|
|
67
|
+
onClose?: (() => void) | undefined
|
|
68
|
+
} = {}) {
|
|
69
|
+
const { step, goToStep, goBack, reset } = useAuth()
|
|
70
|
+
const logo = useStore(useKitStore(), (s) => s.logo)
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (step === null && hasMagicLinkCodeInUrl()) {
|
|
74
|
+
goToStep('verifying-otp')
|
|
75
|
+
}
|
|
76
|
+
}, [step, goToStep])
|
|
77
|
+
|
|
78
|
+
const content = renderStep(step)
|
|
79
|
+
if (!content) return null
|
|
80
|
+
|
|
81
|
+
const handleClose = () => {
|
|
82
|
+
stripMagicLinkCodeFromUrl()
|
|
83
|
+
reset()
|
|
84
|
+
userOnClose?.()
|
|
85
|
+
}
|
|
86
|
+
const title = step ? TITLE_BY_STEP[step] : undefined
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<Screen
|
|
90
|
+
// Some elements in SignUp need to go from edge to edge.
|
|
91
|
+
// No vertical padding; we set px-0 so we can fully control this padding.
|
|
92
|
+
contentClassName={step === 'sign-up' ? 'zd:px-0' : undefined}
|
|
93
|
+
topNav={
|
|
94
|
+
<TopNav
|
|
95
|
+
{...(goBack !== null && { onBack: goBack })}
|
|
96
|
+
onClose={handleClose}
|
|
97
|
+
{...(title && { title })}
|
|
98
|
+
{...(step === 'sign-up' && {
|
|
99
|
+
...(logo && { logo }),
|
|
100
|
+
className: 'zd:px-4',
|
|
101
|
+
})}
|
|
102
|
+
/>
|
|
103
|
+
}
|
|
104
|
+
>
|
|
105
|
+
{content}
|
|
106
|
+
</Screen>
|
|
107
|
+
)
|
|
108
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { Text } from '@zerodev/react-ui'
|
|
2
|
+
import { useSendMagicLink } from '@zerodev/wallet-react'
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { PoweredBy } from '../../shared/components/PoweredBy'
|
|
5
|
+
import { StatusScreen } from '../../shared/components/StatusScreen'
|
|
6
|
+
import { useAuth } from '../hooks/useAuth'
|
|
7
|
+
|
|
8
|
+
export function EmailVerification() {
|
|
9
|
+
const { email, setOtpSession } = useAuth()
|
|
10
|
+
const { mutateAsync: sendMagicLink, isPending: isSendPending } =
|
|
11
|
+
useSendMagicLink()
|
|
12
|
+
|
|
13
|
+
const [secondsLeftUntilResend, setSecondsLeftUntilResend] = useState(60)
|
|
14
|
+
const canResend = secondsLeftUntilResend <= 0 && !isSendPending
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (secondsLeftUntilResend <= 0) return
|
|
18
|
+
|
|
19
|
+
const timer = setInterval(() => {
|
|
20
|
+
setSecondsLeftUntilResend((prev) => Math.max(0, prev - 1))
|
|
21
|
+
}, 1000)
|
|
22
|
+
|
|
23
|
+
return () => {
|
|
24
|
+
clearInterval(timer)
|
|
25
|
+
}
|
|
26
|
+
}, [secondsLeftUntilResend])
|
|
27
|
+
|
|
28
|
+
const handleResend = async () => {
|
|
29
|
+
if (!email || !canResend) return
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const { otpId, otpEncryptionTargetBundle } = await sendMagicLink({
|
|
33
|
+
email,
|
|
34
|
+
})
|
|
35
|
+
setOtpSession({ otpId, otpEncryptionTargetBundle })
|
|
36
|
+
setSecondsLeftUntilResend(60)
|
|
37
|
+
} catch {
|
|
38
|
+
// Error resending magic link
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<>
|
|
44
|
+
<div className="zd:flex-1 zd:flex zd:flex-col zd:gap-8 zd:justify-center">
|
|
45
|
+
<StatusScreen
|
|
46
|
+
imageName="send"
|
|
47
|
+
title={'Check your email!\n An Email is On Its Way'}
|
|
48
|
+
>
|
|
49
|
+
We've sent a magic link to{' '}
|
|
50
|
+
<Text as="span" className="zd:text-solarOrange">
|
|
51
|
+
{email}
|
|
52
|
+
</Text>
|
|
53
|
+
{'\n'}Please open the email and click the link to log in.
|
|
54
|
+
</StatusScreen>
|
|
55
|
+
|
|
56
|
+
<div className="zd:flex zd:flex-col zd:gap-1">
|
|
57
|
+
<Text className="zd:text-center">
|
|
58
|
+
Did not get an email?{' '}
|
|
59
|
+
<button
|
|
60
|
+
type="button"
|
|
61
|
+
disabled={!canResend}
|
|
62
|
+
onClick={handleResend}
|
|
63
|
+
className="zd:cursor-pointer zd:underline zd:disabled:opacity-50 zd:disabled:cursor-not-allowed"
|
|
64
|
+
>
|
|
65
|
+
{canResend
|
|
66
|
+
? 'Resend'
|
|
67
|
+
: `Resend in ${secondsLeftUntilResend} ${secondsLeftUntilResend === 1 ? 'second' : 'seconds'}`}
|
|
68
|
+
</button>
|
|
69
|
+
</Text>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<PoweredBy className="zd:self-center zd:pt-4 zd:pb-6" />
|
|
74
|
+
</>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Button } from '@zerodev/react-ui'
|
|
2
|
+
import { PoweredBy } from '../../shared/components/PoweredBy'
|
|
3
|
+
import { StatusScreen } from '../../shared/components/StatusScreen'
|
|
4
|
+
import { useAuth } from '../hooks/useAuth'
|
|
5
|
+
import { stripMagicLinkCodeFromUrl } from '../utils/url'
|
|
6
|
+
|
|
7
|
+
interface ErrorScreenProps {
|
|
8
|
+
title?: string
|
|
9
|
+
message?: string
|
|
10
|
+
showRetry?: boolean
|
|
11
|
+
showChooseAnother?: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ErrorScreen({
|
|
15
|
+
title = 'Oops, something went wrong',
|
|
16
|
+
message = "We couldn't complete the sign-in process. This could be due to timeout, an expired link, or a cancelled request.",
|
|
17
|
+
showRetry = false,
|
|
18
|
+
showChooseAnother = true,
|
|
19
|
+
}: ErrorScreenProps) {
|
|
20
|
+
const { goToStep, goBack, reset } = useAuth()
|
|
21
|
+
const handleRetry = goBack ?? (() => goToStep('sign-up'))
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<>
|
|
25
|
+
<div className="zd:flex-1 zd:flex zd:flex-col zd:gap-8 zd:items-center zd:justify-center">
|
|
26
|
+
<StatusScreen imageName="error" title={title}>
|
|
27
|
+
{message}
|
|
28
|
+
</StatusScreen>
|
|
29
|
+
|
|
30
|
+
<div className="zd:flex zd:flex-col zd:gap-1">
|
|
31
|
+
{showRetry && (
|
|
32
|
+
<Button action="primary" text="Try again" onClick={handleRetry} />
|
|
33
|
+
)}
|
|
34
|
+
{showChooseAnother && (
|
|
35
|
+
<Button
|
|
36
|
+
action={showRetry ? 'secondary' : 'primary'}
|
|
37
|
+
onClick={() => {
|
|
38
|
+
stripMagicLinkCodeFromUrl()
|
|
39
|
+
goToStep('sign-up')
|
|
40
|
+
}}
|
|
41
|
+
text="Choose another sign-in method"
|
|
42
|
+
/>
|
|
43
|
+
)}
|
|
44
|
+
{!showRetry && !showChooseAnother && (
|
|
45
|
+
<Button action="primary" text="Start over" onClick={reset} />
|
|
46
|
+
)}
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<PoweredBy className="zd:self-center zd:pt-4 zd:pb-6" />
|
|
51
|
+
</>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { Button, Text } from '@zerodev/react-ui'
|
|
2
|
+
import { useSendOTP, useVerifyOTP } from '@zerodev/wallet-react'
|
|
3
|
+
import { useEffect, useState } from 'react'
|
|
4
|
+
import { PoweredBy } from '../../shared/components/PoweredBy'
|
|
5
|
+
import { CodeInput } from '../components/CodeInput'
|
|
6
|
+
import { useAuth } from '../hooks/useAuth'
|
|
7
|
+
|
|
8
|
+
export function OtpInput() {
|
|
9
|
+
const {
|
|
10
|
+
email,
|
|
11
|
+
otpId,
|
|
12
|
+
otpEncryptionTargetBundle,
|
|
13
|
+
setOtpSession,
|
|
14
|
+
clearOtpSession,
|
|
15
|
+
goToStep,
|
|
16
|
+
config,
|
|
17
|
+
} = useAuth()
|
|
18
|
+
const { mutateAsync: sendOtp, isPending: isSendOtpPending } = useSendOTP()
|
|
19
|
+
const { mutateAsync: verifyOtp, isPending } = useVerifyOTP()
|
|
20
|
+
|
|
21
|
+
const [otp, setOtp] = useState('')
|
|
22
|
+
const [error, setError] = useState(false)
|
|
23
|
+
const [secondsUntilResend, setSecondsUntilResend] = useState(60)
|
|
24
|
+
|
|
25
|
+
// Countdown timer for resend
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (secondsUntilResend <= 0) return
|
|
28
|
+
|
|
29
|
+
const interval = setInterval(() => {
|
|
30
|
+
setSecondsUntilResend((prev) => Math.max(0, prev - 1))
|
|
31
|
+
}, 1000)
|
|
32
|
+
return () => clearInterval(interval)
|
|
33
|
+
}, [secondsUntilResend])
|
|
34
|
+
|
|
35
|
+
const handleVerify = async () => {
|
|
36
|
+
if (!otp.trim() || !otpId || !otpEncryptionTargetBundle) return
|
|
37
|
+
|
|
38
|
+
setError(false)
|
|
39
|
+
try {
|
|
40
|
+
await verifyOtp({
|
|
41
|
+
otpId,
|
|
42
|
+
code: otp.trim(),
|
|
43
|
+
otpEncryptionTargetBundle,
|
|
44
|
+
})
|
|
45
|
+
clearOtpSession()
|
|
46
|
+
goToStep('authenticated')
|
|
47
|
+
config?.onSuccess?.()
|
|
48
|
+
} catch (err) {
|
|
49
|
+
setError(true)
|
|
50
|
+
config?.onError?.(err)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const handleComplete = (code: string) => {
|
|
55
|
+
setOtp(code)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const handleResend = async () => {
|
|
59
|
+
if (!email || secondsUntilResend > 0 || isSendOtpPending) return
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const { otpId: newOtpId, otpEncryptionTargetBundle: newBundle } =
|
|
63
|
+
await sendOtp({ email })
|
|
64
|
+
setOtpSession({
|
|
65
|
+
otpId: newOtpId,
|
|
66
|
+
otpEncryptionTargetBundle: newBundle,
|
|
67
|
+
})
|
|
68
|
+
setSecondsUntilResend(60)
|
|
69
|
+
setError(false)
|
|
70
|
+
} catch {
|
|
71
|
+
setError(true)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const canResend = secondsUntilResend <= 0 && !isSendOtpPending
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<>
|
|
79
|
+
<div className="zd:flex-1 zd:flex zd:flex-col zd:gap-8 zd:justify-center zd:items-center">
|
|
80
|
+
<div className="zd:flex zd:flex-col zd:gap-4">
|
|
81
|
+
<Text className="zd:text-h2 zd:text-center">
|
|
82
|
+
Enter verification code
|
|
83
|
+
</Text>
|
|
84
|
+
<Text className="zd:text-center">
|
|
85
|
+
Enter the code from the email we sent to{' '}
|
|
86
|
+
<Text className="zd:text-solarOrange">{email}</Text>
|
|
87
|
+
</Text>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<CodeInput
|
|
91
|
+
onComplete={handleComplete}
|
|
92
|
+
onChange={() => setError(false)}
|
|
93
|
+
disabled={isPending}
|
|
94
|
+
error={error}
|
|
95
|
+
autoFocus
|
|
96
|
+
/>
|
|
97
|
+
|
|
98
|
+
<Button
|
|
99
|
+
text="Confirm code"
|
|
100
|
+
onClick={handleVerify}
|
|
101
|
+
disabled={!otp.trim() || isPending}
|
|
102
|
+
/>
|
|
103
|
+
|
|
104
|
+
<div className="zd:flex zd:flex-col zd:gap-1">
|
|
105
|
+
<Text className="zd:text-center">
|
|
106
|
+
Did not get an email?{' '}
|
|
107
|
+
<button
|
|
108
|
+
type="button"
|
|
109
|
+
disabled={!canResend}
|
|
110
|
+
onClick={handleResend}
|
|
111
|
+
className="zd:cursor-pointer zd:underline zd:disabled:opacity-50 zd:disabled:cursor-not-allowed"
|
|
112
|
+
>
|
|
113
|
+
{canResend
|
|
114
|
+
? 'Resend'
|
|
115
|
+
: `Resend in ${secondsUntilResend} ${secondsUntilResend === 1 ? 'second' : 'seconds'}`}
|
|
116
|
+
</button>
|
|
117
|
+
</Text>
|
|
118
|
+
</div>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<PoweredBy className="zd:self-center zd:pt-4 zd:pb-6" />
|
|
122
|
+
</>
|
|
123
|
+
)
|
|
124
|
+
}
|