@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.
Files changed (94) hide show
  1. package/README.md +124 -0
  2. package/dist/_types/auth/authStoreSlice.d.ts +33 -0
  3. package/dist/_types/auth/authStoreSlice.d.ts.map +1 -0
  4. package/dist/_types/auth/components/BlobAnimation/index.d.ts +4 -0
  5. package/dist/_types/auth/components/BlobAnimation/index.d.ts.map +1 -0
  6. package/dist/_types/auth/components/CodeInput/index.d.ts +11 -0
  7. package/dist/_types/auth/components/CodeInput/index.d.ts.map +1 -0
  8. package/dist/_types/auth/hooks/useAuth.d.ts +18 -0
  9. package/dist/_types/auth/hooks/useAuth.d.ts.map +1 -0
  10. package/dist/_types/auth/index.d.ts +4 -0
  11. package/dist/_types/auth/index.d.ts.map +1 -0
  12. package/dist/_types/auth/pages/EmailVerification.d.ts +2 -0
  13. package/dist/_types/auth/pages/EmailVerification.d.ts.map +1 -0
  14. package/dist/_types/auth/pages/ErrorScreen.d.ts +9 -0
  15. package/dist/_types/auth/pages/ErrorScreen.d.ts.map +1 -0
  16. package/dist/_types/auth/pages/OtpInput.d.ts +2 -0
  17. package/dist/_types/auth/pages/OtpInput.d.ts.map +1 -0
  18. package/dist/_types/auth/pages/SignUp.d.ts +2 -0
  19. package/dist/_types/auth/pages/SignUp.d.ts.map +1 -0
  20. package/dist/_types/auth/pages/Verifying.d.ts +2 -0
  21. package/dist/_types/auth/pages/Verifying.d.ts.map +1 -0
  22. package/dist/_types/auth/pages/WalletSelection.d.ts +2 -0
  23. package/dist/_types/auth/pages/WalletSelection.d.ts.map +1 -0
  24. package/dist/_types/auth/types.d.ts +12 -0
  25. package/dist/_types/auth/types.d.ts.map +1 -0
  26. package/dist/_types/auth/utils/url.d.ts +3 -0
  27. package/dist/_types/auth/utils/url.d.ts.map +1 -0
  28. package/dist/_types/connector.d.ts +17 -0
  29. package/dist/_types/connector.d.ts.map +1 -0
  30. package/dist/_types/index.d.ts +10 -0
  31. package/dist/_types/index.d.ts.map +1 -0
  32. package/dist/_types/shared/components/PoweredBy/index.d.ts +6 -0
  33. package/dist/_types/shared/components/PoweredBy/index.d.ts.map +1 -0
  34. package/dist/_types/shared/components/Screen/EllipticalRadial.d.ts +23 -0
  35. package/dist/_types/shared/components/Screen/EllipticalRadial.d.ts.map +1 -0
  36. package/dist/_types/shared/components/Screen/MultiRadialBackground.d.ts +4 -0
  37. package/dist/_types/shared/components/Screen/MultiRadialBackground.d.ts.map +1 -0
  38. package/dist/_types/shared/components/Screen/index.d.ts +9 -0
  39. package/dist/_types/shared/components/Screen/index.d.ts.map +1 -0
  40. package/dist/_types/shared/components/SignUpFooter/index.d.ts +8 -0
  41. package/dist/_types/shared/components/SignUpFooter/index.d.ts.map +1 -0
  42. package/dist/_types/shared/components/StatusScreen/index.d.ts +10 -0
  43. package/dist/_types/shared/components/StatusScreen/index.d.ts.map +1 -0
  44. package/dist/_types/shared/components/TopNav/index.d.ts +10 -0
  45. package/dist/_types/shared/components/TopNav/index.d.ts.map +1 -0
  46. package/dist/_types/shared/hooks/useKitStore.d.ts +11 -0
  47. package/dist/_types/shared/hooks/useKitStore.d.ts.map +1 -0
  48. package/dist/_types/shared/utils/common.d.ts +5 -0
  49. package/dist/_types/shared/utils/common.d.ts.map +1 -0
  50. package/dist/_types/shared/utils/store.d.ts +4 -0
  51. package/dist/_types/shared/utils/store.d.ts.map +1 -0
  52. package/dist/_types/store.d.ts +25 -0
  53. package/dist/_types/store.d.ts.map +1 -0
  54. package/dist/_types/types.d.ts +38 -0
  55. package/dist/_types/types.d.ts.map +1 -0
  56. package/dist/blob.webm +0 -0
  57. package/dist/error.webp +0 -0
  58. package/dist/index.cjs +4 -0
  59. package/dist/index.cjs.map +1 -0
  60. package/dist/index.mjs +1374 -0
  61. package/dist/index.mjs.map +1 -0
  62. package/dist/loading.webp +0 -0
  63. package/dist/send.webp +0 -0
  64. package/dist/styles.css +2 -0
  65. package/dist/success.webp +0 -0
  66. package/package.json +96 -0
  67. package/src/assets.d.ts +14 -0
  68. package/src/auth/authStoreSlice.ts +180 -0
  69. package/src/auth/components/BlobAnimation/index.tsx +24 -0
  70. package/src/auth/components/CodeInput/index.tsx +118 -0
  71. package/src/auth/hooks/useAuth.ts +32 -0
  72. package/src/auth/index.tsx +108 -0
  73. package/src/auth/pages/EmailVerification.tsx +76 -0
  74. package/src/auth/pages/ErrorScreen.tsx +53 -0
  75. package/src/auth/pages/OtpInput.tsx +124 -0
  76. package/src/auth/pages/SignUp.tsx +303 -0
  77. package/src/auth/pages/Verifying.tsx +108 -0
  78. package/src/auth/pages/WalletSelection.tsx +53 -0
  79. package/src/auth/types.ts +23 -0
  80. package/src/auth/utils/url.ts +12 -0
  81. package/src/connector.ts +182 -0
  82. package/src/index.ts +25 -0
  83. package/src/shared/components/PoweredBy/index.tsx +26 -0
  84. package/src/shared/components/Screen/EllipticalRadial.tsx +65 -0
  85. package/src/shared/components/Screen/MultiRadialBackground.tsx +323 -0
  86. package/src/shared/components/Screen/index.tsx +65 -0
  87. package/src/shared/components/SignUpFooter/index.tsx +66 -0
  88. package/src/shared/components/StatusScreen/index.tsx +49 -0
  89. package/src/shared/components/TopNav/index.tsx +45 -0
  90. package/src/shared/hooks/useKitStore.ts +20 -0
  91. package/src/shared/utils/common.ts +26 -0
  92. package/src/shared/utils/store.ts +9 -0
  93. package/src/store.ts +44 -0
  94. 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
+ }