@zerodev/wallet-react-ui 0.0.11 → 0.0.13

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 (81) hide show
  1. package/README.md +38 -7
  2. package/dist/_types/auth/authStoreSlice.d.ts +31 -0
  3. package/dist/_types/auth/authStoreSlice.d.ts.map +1 -1
  4. package/dist/_types/auth/brandAssets.d.ts +2 -0
  5. package/dist/_types/auth/brandAssets.d.ts.map +1 -0
  6. package/dist/_types/auth/components/WalletSheet/index.d.ts +20 -0
  7. package/dist/_types/auth/components/WalletSheet/index.d.ts.map +1 -0
  8. package/dist/_types/auth/hooks/useAuth.d.ts +5 -0
  9. package/dist/_types/auth/hooks/useAuth.d.ts.map +1 -1
  10. package/dist/_types/auth/hooks/useWalletConnectPairing.d.ts +19 -0
  11. package/dist/_types/auth/hooks/useWalletConnectPairing.d.ts.map +1 -0
  12. package/dist/_types/auth/hooks/useWalletInfo.d.ts +32 -0
  13. package/dist/_types/auth/hooks/useWalletInfo.d.ts.map +1 -0
  14. package/dist/_types/auth/index.d.ts.map +1 -1
  15. package/dist/_types/auth/pages/SignUp/InstalledWallets.d.ts.map +1 -1
  16. package/dist/_types/auth/pages/SignUp/MoreWallets.d.ts.map +1 -1
  17. package/dist/_types/auth/pages/SignUp/Wallet.d.ts.map +1 -1
  18. package/dist/_types/auth/pages/SignUp/WalletConnect.d.ts +5 -0
  19. package/dist/_types/auth/pages/SignUp/WalletConnect.d.ts.map +1 -0
  20. package/dist/_types/auth/pages/SignUp/context.d.ts +4 -0
  21. package/dist/_types/auth/pages/SignUp/context.d.ts.map +1 -1
  22. package/dist/_types/auth/pages/SignUp/index.d.ts +2 -0
  23. package/dist/_types/auth/pages/SignUp/index.d.ts.map +1 -1
  24. package/dist/_types/auth/pages/Verifying.d.ts.map +1 -1
  25. package/dist/_types/auth/pages/WalletConnecting.d.ts +11 -0
  26. package/dist/_types/auth/pages/WalletConnecting.d.ts.map +1 -0
  27. package/dist/_types/auth/types.d.ts +1 -1
  28. package/dist/_types/auth/types.d.ts.map +1 -1
  29. package/dist/_types/auth/utils/isCancellationError.d.ts +4 -0
  30. package/dist/_types/auth/utils/isCancellationError.d.ts.map +1 -1
  31. package/dist/_types/auth/utils/isMobile.d.ts +2 -0
  32. package/dist/_types/auth/utils/isMobile.d.ts.map +1 -0
  33. package/dist/_types/auth/utils/isResourceUnavailableError.d.ts +12 -0
  34. package/dist/_types/auth/utils/isResourceUnavailableError.d.ts.map +1 -0
  35. package/dist/_types/auth/utils/isZeroDevWalletConnect.d.ts +6 -0
  36. package/dist/_types/auth/utils/isZeroDevWalletConnect.d.ts.map +1 -0
  37. package/dist/_types/auth/utils/walletDeepLink.d.ts +18 -0
  38. package/dist/_types/auth/utils/walletDeepLink.d.ts.map +1 -0
  39. package/dist/_types/auth/walletGuide.d.ts +21 -3
  40. package/dist/_types/auth/walletGuide.d.ts.map +1 -1
  41. package/dist/_types/connector.d.ts.map +1 -1
  42. package/dist/_types/index.d.ts +3 -0
  43. package/dist/_types/index.d.ts.map +1 -1
  44. package/dist/_types/zeroDevWalletConnect.d.ts +13 -0
  45. package/dist/_types/zeroDevWalletConnect.d.ts.map +1 -0
  46. package/dist/index.cjs +3 -3
  47. package/dist/index.cjs.map +1 -1
  48. package/dist/index.mjs +1228 -729
  49. package/dist/index.mjs.map +1 -1
  50. package/dist/styles.css +1 -1
  51. package/package.json +5 -4
  52. package/src/assets.d.ts +5 -0
  53. package/src/auth/authStoreSlice.ts +72 -3
  54. package/src/auth/brandAssets.ts +5 -0
  55. package/src/auth/components/WalletSheet/index.tsx +211 -0
  56. package/src/auth/hooks/useAuth.ts +7 -0
  57. package/src/auth/hooks/useWalletConnectPairing.ts +134 -0
  58. package/src/auth/hooks/useWalletInfo.ts +145 -0
  59. package/src/auth/index.tsx +3 -0
  60. package/src/auth/pages/SignUp/InstalledWallets.tsx +18 -28
  61. package/src/auth/pages/SignUp/MoreWallets.tsx +53 -30
  62. package/src/auth/pages/SignUp/Wallet.tsx +44 -30
  63. package/src/auth/pages/SignUp/WalletConnect.tsx +32 -0
  64. package/src/auth/pages/SignUp/context.tsx +4 -0
  65. package/src/auth/pages/SignUp/index.tsx +31 -0
  66. package/src/auth/pages/Verifying.tsx +28 -8
  67. package/src/auth/pages/WalletConnecting.tsx +237 -0
  68. package/src/auth/types.ts +1 -0
  69. package/src/auth/utils/isCancellationError.ts +20 -1
  70. package/src/auth/utils/isMobile.ts +9 -0
  71. package/src/auth/utils/isResourceUnavailableError.ts +29 -0
  72. package/src/auth/utils/isZeroDevWalletConnect.ts +8 -0
  73. package/src/auth/utils/walletDeepLink.ts +23 -0
  74. package/src/auth/walletGuide.ts +42 -8
  75. package/src/connector.ts +13 -4
  76. package/src/index.ts +7 -2
  77. package/src/shared/components/StatusScreen/index.tsx +1 -1
  78. package/src/zeroDevWalletConnect.ts +95 -0
  79. package/dist/_types/shared/components/TokenSummary/index.d.ts +0 -18
  80. package/dist/_types/shared/components/TokenSummary/index.d.ts.map +0 -1
  81. package/src/shared/components/TokenSummary/index.tsx +0 -50
@@ -0,0 +1,145 @@
1
+ import { useEffect, useMemo, useState } from 'react'
2
+ import { useAccount } from 'wagmi'
3
+ import {
4
+ matchesWallet,
5
+ WALLET_GUIDE,
6
+ type WalletGuideEntry,
7
+ type WalletId,
8
+ } from '../walletGuide'
9
+
10
+ /** How the active connection reaches the wallet. */
11
+ export type WalletSource = 'injected' | 'walletconnect' | 'embedded' | 'other'
12
+
13
+ export type WalletInfo = {
14
+ /** Human-readable wallet name ('MetaMask', 'Trust Wallet', …). */
15
+ name?: string | undefined
16
+ /** Wallet icon URL or data: URI, when one is known. */
17
+ icon?: string | undefined
18
+ /** Guide id when the wallet matches the kit's wallet guide. */
19
+ walletId?: WalletId | undefined
20
+ source: WalletSource
21
+ }
22
+
23
+ /** The slice of `@walletconnect/ethereum-provider` this hook reads. */
24
+ type WcPeerProvider = {
25
+ session?: {
26
+ peer?: {
27
+ metadata?: { name?: string; icons?: readonly string[] }
28
+ }
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Guide entry for a WalletConnect peer, matched by name. Peers report their
34
+ * own branding ('MetaMask Wallet', 'Trust Wallet', …), so match by
35
+ * case-insensitive containment in either direction against the guide names.
36
+ */
37
+ function guideEntryForPeerName(name: string): WalletGuideEntry | undefined {
38
+ const peer = name.toLowerCase()
39
+ return WALLET_GUIDE.find((wallet) => {
40
+ const guide = wallet.name.toLowerCase()
41
+ return peer.includes(guide) || guide.includes(peer)
42
+ })
43
+ }
44
+
45
+ function guideEntryForConnector(connector: {
46
+ id: string
47
+ name?: string
48
+ type?: string
49
+ rdns?: string | readonly string[] | undefined
50
+ }): WalletGuideEntry | undefined {
51
+ return WALLET_GUIDE.find((wallet) => matchesWallet(connector, wallet))
52
+ }
53
+
54
+ function useResolvedWalletInfo(): WalletInfo | undefined {
55
+ const { connector, isConnected } = useAccount()
56
+ const [peerMetadata, setPeerMetadata] = useState<
57
+ { name?: string; icons?: readonly string[] } | undefined
58
+ >(undefined)
59
+
60
+ const isWalletConnect = !!connector && connector.type === 'walletConnect'
61
+
62
+ useEffect(() => {
63
+ if (!connector || !isWalletConnect) {
64
+ setPeerMetadata(undefined)
65
+ return
66
+ }
67
+ let cancelled = false
68
+ connector
69
+ .getProvider()
70
+ .then((provider) => {
71
+ if (cancelled) return
72
+ const metadata = (provider as WcPeerProvider).session?.peer?.metadata
73
+ setPeerMetadata(metadata)
74
+ })
75
+ .catch(() => {
76
+ // Provider unavailable (torn down mid-flight) — stay unresolved.
77
+ })
78
+ return () => {
79
+ cancelled = true
80
+ }
81
+ }, [connector, isWalletConnect])
82
+
83
+ // Memoized so the identity is reference-stable across renders: consumers
84
+ // put `walletInfo` in effect deps (analytics on wallet change), and a fresh
85
+ // object every render would fire those on every render instead.
86
+ return useMemo((): WalletInfo | undefined => {
87
+ if (!isConnected || !connector) return undefined
88
+
89
+ if (connector.id === 'zerodev-wallet') {
90
+ return {
91
+ name: connector.name,
92
+ icon: connector.icon,
93
+ source: 'embedded',
94
+ }
95
+ }
96
+
97
+ if (isWalletConnect) {
98
+ const entry = peerMetadata?.name
99
+ ? guideEntryForPeerName(peerMetadata.name)
100
+ : undefined
101
+ return {
102
+ name: peerMetadata?.name ?? undefined,
103
+ icon: peerMetadata?.icons?.[0] ?? entry?.icon,
104
+ walletId: entry?.id as WalletId | undefined,
105
+ // Identity is about the session, so a raw walletConnect() connector
106
+ // resolves here too — unlike pairing, which is kit-connector-only.
107
+ source: 'walletconnect',
108
+ }
109
+ }
110
+
111
+ const entry = guideEntryForConnector(connector)
112
+
113
+ return {
114
+ name: connector.name,
115
+ icon: connector.icon ?? entry?.icon,
116
+ walletId: entry?.id as WalletId | undefined,
117
+ source: connector.type === 'injected' ? 'injected' : 'other',
118
+ }
119
+ }, [connector, isConnected, isWalletConnect, peerMetadata])
120
+ }
121
+
122
+ /**
123
+ * Identity of the wallet behind the active wagmi connection, for
124
+ * wallet-specific handling and analytics attribution. Call-compatible with
125
+ * AppKit's `useWalletInfo` — same name, same `{ walletInfo }` return shape,
126
+ * so migrating is an import swap; `walletId` and `source` are kit extras.
127
+ *
128
+ * wagmi's `useAccount().connector` already names injected wallets, but a
129
+ * WalletConnect connection only reports "WalletConnect" — the actual wallet
130
+ * on the other end (Trust, Rainbow, … on a phone) is in the session's peer
131
+ * metadata, which this hook reads from the provider. `walletInfo` is
132
+ * `undefined` while disconnected, and the WalletConnect case resolves
133
+ * asynchronously (briefly `name: undefined` after connect/reload).
134
+ *
135
+ * @param _namespace - Accepted for AppKit call-compatibility ('eip155');
136
+ * ignored — the kit is EVM-only.
137
+ */
138
+ export function useWalletInfo(_namespace?: string): {
139
+ walletInfo: WalletInfo | undefined
140
+ } {
141
+ const walletInfo = useResolvedWalletInfo()
142
+ // The wrapper is memoized too, so the returned object is stable for
143
+ // consumers that depend on it whole rather than destructuring.
144
+ return useMemo(() => ({ walletInfo }), [walletInfo])
145
+ }
@@ -7,6 +7,7 @@ import { ErrorScreen } from './pages/ErrorScreen'
7
7
  import { OtpInput } from './pages/OtpInput'
8
8
  import { SignUp } from './pages/SignUp'
9
9
  import { Verifying } from './pages/Verifying'
10
+ import { WalletConnecting } from './pages/WalletConnecting'
10
11
  import type { AuthStep } from './types'
11
12
  import { hasMagicLinkCodeInUrl, stripMagicLinkCodeFromUrl } from './utils/url'
12
13
 
@@ -47,6 +48,8 @@ function renderStep(
47
48
  return <OAuthCallback />
48
49
  case 'passkey-prompt':
49
50
  return <PasskeyPrompt />
51
+ case 'wallet-connecting':
52
+ return <WalletConnecting />
50
53
  case 'error':
51
54
  return <ErrorScreen />
52
55
  default:
@@ -1,9 +1,8 @@
1
1
  import { Badge, ListItem, ListItemChevron } from '@zerodev/react-ui'
2
- import { useConnect, useConnectors } from 'wagmi'
2
+ import { useConnectors } from 'wagmi'
3
3
  import { useAuth } from '../../hooks/useAuth'
4
- import { isCancellationError } from '../../utils/isCancellationError'
5
4
  import { matchesWallet, WALLET_GUIDE } from '../../walletGuide'
6
- import { useReportPending, useSignUpContext } from './context'
5
+ import { useSignUpContext } from './context'
7
6
 
8
7
  /** Auto-discovered rows for installed wallets: one row per announced (6963)
9
8
  * browser extension. Renders nothing when no wallet is installed. */
@@ -19,12 +18,9 @@ export function SignUpInstalledWallets({
19
18
  * curated ones. */
20
19
  maxWallets?: number
21
20
  }) {
22
- const { goToStep } = useAuth()
23
- const { authPending, guardAgreement, setError, registeredWallets } =
24
- useSignUpContext()
21
+ const { startWalletConnection } = useAuth()
22
+ const { authPending, guardAgreement, registeredWallets } = useSignUpContext()
25
23
  const connectors = useConnectors()
26
- const { mutate: connect, isPending } = useConnect()
27
- useReportPending(isPending)
28
24
 
29
25
  // Same rule that earns the INSTALLED badge elsewhere: only a 6963
30
26
  // announcement proves a live extension. Announcements surface as
@@ -56,38 +52,32 @@ export function SignUpInstalledWallets({
56
52
  .sort((a, b) => a.rank - b.rank)
57
53
  .slice(0, maxWallets)
58
54
 
59
- const startConnect = (connector: (typeof connectors)[number]) => {
55
+ // Hand off to the `wallet-connecting` step, which owns the connect() call.
56
+ const startConnect = (row: (typeof rows)[number]) => {
60
57
  if (authPending) return
61
58
  if (!guardAgreement()) return
62
- setError(null)
63
- connect(
64
- { connector },
65
- {
66
- // The external wallet is now the active wagmi connection — the
67
- // embedded-wallet flow is done, so close it (mirrors SignUp.Wallet).
68
- onSuccess: () => goToStep(null),
69
- onError: (err) => {
70
- if (!isCancellationError(err)) {
71
- setError(err instanceof Error ? err.message : String(err))
72
- }
73
- },
74
- },
75
- )
59
+ startWalletConnection({
60
+ connectorUid: row.connector.uid,
61
+ name: row.name,
62
+ icon: row.icon,
63
+ })
76
64
  }
77
65
 
78
66
  return (
79
67
  <>
80
- {rows.map(({ connector, name, icon }) => (
68
+ {rows.map((row) => (
81
69
  <ListItem
82
- key={connector.uid}
83
- title={name}
70
+ key={row.connector.uid}
71
+ title={row.name}
84
72
  icon={
85
- icon ? <img src={icon} alt="" className="zd:w-6 zd:h-6" /> : null
73
+ row.icon ? (
74
+ <img src={row.icon} alt="" className="zd:w-6 zd:h-6" />
75
+ ) : null
86
76
  }
87
77
  subtitle={<Badge text="INSTALLED" />}
88
78
  trailing={<ListItemChevron />}
89
79
  disabled={authPending}
90
- onClick={() => startConnect(connector)}
80
+ onClick={() => startConnect(row)}
91
81
  />
92
82
  ))}
93
83
  </>
@@ -1,14 +1,15 @@
1
1
  import { ListItem, ListItemChevron, ListItemIcon } from '@zerodev/react-ui'
2
2
  import { useState } from 'react'
3
- import { useConnect, useConnectors } from 'wagmi'
3
+ import { useConnectors } from 'wagmi'
4
+ import { walletConnectLogo } from '../../brandAssets'
4
5
  import {
5
6
  WalletGridSheet,
6
7
  type WalletTileData,
7
8
  } from '../../components/WalletGridSheet'
8
9
  import { useAuth } from '../../hooks/useAuth'
9
- import { isCancellationError } from '../../utils/isCancellationError'
10
- import { matchesWallet, WALLET_GUIDE } from '../../walletGuide'
11
- import { useReportPending, useSignUpContext } from './context'
10
+ import { isZeroDevWalletConnect } from '../../utils/isZeroDevWalletConnect'
11
+ import { announcesWallet, matchesWallet, WALLET_GUIDE } from '../../walletGuide'
12
+ import { useSignUpContext } from './context'
12
13
 
13
14
  /** "More wallets" row — opens the wallet grid sheet. */
14
15
  export function SignUpMoreWallets({
@@ -16,12 +17,10 @@ export function SignUpMoreWallets({
16
17
  }: {
17
18
  title?: string
18
19
  }) {
19
- const { goToStep } = useAuth()
20
- const { authPending, guardAgreement, setError } = useSignUpContext()
20
+ const { startWalletConnection } = useAuth()
21
+ const { authPending, guardAgreement, openWalletSheet } = useSignUpContext()
21
22
  const [open, setOpen] = useState(false)
22
23
  const connectors = useConnectors()
23
- const { mutate: connect, isPending } = useConnect()
24
- useReportPending(isPending)
25
24
 
26
25
  // Our own connector is the embedded wallet, and walletConnect-type
27
26
  // connectors are the mobile transport rather than a wallet — neither
@@ -30,37 +29,45 @@ export function SignUpMoreWallets({
30
29
  (c) => c.id !== 'zerodev-wallet' && c.type !== 'walletConnect',
31
30
  )
32
31
 
33
- const startConnect = (connector: (typeof connectors)[number]) => {
32
+ // Hand off to the `wallet-connecting` step, which owns the connect() call.
33
+ const startConnect = (
34
+ connector: (typeof connectors)[number],
35
+ name: string,
36
+ icon: string | undefined,
37
+ ) => {
34
38
  if (authPending) return
35
39
  if (!guardAgreement()) return
36
40
  setOpen(false)
37
- setError(null)
38
- connect(
39
- { connector },
40
- {
41
- // The external wallet is now the active wagmi connection — the
42
- // embedded-wallet flow is done, so close it.
43
- onSuccess: () => goToStep(null),
44
- onError: (err) => {
45
- if (!isCancellationError(err)) {
46
- setError(err instanceof Error ? err.message : String(err))
47
- }
48
- },
49
- },
50
- )
41
+ startWalletConnection({ connectorUid: connector.uid, name, icon })
51
42
  }
52
43
 
53
- // Claimed by a connector → connect it; otherwise the tile opens the
54
- // vendor download page.
44
+ const wcEnabled = connectors.some(isZeroDevWalletConnect)
45
+
55
46
  const guideTiles: WalletTileData[] = WALLET_GUIDE.map((wallet) => {
56
- const installed = walletConnectors.find((c) => matchesWallet(c, wallet))
47
+ const announced = walletConnectors.find((c) => announcesWallet(c, wallet))
57
48
  return {
58
49
  key: wallet.id,
59
50
  name: wallet.name,
60
51
  icon: wallet.icon,
61
52
  onSelect: () => {
62
- if (installed) {
63
- startConnect(installed)
53
+ // Announced = the wallet is live on this page (extension or its own
54
+ // in-app browser) — connect directly instead of a WC handoff.
55
+ if (announced) {
56
+ startConnect(announced, wallet.name, wallet.icon)
57
+ return
58
+ }
59
+ if (wcEnabled) {
60
+ if (authPending) return
61
+ if (!guardAgreement()) return
62
+ setOpen(false)
63
+ openWalletSheet(wallet)
64
+ return
65
+ }
66
+ // No WC handoff available — a configured connector that claims the
67
+ // wallet (e.g. a vendor SDK) is the last way to connect.
68
+ const claimed = walletConnectors.find((c) => matchesWallet(c, wallet))
69
+ if (claimed) {
70
+ startConnect(claimed, wallet.name, wallet.icon)
64
71
  return
65
72
  }
66
73
  setOpen(false)
@@ -77,9 +84,25 @@ export function SignUpMoreWallets({
77
84
  key: connector.uid,
78
85
  name: connector.name,
79
86
  icon: connector.icon,
80
- onSelect: () => startConnect(connector),
87
+ onSelect: () => startConnect(connector, connector.name, connector.icon),
81
88
  }))
82
89
 
90
+ const walletConnectTiles: WalletTileData[] = wcEnabled
91
+ ? [
92
+ {
93
+ key: 'walletconnect',
94
+ name: 'WalletConnect',
95
+ icon: walletConnectLogo,
96
+ onSelect: () => {
97
+ if (authPending) return
98
+ if (!guardAgreement()) return
99
+ setOpen(false)
100
+ openWalletSheet()
101
+ },
102
+ },
103
+ ]
104
+ : []
105
+
83
106
  const handleClick = () => {
84
107
  if (authPending) return
85
108
  if (!guardAgreement()) return
@@ -98,7 +121,7 @@ export function SignUpMoreWallets({
98
121
  <WalletGridSheet
99
122
  open={open}
100
123
  onOpenChange={setOpen}
101
- tiles={[...guideTiles, ...connectorTiles]}
124
+ tiles={[...walletConnectTiles, ...guideTiles, ...connectorTiles]}
102
125
  />
103
126
  </>
104
127
  )
@@ -1,10 +1,15 @@
1
1
  import { Badge, ListItem, ListItemChevron } from '@zerodev/react-ui'
2
2
  import { useLayoutEffect } from 'react'
3
- import { useConnect, useConnectors } from 'wagmi'
3
+ import { useConnectors } from 'wagmi'
4
4
  import { useAuth } from '../../hooks/useAuth'
5
- import { isCancellationError } from '../../utils/isCancellationError'
6
- import { matchesWallet, WALLET_GUIDE, type WalletId } from '../../walletGuide'
7
- import { useReportPending, useSignUpContext } from './context'
5
+ import { isZeroDevWalletConnect } from '../../utils/isZeroDevWalletConnect'
6
+ import {
7
+ announcesWallet,
8
+ matchesWallet,
9
+ WALLET_GUIDE,
10
+ type WalletId,
11
+ } from '../../walletGuide'
12
+ import { useSignUpContext } from './context'
8
13
 
9
14
  /** Dedicated row for a single guide wallet: connects it directly when a live
10
15
  * connector claims it, and is a download link for the vendor page otherwise. */
@@ -17,20 +22,15 @@ export function SignUpWallet({ walletId }: { walletId: WalletId }) {
17
22
  )
18
23
  }
19
24
 
20
- const { goToStep } = useAuth()
21
- const { authPending, guardAgreement, setError, registerWallet } =
25
+ const { startWalletConnection } = useAuth()
26
+ const { authPending, guardAgreement, registerWallet, openWalletSheet } =
22
27
  useSignUpContext()
23
28
  const connectors = useConnectors()
24
- const { mutate: connect, isPending } = useConnect()
25
- useReportPending(isPending)
26
29
  useLayoutEffect(() => registerWallet(wallet.id), [registerWallet, wallet.id])
27
30
 
28
- // Same claim rule as the MoreWallets grid: a 6963 announcement or a
29
- // configured SDK connector both make the row connectable. Only an
30
- // announcement (id === rdns) proves a live extension and earns the badge.
31
- const installed = connectors.find((c) => matchesWallet(c, wallet))
32
- const isAnnounced =
33
- !!wallet.rdns && connectors.some((c) => c.id === wallet.rdns)
31
+ // Only a 6963 announcement proves a live extension (or the wallet's own
32
+ // in-app browser) it earns the badge and a direct connect.
33
+ const announced = connectors.find((c) => announcesWallet(c, wallet))
34
34
 
35
35
  const shared = {
36
36
  title: wallet.name,
@@ -38,7 +38,29 @@ export function SignUpWallet({ walletId }: { walletId: WalletId }) {
38
38
  trailing: <ListItemChevron />,
39
39
  }
40
40
 
41
- if (!installed) {
41
+ // An announcement means the wallet is right here (extension, or its own
42
+ // in-app browser) — connect it directly; WC pairing would bounce out of
43
+ // the very wallet the user is standing in.
44
+ if (!announced && connectors.some(isZeroDevWalletConnect)) {
45
+ return (
46
+ <ListItem
47
+ {...shared}
48
+ disabled={authPending}
49
+ onClick={() => {
50
+ if (authPending) return
51
+ if (!guardAgreement()) return
52
+ openWalletSheet(wallet)
53
+ }}
54
+ />
55
+ )
56
+ }
57
+
58
+ // Same claim rule as the MoreWallets grid: announced, or — without a WC
59
+ // handoff — a configured connector (e.g. a vendor SDK) that claims the
60
+ // wallet.
61
+ const claimed = announced ?? connectors.find((c) => matchesWallet(c, wallet))
62
+
63
+ if (!claimed) {
42
64
  return (
43
65
  <ListItem {...shared} asChild>
44
66
  {/* biome-ignore lint/a11y/useAnchorContent: the row layout (incl. the title text) is injected into the anchor via Slot */}
@@ -51,29 +73,21 @@ export function SignUpWallet({ walletId }: { walletId: WalletId }) {
51
73
  )
52
74
  }
53
75
 
76
+ // Hand off to the `wallet-connecting` step, which owns the connect() call.
54
77
  const handleClick = () => {
55
78
  if (authPending) return
56
79
  if (!guardAgreement()) return
57
- setError(null)
58
- connect(
59
- { connector: installed },
60
- {
61
- // The external wallet is now the active wagmi connection — the
62
- // embedded-wallet flow is done, so close it (mirrors SignUp.MoreWallets).
63
- onSuccess: () => goToStep(null),
64
- onError: (err) => {
65
- if (!isCancellationError(err)) {
66
- setError(err instanceof Error ? err.message : String(err))
67
- }
68
- },
69
- },
70
- )
80
+ startWalletConnection({
81
+ connectorUid: claimed.uid,
82
+ name: wallet.name,
83
+ icon: wallet.icon,
84
+ })
71
85
  }
72
86
 
73
87
  return (
74
88
  <ListItem
75
89
  {...shared}
76
- {...(isAnnounced && { subtitle: <Badge text="INSTALLED" /> })}
90
+ {...(!!announced && { subtitle: <Badge text="INSTALLED" /> })}
77
91
  disabled={authPending}
78
92
  onClick={handleClick}
79
93
  />
@@ -0,0 +1,32 @@
1
+ import { Badge, ListItem, ListItemChevron } from '@zerodev/react-ui'
2
+ import { useConnectors } from 'wagmi'
3
+ import { walletConnectLogo } from '../../brandAssets'
4
+ import { isZeroDevWalletConnect } from '../../utils/isZeroDevWalletConnect'
5
+ import { useSignUpContext } from './context'
6
+
7
+ /** Generic WalletConnect row — opens the pairing sheet with a raw-URI QR any
8
+ * wallet's scanner can claim. Renders nothing unless a `zeroDevWalletConnect`
9
+ * connector is in the wagmi config. */
10
+ export function SignUpWalletConnect() {
11
+ const { authPending, guardAgreement, openWalletSheet } = useSignUpContext()
12
+ const connectors = useConnectors()
13
+
14
+ if (!connectors.some(isZeroDevWalletConnect)) return null
15
+
16
+ const handleClick = () => {
17
+ if (authPending) return
18
+ if (!guardAgreement()) return
19
+ openWalletSheet()
20
+ }
21
+
22
+ return (
23
+ <ListItem
24
+ title="WalletConnect"
25
+ icon={<img src={walletConnectLogo} alt="" className="zd:w-6 zd:h-6" />}
26
+ subtitle={<Badge text="QR CODE" />}
27
+ trailing={<ListItemChevron />}
28
+ disabled={authPending}
29
+ onClick={handleClick}
30
+ />
31
+ )
32
+ }
@@ -1,5 +1,6 @@
1
1
  import { createContext, useContext, useEffect } from 'react'
2
2
  import type { EmailAuthMethod } from '../../types'
3
+ import type { WalletGuideEntry } from '../../walletGuide'
3
4
 
4
5
  export type SignUpContextValue = {
5
6
  /** True while any method's auth attempt is in flight — used to disable
@@ -21,6 +22,9 @@ export type SignUpContextValue = {
21
22
  registeredWallets: readonly string[]
22
23
  /** Returns the unregister function. */
23
24
  registerWallet: (walletId: string) => () => void
25
+ /** Opens the root's single WalletSheet — a wallet's connection paths, or
26
+ * the generic WalletConnect pairing when called without one. */
27
+ openWalletSheet: (wallet?: WalletGuideEntry) => void
24
28
  }
25
29
 
26
30
  export const SignUpContext = createContext<SignUpContextValue | null>(null)
@@ -2,7 +2,10 @@ import { Button, Text } from '@zerodev/react-ui'
2
2
  import { type ReactNode, useCallback, useState } from 'react'
3
3
  import { SignUpFooter } from '../../../shared/components/SignUpFooter'
4
4
  import { BlobAnimation } from '../../components/BlobAnimation'
5
+ import { WalletSheet } from '../../components/WalletSheet'
6
+ import { useWalletConnectPairing } from '../../hooks/useWalletConnectPairing'
5
7
  import type { EmailAuthMethod } from '../../types'
8
+ import type { WalletGuideEntry } from '../../walletGuide'
6
9
  import { SignUpContext } from './context'
7
10
  import { SignUpEmail } from './Email'
8
11
  import { SignUpGoogle } from './Google'
@@ -10,6 +13,7 @@ import { SignUpInstalledWallets } from './InstalledWallets'
10
13
  import { SignUpMoreWallets } from './MoreWallets'
11
14
  import { SignUpPasskey } from './Passkey'
12
15
  import { SignUpWallet } from './Wallet'
16
+ import { SignUpWalletConnect } from './WalletConnect'
13
17
 
14
18
  type SignUpRootProps = {
15
19
  children: ReactNode
@@ -51,6 +55,17 @@ function SignUpRoot({
51
55
  }
52
56
  }, [])
53
57
 
58
+ // The page's single WalletSheet — every wallet surface opens it via
59
+ // `openWalletSheet` instead of mounting a sheet of its own. The box
60
+ // distinguishes open-without-a-wallet (generic pairing) from closed.
61
+ const [walletSheet, setWalletSheet] = useState<{
62
+ wallet?: WalletGuideEntry | undefined
63
+ } | null>(null)
64
+ // Pairing preloads at page mount so the sheet's QR is ready on open and the
65
+ // mobile deep link below fires synchronously inside the tap — iOS only
66
+ // hands a universal link to the app for gesture-qualified navigations.
67
+ const pairing = useWalletConnectPairing()
68
+
54
69
  const requiresAgreement = !!(termsAndConditionsUrl || privacyPolicyUrl)
55
70
  const needsAgreement = requiresAgreement && !agreedToTerms
56
71
 
@@ -71,6 +86,13 @@ function SignUpRoot({
71
86
  setError,
72
87
  registeredWallets,
73
88
  registerWallet,
89
+ openWalletSheet: (wallet) => {
90
+ const deepLink = wallet && pairing.deepLinkFor(wallet)
91
+ if (deepLink) window.location.href = deepLink
92
+ // Sheet opens either way — it's the fallback surface when the
93
+ // redirect doesn't take (and the only surface on desktop).
94
+ setWalletSheet({ wallet })
95
+ },
74
96
  }}
75
97
  >
76
98
  {error !== null && (
@@ -124,6 +146,14 @@ function SignUpRoot({
124
146
  />
125
147
  </div>
126
148
  </div>
149
+ <WalletSheet
150
+ pairing={pairing}
151
+ open={walletSheet !== null}
152
+ onOpenChange={(open) => {
153
+ if (!open) setWalletSheet(null)
154
+ }}
155
+ wallet={walletSheet?.wallet}
156
+ />
127
157
  </SignUpContext.Provider>
128
158
  )
129
159
  }
@@ -158,6 +188,7 @@ export const SignUp = Object.assign(SignUpRoot, {
158
188
  Google: SignUpGoogle,
159
189
  Email: SignUpEmail,
160
190
  Wallet: SignUpWallet,
191
+ WalletConnect: SignUpWalletConnect,
161
192
  InstalledWallets: SignUpInstalledWallets,
162
193
  MoreWallets: SignUpMoreWallets,
163
194
  Divider: SignUpDivider,
@@ -15,6 +15,7 @@ export function Verifying() {
15
15
  useAuth()
16
16
  const [code] = useState<string | null>(getCodeFromUrl)
17
17
  const [error, setError] = useState<Error | null>(null)
18
+ const [sessionMissing, setSessionMissing] = useState(false)
18
19
 
19
20
  // ref to prevent useEffect firing twice in dev's StrictMode
20
21
  const hasVerifiedRef = useRef(false)
@@ -35,14 +36,15 @@ export function Verifying() {
35
36
  if (hasVerifiedRef.current || !code) return
36
37
  hasVerifiedRef.current = true
37
38
 
38
- // No active OTP session — most commonly the user tapped the same
39
- // magic link again after a successful verify, which cleared the
40
- // session. Skip the (doomed) mutation, clean the URL, and drop
41
- // back to step=null so the host app's own routing handles the
42
- // already-authenticated user without flashing an error.
39
+ // No active OTP session — the link was already used, expired, or opened
40
+ // in a browser that never started the email flow. Skip the (doomed)
41
+ // mutation and show the expired-link error below instead of silently
42
+ // dropping the step: a blank screen gives the user nothing to act on.
43
+ // The already-authenticated re-tap case is covered too — hosts route
44
+ // connected users away on their own (wagmi reconnect), so the error only
45
+ // stays up for users who genuinely can't be verified.
43
46
  if (!otpId || !otpEncryptionTargetBundle) {
44
- stripMagicLinkCodeFromUrl()
45
- goToStep(null)
47
+ setSessionMissing(true)
46
48
  return
47
49
  }
48
50
 
@@ -58,7 +60,25 @@ export function Verifying() {
58
60
  </StatusScreen>
59
61
  )}
60
62
 
61
- {!error && !code && !isVerificationLoading && (
63
+ {!error && sessionMissing && (
64
+ <>
65
+ <StatusScreen imageName="error" title="Link Expired">
66
+ This verification link has expired or was already used.
67
+ <br />
68
+ Please sign in again to request a new one.
69
+ </StatusScreen>
70
+ <Button
71
+ action="primary"
72
+ onClick={() => {
73
+ stripMagicLinkCodeFromUrl()
74
+ goToStep('sign-up')
75
+ }}
76
+ text="Choose another sign-in method"
77
+ />
78
+ </>
79
+ )}
80
+
81
+ {!error && !sessionMissing && !code && !isVerificationLoading && (
62
82
  <>
63
83
  <StatusScreen imageName="error" title="Invalid Link">
64
84
  This verification link is invalid or incomplete.