@gluwa/connect-kit 0.1.0-next.0 → 0.1.0-next.2

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.
@@ -17,11 +17,23 @@ export type CreditConnectConnectorOptions = {
17
17
 
18
18
  type CreditConnectProvider = EIP1193Provider;
19
19
  type ProviderListener = (...args: unknown[]) => void;
20
+ type CreditConnectProviderWithEvents = CreditConnectProvider & {
21
+ on: (event: string, listener: ProviderListener) => CreditConnectProviderWithEvents;
22
+ removeListener: (event: string, listener: ProviderListener) => CreditConnectProviderWithEvents;
23
+ };
20
24
 
21
25
  const CONNECTOR_ID = 'credit-connect';
22
26
  const CONNECTOR_NAME = 'Credit Wallet';
23
27
  const CONNECTOR_TYPE = 'credit-connect';
24
28
 
29
+ const castConnectResult = <withCapabilities extends boolean>(value: unknown) =>
30
+ value as {
31
+ accounts: withCapabilities extends true
32
+ ? ReadonlyArray<{ address: Address; capabilities: Record<string, unknown> }>
33
+ : readonly Address[];
34
+ chainId: number;
35
+ };
36
+
25
37
  export const creditConnectConnector = (
26
38
  options: CreditConnectConnectorOptions,
27
39
  ): ReturnType<typeof createConnector> => {
@@ -157,7 +169,20 @@ export const creditConnectConnector = (
157
169
  const chainId = Number.parseInt(getEvmAddressInfo(session)[0].networkIdentifier, 10);
158
170
  if (!Number.isFinite(chainId)) throw new Error(`Invalid chainId: ${chainId}`);
159
171
  selectedChainId = chainId;
160
- return { accounts, chainId } as any;
172
+ if (parameters?.withCapabilities === true) {
173
+ return castConnectResult<withCapabilities>({
174
+ accounts: accounts.map((address) => ({
175
+ address,
176
+ capabilities: {} as Record<string, unknown>,
177
+ })),
178
+ chainId,
179
+ });
180
+ }
181
+
182
+ return castConnectResult<withCapabilities>({
183
+ accounts,
184
+ chainId,
185
+ });
161
186
  }
162
187
 
163
188
  if (m.state.status === 'CONNECTING' && m.state.connectingPayload) {
@@ -202,7 +227,20 @@ export const creditConnectConnector = (
202
227
  if (!Number.isFinite(chainId)) throw new Error(`Invalid chainId: ${chainId}`);
203
228
 
204
229
  selectedChainId = chainId;
205
- return { accounts, chainId } as any;
230
+ if (parameters?.withCapabilities === true) {
231
+ return castConnectResult<withCapabilities>({
232
+ accounts: accounts.map((address) => ({
233
+ address,
234
+ capabilities: {} as Record<string, unknown>,
235
+ })),
236
+ chainId,
237
+ });
238
+ }
239
+
240
+ return castConnectResult<withCapabilities>({
241
+ accounts,
242
+ chainId,
243
+ });
206
244
  } catch (error) {
207
245
  resetState();
208
246
  throw error;
@@ -277,9 +315,17 @@ export const creditConnectConnector = (
277
315
  if (provider) return provider;
278
316
  await ensureManagerInitialized();
279
317
 
280
- provider = {
281
- request: async (args: unknown) => {
282
- const { method, params } = args as { method: string; params?: unknown[] };
318
+ const createdProvider = {
319
+ request: async (args) => {
320
+ const { method, params: rawParams } = args as {
321
+ method: string;
322
+ params?: unknown[] | object;
323
+ };
324
+ const params = Array.isArray(rawParams)
325
+ ? [...rawParams]
326
+ : rawParams === undefined
327
+ ? []
328
+ : [rawParams];
283
329
  const session = getCurrentSession();
284
330
 
285
331
  const getSelectedAddressInfo = (address?: string) => {
@@ -409,20 +455,22 @@ export const creditConnectConnector = (
409
455
  },
410
456
 
411
457
  on: (event: string, listener: ProviderListener) => {
412
- if (!providerListeners.has(event)) providerListeners.set(event, new Set());
413
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
414
- providerListeners.get(event)!.add(listener);
415
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
416
- return provider!;
458
+ let listeners = providerListeners.get(event);
459
+ if (!listeners) {
460
+ listeners = new Set();
461
+ providerListeners.set(event, listeners);
462
+ }
463
+ listeners.add(listener);
464
+ return createdProvider;
417
465
  },
418
466
 
419
467
  removeListener: (event: string, listener: ProviderListener) => {
420
468
  providerListeners.get(event)?.delete(listener);
421
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
422
- return provider!;
469
+ return createdProvider;
423
470
  },
424
- } as unknown as CreditConnectProvider;
471
+ } as CreditConnectProviderWithEvents;
425
472
 
473
+ provider = createdProvider;
426
474
  return provider;
427
475
  },
428
476
 
@@ -0,0 +1,150 @@
1
+ import { useState, useRef, useCallback } from 'react';
2
+ import type { WCWallet, WCSubView } from '../types';
3
+
4
+ const fetchWCWalletList = async (projectId: string): Promise<WCWallet[]> => {
5
+ const all: WCWallet[] = [];
6
+ let page = 1;
7
+ const entries = 100;
8
+
9
+ while (page <= 10) {
10
+ const url = new URL('https://explorer-api.walletconnect.com/v3/wallets');
11
+ url.searchParams.set('projectId', projectId);
12
+ url.searchParams.set('entries', String(entries));
13
+ url.searchParams.set('page', String(page));
14
+
15
+ const res = await fetch(url.toString());
16
+ if (!res.ok) throw new Error(`Failed to fetch wallet list: ${res.status}`);
17
+
18
+ const json = (await res.json()) as {
19
+ listings: Record<
20
+ string,
21
+ {
22
+ id: string;
23
+ name: string;
24
+ homepage?: string;
25
+ image_url?: { sm?: string; md?: string };
26
+ mobile?: { native?: string; universal?: string };
27
+ desktop?: { native?: string; universal?: string };
28
+ app?: { ios?: string; android?: string };
29
+ }
30
+ >;
31
+ total: number;
32
+ };
33
+
34
+ const listings = Object.values(json.listings ?? {});
35
+ for (const w of listings) {
36
+ all.push({
37
+ id: w.id,
38
+ name: w.name,
39
+ imageUrl: w.image_url?.md ?? w.image_url?.sm ?? '',
40
+ mobileNative: w.mobile?.native ?? '',
41
+ mobileUniversal: w.mobile?.universal ?? '',
42
+ desktopNative: w.desktop?.native ?? '',
43
+ desktopUniversal: w.desktop?.universal ?? '',
44
+ appIos: w.app?.ios ?? '',
45
+ appAndroid: w.app?.android ?? '',
46
+ homepage: w.homepage ?? '',
47
+ });
48
+ }
49
+
50
+ if (listings.length < entries || all.length >= json.total) break;
51
+ page += 1;
52
+ }
53
+
54
+ return all;
55
+ };
56
+
57
+ export interface WCState {
58
+ subView: WCSubView;
59
+ walletList: WCWallet[];
60
+ walletListLoading: boolean;
61
+ walletListSearch: string;
62
+ filterActive: boolean;
63
+ selectedWallet: WCWallet | null;
64
+ loadWalletList: (projectId: string) => Promise<void>;
65
+ onShowList: () => void;
66
+ onSelectWallet: (wallet: WCWallet) => void;
67
+ onSearchChange: (q: string) => void;
68
+ onFilterToggle: () => void;
69
+ // Returns true if WC handled the back navigation, false if the parent should handle it
70
+ handleBack: () => boolean;
71
+ resetView: () => void;
72
+ reset: () => void;
73
+ }
74
+
75
+ export const useWCState = (): WCState => {
76
+ const [subView, setSubView] = useState<WCSubView>('qr');
77
+ const [walletList, setWalletList] = useState<WCWallet[]>([]);
78
+ const [walletListLoading, setWalletListLoading] = useState(false);
79
+ const [walletListSearch, setWalletListSearch] = useState('');
80
+ const [filterActive, setFilterActive] = useState(false);
81
+ const [selectedWallet, setSelectedWallet] = useState<WCWallet | null>(null);
82
+ const loadedRef = useRef(false);
83
+
84
+ const loadWalletList = useCallback(async (projectId: string): Promise<void> => {
85
+ if (loadedRef.current) return;
86
+ loadedRef.current = true;
87
+ setWalletListLoading(true);
88
+ try {
89
+ const list = await fetchWCWalletList(projectId);
90
+ setWalletList(list);
91
+ } catch {
92
+ loadedRef.current = false;
93
+ } finally {
94
+ setWalletListLoading(false);
95
+ }
96
+ }, []);
97
+
98
+ const onShowList = useCallback((): void => setSubView('list'), []);
99
+
100
+ const onSelectWallet = useCallback((wallet: WCWallet): void => {
101
+ setSelectedWallet(wallet);
102
+ setSubView('wallet');
103
+ }, []);
104
+
105
+ const onSearchChange = useCallback((q: string): void => setWalletListSearch(q), []);
106
+
107
+ const onFilterToggle = useCallback((): void => setFilterActive((prev) => !prev), []);
108
+
109
+ const handleBack = useCallback((): boolean => {
110
+ if (subView === 'wallet') {
111
+ setSelectedWallet(null);
112
+ setSubView('list');
113
+ return true;
114
+ }
115
+ if (subView === 'list') {
116
+ setSubView('qr');
117
+ return true;
118
+ }
119
+ return false;
120
+ }, [subView]);
121
+
122
+ const resetView = useCallback((): void => {
123
+ setSubView('qr');
124
+ setSelectedWallet(null);
125
+ }, []);
126
+
127
+ const reset = useCallback((): void => {
128
+ setSubView('qr');
129
+ setWalletListSearch('');
130
+ setFilterActive(false);
131
+ setSelectedWallet(null);
132
+ }, []);
133
+
134
+ return {
135
+ subView,
136
+ walletList,
137
+ walletListLoading,
138
+ walletListSearch,
139
+ filterActive,
140
+ selectedWallet,
141
+ loadWalletList,
142
+ onShowList,
143
+ onSelectWallet,
144
+ onSearchChange,
145
+ onFilterToggle,
146
+ handleBack,
147
+ resetView,
148
+ reset,
149
+ };
150
+ };
@@ -1,12 +1,54 @@
1
1
  import { useEffect, useRef, useCallback } from 'react';
2
- import { useConnect, useAccount, useConfig } from 'wagmi';
2
+ import { useConnect, useConfig } from 'wagmi';
3
3
  import type { ConnectorId, ConnectResult } from '../types';
4
4
 
5
- export const WAGMI_CONNECTOR_ID: Partial<Record<ConnectorId, string>> = {
6
- METAMASK: 'metaMaskSDK',
7
- WALLET_CONNECT: 'walletConnect',
8
- CREDIT_WALLET: 'walletConnect',
9
- CREDIT_CONNECT: 'credit-connect',
5
+ type WagmiConnectorLike = {
6
+ id: string;
7
+ name: string;
8
+ type?: string;
9
+ rdns?: string | readonly string[];
10
+ };
11
+
12
+ const hasMetaMaskKeyword = (value: string): boolean => value.toLowerCase().includes('metamask');
13
+ const hasWalletConnectKeyword = (value: string): boolean =>
14
+ value.toLowerCase().includes('walletconnect');
15
+
16
+ const isMetaMaskConnector = (connector: WagmiConnectorLike): boolean => {
17
+ if (connector.id === 'injected' || connector.id === 'metaMaskSDK') return true;
18
+ if (hasMetaMaskKeyword(connector.id) || hasMetaMaskKeyword(connector.name)) return true;
19
+
20
+ if (connector.rdns) {
21
+ const rdnsList = Array.isArray(connector.rdns) ? connector.rdns : [connector.rdns];
22
+ if (rdnsList.some((rdns) => hasMetaMaskKeyword(rdns))) return true;
23
+ }
24
+
25
+ return false;
26
+ };
27
+
28
+ const isWalletConnectConnector = (connector: WagmiConnectorLike): boolean => {
29
+ if (connector.id === 'walletConnect') return true;
30
+ if (hasWalletConnectKeyword(connector.id) || hasWalletConnectKeyword(connector.name)) return true;
31
+ return false;
32
+ };
33
+
34
+ export const resolveWagmiConnector = <T extends WagmiConnectorLike>(
35
+ connectors: readonly T[],
36
+ connectorId: ConnectorId,
37
+ ): T | undefined => {
38
+ switch (connectorId) {
39
+ case 'CREDIT_CONNECT':
40
+ return connectors.find((connector) => connector.id === 'credit-connect');
41
+
42
+ case 'METAMASK':
43
+ return connectors.find(isMetaMaskConnector);
44
+
45
+ case 'CREDIT_WALLET':
46
+ case 'WALLET_CONNECT':
47
+ return connectors.find(isWalletConnectConnector);
48
+
49
+ default:
50
+ return undefined;
51
+ }
10
52
  };
11
53
 
12
54
  interface Options {
@@ -27,7 +69,6 @@ export const useWagmiConnect = ({
27
69
  onQrUri,
28
70
  }: Options): UseWagmiConnectResult => {
29
71
  const config = useConfig();
30
- const { address, status } = useAccount();
31
72
  const pendingConnectorId = useRef<ConnectorId | null>(null);
32
73
 
33
74
  // 콜백 참조
@@ -36,26 +77,12 @@ export const useWagmiConnect = ({
36
77
  const onQrUriRef = useRef(onQrUri);
37
78
  useEffect(() => {
38
79
  onConnectRef.current = onConnect;
39
- }, [onConnect]);
40
- useEffect(() => {
41
80
  onErrorRef.current = onError;
42
- }, [onError]);
43
- useEffect(() => {
44
81
  onQrUriRef.current = onQrUri;
45
- }, [onQrUri]);
82
+ }, [onConnect, onError, onQrUri]);
46
83
 
47
84
  // 연결 시도
48
- const { connect, reset, isPending } = useConnect({
49
- mutation: {
50
- onError(error) {
51
- const id = pendingConnectorId.current;
52
- if (id) {
53
- onErrorRef.current(error as Error, id);
54
- pendingConnectorId.current = null;
55
- }
56
- },
57
- },
58
- });
85
+ const { connectAsync, reset, isPending } = useConnect();
59
86
 
60
87
  // QR 코드 표시
61
88
  useEffect(() => {
@@ -80,38 +107,34 @@ export const useWagmiConnect = ({
80
107
  };
81
108
  }, [config.connectors]);
82
109
 
83
- // 연결 완료
84
- useEffect(() => {
85
- if (status === 'connected' && address && pendingConnectorId.current) {
86
- const id = pendingConnectorId.current;
87
- pendingConnectorId.current = null;
88
- onConnectRef.current({ address, connectorId: id });
89
- }
90
- }, [status, address]);
91
-
92
110
  // 연결 트리거
93
111
  const triggerConnect = useCallback(
94
112
  (connectorId: ConnectorId): void => {
95
- const wagmiId = WAGMI_CONNECTOR_ID[connectorId];
96
- if (!wagmiId) {
97
- onErrorRef.current(new Error(`No wagmi connector mapped for ${connectorId}`), connectorId);
98
- return;
99
- }
100
-
101
- const connector = config.connectors.find((c) => c.id === wagmiId);
113
+ const connector = resolveWagmiConnector(config.connectors, connectorId);
102
114
  if (!connector) {
103
- onErrorRef.current(
104
- new Error(`Wagmi connector '${wagmiId}' not found in config`),
105
- connectorId,
106
- );
115
+ onErrorRef.current(new Error(`No wagmi connector found for ${connectorId}`), connectorId);
107
116
  return;
108
117
  }
109
118
 
110
119
  onQrUriRef.current(null);
111
120
  pendingConnectorId.current = connectorId;
112
- connect({ connector });
121
+ connectAsync({ connector })
122
+ .then((result) => {
123
+ if (pendingConnectorId.current !== connectorId) return;
124
+ const address = result.accounts?.[0];
125
+ if (!address) {
126
+ throw new Error('Connected but no account returned');
127
+ }
128
+ pendingConnectorId.current = null;
129
+ onConnectRef.current({ address, connectorId });
130
+ })
131
+ .catch((error) => {
132
+ if (pendingConnectorId.current !== connectorId) return;
133
+ pendingConnectorId.current = null;
134
+ onErrorRef.current(error as Error, connectorId);
135
+ });
113
136
  },
114
- [config.connectors, connect],
137
+ [config.connectors, connectAsync],
115
138
  );
116
139
 
117
140
  // 연결 취소
package/src/index.ts CHANGED
@@ -1,4 +1,16 @@
1
1
  export { ConnectModal } from './ConnectModal';
2
+ export { ConnectKitProvider, useConnectKit } from './ConnectKitProvider';
3
+ export type { ConnectKitContextValue, ConnectKitProviderProps } from './ConnectKitProvider';
2
4
  export { creditConnectConnector } from './creditConnectConnector';
3
5
  export type { CreditConnectConnectorOptions } from './creditConnectConnector';
4
- export type { ConnectModalProps, ConnectResult, ConnectorId, WCWallet, WCSubView } from './types';
6
+ export type {
7
+ ConnectErrorContext,
8
+ ConnectErrorReason,
9
+ ConnectModalProps,
10
+ Connectors,
11
+ ConnectResult,
12
+ ConnectorId,
13
+ CreditWalletStrategy,
14
+ WCSubView,
15
+ WCWallet,
16
+ } from './types';
package/src/types.ts CHANGED
@@ -1,8 +1,14 @@
1
+ import type { ReactNode } from 'react';
2
+
1
3
  export type ConnectorId = 'CREDIT_WALLET' | 'CREDIT_CONNECT' | 'METAMASK' | 'WALLET_CONNECT';
2
4
 
3
- export type ValidConnectors =
4
- | Array<Exclude<ConnectorId, 'WALLET_CONNECT'>>
5
- | Array<Exclude<ConnectorId, 'CREDIT_WALLET'>>;
5
+ export type CreditWalletStrategy = 'walletConnect' | 'creditConnect';
6
+
7
+ export interface Connectors {
8
+ creditWallet?: CreditWalletStrategy | false;
9
+ metamask?: boolean;
10
+ walletConnect?: boolean;
11
+ }
6
12
 
7
13
  export interface WCWallet {
8
14
  id: string;
@@ -24,11 +30,23 @@ export interface ConnectResult {
24
30
  connectorId: ConnectorId;
25
31
  }
26
32
 
33
+ export type ConnectErrorReason = 'rejected' | 'unsupported' | 'unknown';
34
+
35
+ export interface ConnectErrorContext {
36
+ connectorId: ConnectorId;
37
+ reason: ConnectErrorReason;
38
+ error: Error;
39
+ retry: () => void;
40
+ dismiss: () => void;
41
+ }
42
+
27
43
  export interface ConnectModalProps {
28
44
  open: boolean;
29
- connectors: ValidConnectors;
45
+ connectors: Connectors;
30
46
  onConnect: (result: ConnectResult) => void;
31
47
  onClose: () => void;
32
48
  onLog?: (message: string, error?: Error) => void;
33
49
  wcProjectId?: string;
50
+ requiredChainId?: number;
51
+ renderConnectError?: (ctx: ConnectErrorContext) => ReactNode;
34
52
  }
@@ -1,8 +1,8 @@
1
- import { type FC, useEffect } from 'react';
2
- import { QRCodeSVG } from 'qrcode.react';
1
+ import { type FC, useEffect, useMemo } from 'react';
3
2
  import { isMobileDevice, tryOpenDeepLink } from '../utils/platform';
4
3
  import { CONNECTOR_META } from '../connector-meta';
5
4
  import { type ConnectorId } from '../types';
5
+ import { QRFrame } from '../components/QRFrame';
6
6
 
7
7
  interface CreditWalletViewProps {
8
8
  connectorId: Extract<ConnectorId, 'CREDIT_CONNECT' | 'CREDIT_WALLET'>;
@@ -11,7 +11,7 @@ interface CreditWalletViewProps {
11
11
 
12
12
  export const CreditWalletView: FC<CreditWalletViewProps> = ({ connectorId, qrUri }) => {
13
13
  const { downloadUrl, logoUrl, deepLinkBase } = CONNECTOR_META[connectorId];
14
- const isMobile = isMobileDevice();
14
+ const isMobile = useMemo(() => isMobileDevice(), []);
15
15
 
16
16
  useEffect(() => {
17
17
  if (isMobile && qrUri && deepLinkBase) {
@@ -29,20 +29,7 @@ export const CreditWalletView: FC<CreditWalletViewProps> = ({ connectorId, qrUri
29
29
 
30
30
  return (
31
31
  <div className="ck-view ck-view--qr">
32
- <div className={`ck-qr-frame ${!qrUri ? 'ck-qr-frame--loading' : ''}`}>
33
- {qrUri ? (
34
- <QRCodeSVG
35
- value={qrUri}
36
- size={196}
37
- level="H"
38
- imageSettings={
39
- logoUrl ? { src: logoUrl, width: 40, height: 40, excavate: true } : undefined
40
- }
41
- />
42
- ) : (
43
- <div className="ck-qr-spinner" aria-label="Loading QR code" />
44
- )}
45
- </div>
32
+ <QRFrame qrUri={qrUri} logoUrl={logoUrl} />
46
33
 
47
34
  <p className="ck-view__caption">Open the Credit Wallet app and scan the QR</p>
48
35
 
@@ -1,7 +1,7 @@
1
- import { type FC, useEffect } from 'react';
2
- import { QRCodeSVG } from 'qrcode.react';
1
+ import { type FC, useEffect, useMemo } from 'react';
3
2
  import { isMobileDevice, tryOpenDeepLink } from '../utils/platform';
4
3
  import { CONNECTOR_META } from '../connector-meta';
4
+ import { QRFrame, CopyLinkButton } from '../components/QRFrame';
5
5
 
6
6
  const { downloadUrl, deepLinkBase } = CONNECTOR_META.METAMASK;
7
7
 
@@ -12,7 +12,7 @@ interface MetaMaskViewProps {
12
12
  }
13
13
 
14
14
  export const MetaMaskView: FC<MetaMaskViewProps> = ({ qrUri, hasExtension, logoUrl }) => {
15
- const isMobile = isMobileDevice();
15
+ const isMobile = useMemo(() => isMobileDevice(), []);
16
16
 
17
17
  useEffect(() => {
18
18
  if (isMobile && qrUri && deepLinkBase) {
@@ -41,33 +41,8 @@ export const MetaMaskView: FC<MetaMaskViewProps> = ({ qrUri, hasExtension, logoU
41
41
 
42
42
  return (
43
43
  <div className="ck-view ck-view--qr">
44
- <div className={`ck-qr-frame ${!qrUri ? 'ck-qr-frame--loading' : ''}`}>
45
- {qrUri ? (
46
- <QRCodeSVG
47
- value={qrUri}
48
- size={196}
49
- level="H"
50
- imageSettings={
51
- logoUrl ? { src: logoUrl, width: 40, height: 40, excavate: true } : undefined
52
- }
53
- />
54
- ) : (
55
- <div className="ck-qr-spinner" aria-label="Loading QR code" />
56
- )}
57
- </div>
58
-
59
- {qrUri && (
60
- <button
61
- type="button"
62
- className="ck-copy-link"
63
- onClick={() => {
64
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
65
- navigator.clipboard.writeText(qrUri);
66
- }}
67
- >
68
- Copy link
69
- </button>
70
- )}
44
+ <QRFrame qrUri={qrUri} logoUrl={logoUrl} />
45
+ <CopyLinkButton qrUri={qrUri} />
71
46
 
72
47
  {downloadUrl && (
73
48
  <div className="ck-download-card">
@@ -0,0 +1,67 @@
1
+ import { useState, type FC } from 'react';
2
+ import { useSwitchChain } from 'wagmi';
3
+
4
+ interface SwitchChainViewProps {
5
+ currentChainId: number | undefined;
6
+ requiredChainId: number;
7
+ requiredChainName?: string;
8
+ onDisconnect: () => void;
9
+ onLog?: (message: string, error?: Error) => void;
10
+ }
11
+
12
+ export const SwitchChainView: FC<SwitchChainViewProps> = ({
13
+ currentChainId,
14
+ requiredChainId,
15
+ requiredChainName,
16
+ onDisconnect,
17
+ onLog,
18
+ }) => {
19
+ const { switchChainAsync, isPending } = useSwitchChain();
20
+ const [error, setError] = useState<Error | null>(null);
21
+
22
+ const targetLabel = requiredChainName ?? `chain ${requiredChainId}`;
23
+
24
+ const handleSwitch = (): void => {
25
+ setError(null);
26
+ switchChainAsync({ chainId: requiredChainId }).catch((err: unknown) => {
27
+ const normalized = err instanceof Error ? err : new Error(String(err));
28
+ // wagmi's MetaMask connector handles 4902 (chain not added) via wallet_addEthereumChain
29
+ // automatically when chain metadata is configured. We only surface what reaches us.
30
+ const code =
31
+ typeof err === 'object' && err !== null && 'code' in err
32
+ ? (err as { code: unknown }).code
33
+ : undefined;
34
+ if (code === 4902) {
35
+ onLog?.('[connect-kit] chain not registered in wallet (4902)', normalized);
36
+ } else {
37
+ onLog?.(`[connect-kit] switch chain failed: ${normalized.message}`, normalized);
38
+ }
39
+ setError(normalized);
40
+ });
41
+ };
42
+
43
+ return (
44
+ <div className="ck-view ck-view--switch-chain">
45
+ <h4 className="ck-view__title">Wrong network</h4>
46
+ <p className="ck-view__description">
47
+ {currentChainId != null
48
+ ? `Connected to chain ${currentChainId}. Please switch to ${targetLabel} to continue.`
49
+ : `Please switch to ${targetLabel} to continue.`}
50
+ </p>
51
+ <div className="ck-view__actions">
52
+ <button
53
+ type="button"
54
+ className="ck-btn-primary"
55
+ onClick={handleSwitch}
56
+ disabled={isPending}
57
+ >
58
+ {isPending ? 'Waiting for wallet…' : `Switch to ${targetLabel}`}
59
+ </button>
60
+ <button type="button" className="ck-btn-secondary" onClick={onDisconnect}>
61
+ Disconnect
62
+ </button>
63
+ </div>
64
+ {error && <p className="ck-view__caption ck-view__caption--error">{error.message}</p>}
65
+ </div>
66
+ );
67
+ };