@gluwa/connect-kit 0.1.0-next.1 → 0.1.0-next.3

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.
@@ -1,8 +1,10 @@
1
- import { useState, useEffect, useCallback, useRef, useMemo, type FC } from 'react';
1
+ import { useState, useEffect, useCallback, useMemo, type FC, type ReactNode } from 'react';
2
2
  import './ConnectModal.scss';
3
- import { useConfig } from 'wagmi';
3
+ import { useAccount, useConfig, useDisconnect } from 'wagmi';
4
4
  import {
5
5
  type ConnectModalProps,
6
+ type ConnectErrorContext,
7
+ type ConnectErrorReason,
6
8
  type Connectors,
7
9
  type ConnectorId,
8
10
  type WCWallet,
@@ -16,6 +18,7 @@ import { IdleView } from './views/IdleView';
16
18
  import { CreditWalletView } from './views/CreditWalletView';
17
19
  import { MetaMaskView } from './views/MetaMaskView';
18
20
  import { WalletConnectView } from './views/WalletConnectView';
21
+ import { SwitchChainView } from './views/SwitchChainView';
19
22
 
20
23
  const RECENT_KEY = 'connect-kit:recent';
21
24
  const readRecentConnector = (): ConnectorId | null => {
@@ -32,6 +35,18 @@ const writeRecentConnector = (id: ConnectorId): void => {
32
35
  } catch {}
33
36
  };
34
37
 
38
+ const classifyError = (error: Error): ConnectErrorReason => {
39
+ const code =
40
+ typeof error === 'object' && error !== null && 'code' in error
41
+ ? (error as unknown as { code: unknown }).code
42
+ : undefined;
43
+ if (code === 4001) return 'rejected';
44
+ const message = error.message?.toLowerCase() ?? '';
45
+ if (message.includes('user rejected') || message.includes('user denied')) return 'rejected';
46
+ if (message.includes('unsupported') || message.includes('not installed')) return 'unsupported';
47
+ return 'unknown';
48
+ };
49
+
35
50
  interface SelectorPaneProps {
36
51
  connectors: ConnectorId[];
37
52
  selected: ConnectorId | null;
@@ -149,6 +164,7 @@ interface DetailPaneProps {
149
164
  hasMetaMaskExtension: boolean;
150
165
  connectorLogoMap: Partial<Record<ConnectorId, string>>;
151
166
  wc: WCState;
167
+ errorOverride: ReactNode | null;
152
168
  onBack: () => void;
153
169
  onClose: () => void;
154
170
  }
@@ -159,6 +175,7 @@ const DetailPane: FC<DetailPaneProps> = ({
159
175
  hasMetaMaskExtension,
160
176
  connectorLogoMap,
161
177
  wc,
178
+ errorOverride,
162
179
  onBack,
163
180
  onClose,
164
181
  }) => {
@@ -181,35 +198,39 @@ const DetailPane: FC<DetailPaneProps> = ({
181
198
  </div>
182
199
 
183
200
  <div className="ck-pane__body">
184
- {!selectedConnector && <IdleView />}
201
+ {errorOverride || (
202
+ <>
203
+ {!selectedConnector && <IdleView />}
185
204
 
186
- {(selectedConnector === 'CREDIT_CONNECT' || selectedConnector === 'CREDIT_WALLET') && (
187
- <CreditWalletView connectorId={selectedConnector} qrUri={qrUri} />
188
- )}
205
+ {(selectedConnector === 'CREDIT_CONNECT' || selectedConnector === 'CREDIT_WALLET') && (
206
+ <CreditWalletView connectorId={selectedConnector} qrUri={qrUri} />
207
+ )}
189
208
 
190
- {selectedConnector === 'METAMASK' && (
191
- <MetaMaskView
192
- qrUri={qrUri}
193
- hasExtension={hasMetaMaskExtension}
194
- logoUrl={connectorLogoMap.METAMASK}
195
- />
196
- )}
209
+ {selectedConnector === 'METAMASK' && (
210
+ <MetaMaskView
211
+ qrUri={qrUri}
212
+ hasExtension={hasMetaMaskExtension}
213
+ logoUrl={connectorLogoMap.METAMASK}
214
+ />
215
+ )}
197
216
 
198
- {selectedConnector === 'WALLET_CONNECT' && (
199
- <WalletConnectView
200
- subView={wc.subView}
201
- qrUri={qrUri}
202
- logoUrl={connectorLogoMap.WALLET_CONNECT}
203
- walletList={wc.walletList}
204
- walletListLoading={wc.walletListLoading}
205
- walletListSearch={wc.walletListSearch}
206
- walletListFilterActive={wc.filterActive}
207
- selectedWallet={wc.selectedWallet}
208
- onShowList={wc.onShowList}
209
- onSelectWallet={wc.onSelectWallet}
210
- onSearchChange={wc.onSearchChange}
211
- onFilterToggle={wc.onFilterToggle}
212
- />
217
+ {selectedConnector === 'WALLET_CONNECT' && (
218
+ <WalletConnectView
219
+ subView={wc.subView}
220
+ qrUri={qrUri}
221
+ logoUrl={connectorLogoMap.WALLET_CONNECT}
222
+ walletList={wc.walletList}
223
+ walletListLoading={wc.walletListLoading}
224
+ walletListSearch={wc.walletListSearch}
225
+ walletListFilterActive={wc.filterActive}
226
+ selectedWallet={wc.selectedWallet}
227
+ onShowList={wc.onShowList}
228
+ onSelectWallet={wc.onSelectWallet}
229
+ onSearchChange={wc.onSearchChange}
230
+ onFilterToggle={wc.onFilterToggle}
231
+ />
232
+ )}
233
+ </>
213
234
  )}
214
235
  </div>
215
236
  </div>
@@ -278,45 +299,69 @@ const ConnectModalInner: FC<ConnectModalInnerProps> = ({
278
299
  onClose,
279
300
  onLog,
280
301
  wcProjectId,
302
+ requiredChainId,
303
+ renderConnectError,
281
304
  }) => {
282
305
  const [selectedConnector, setSelectedConnector] = useState<ConnectorId | null>(null);
283
- const [qrUriMap, setQrUriMap] = useState<Partial<Record<ConnectorId, string>>>({});
284
- const pendingQrConnectorRef = useRef<ConnectorId | null>(null);
306
+ const [qrUri, setQrUri] = useState<string | null>(null);
285
307
  const [recentConnector, setRecentConnector] = useState<ConnectorId | null>(null);
286
308
  const [hasMetaMaskExtension, setHasMetaMaskExtension] = useState<boolean>(false);
309
+ const [errorState, setErrorState] = useState<{ connectorId: ConnectorId; error: Error } | null>(
310
+ null,
311
+ );
287
312
 
288
313
  const wc = useWCState();
289
314
 
290
- // QR URI 수신 커넥터별 캐시에 저장 (null은 무시하여 캐시 유지)
315
+ // wagmi 상태chain mismatch invariant 판정용
316
+ const account = useAccount();
317
+ const { disconnectAsync } = useDisconnect();
318
+ const wagmiConfig = useConfig();
319
+
320
+ const chainMismatch =
321
+ requiredChainId != null &&
322
+ account.status === 'connected' &&
323
+ account.chainId != null &&
324
+ account.chainId !== requiredChainId;
325
+
326
+ const requiredChainName = useMemo<string | undefined>(() => {
327
+ if (requiredChainId == null) return undefined;
328
+ return wagmiConfig.chains.find((chain) => chain.id === requiredChainId)?.name;
329
+ }, [wagmiConfig.chains, requiredChainId]);
330
+
331
+ // QR URI 수신
291
332
  const handleQrUri = useCallback((uri: string | null) => {
292
- if (uri) {
293
- const id = pendingQrConnectorRef.current;
294
- if (id) setQrUriMap((prev) => ({ ...prev, [id]: uri }));
295
- }
333
+ setQrUri(uri);
296
334
  }, []);
297
335
 
298
336
  // 연결 완료
299
337
  const handleConnect = useCallback(
300
338
  (result: Parameters<typeof onConnect>[0]) => {
339
+ setErrorState(null);
301
340
  onConnect(result);
341
+ // chain mismatch가 있으면 모달은 SwitchChainView로 자동 전환됨 (닫지 않음)
342
+ // Provider 측에서 requiredChainId가 매칭될 때까지 force-open 유지
302
343
  onClose();
303
344
  },
304
345
  [onConnect, onClose],
305
346
  );
306
347
 
307
- // 연결 실패 — 실패한 커넥터의 QR 캐시만 삭제
348
+ // 연결 실패
308
349
  const handleError = useCallback(
309
- (error: Error, connectorId: ConnectorId): void => {
310
- onLog?.(`[connect-kit] connection failed (${connectorId}): ${error.message}`, error);
311
- setSelectedConnector(null);
312
- setQrUriMap((prev) => {
313
- const next = { ...prev };
314
- delete next[connectorId];
315
- return next;
316
- });
350
+ (error: unknown, connectorId: ConnectorId): void => {
351
+ const normalizedError = error instanceof Error ? error : new Error(String(error));
352
+ onLog?.(
353
+ `[connect-kit] connection failed (${connectorId}): ${normalizedError.message}`,
354
+ normalizedError,
355
+ );
356
+ setQrUri(null);
317
357
  wc.resetView();
358
+ if (renderConnectError) {
359
+ setErrorState({ connectorId, error: normalizedError });
360
+ } else {
361
+ setSelectedConnector(null);
362
+ }
318
363
  },
319
- [onLog, wc.resetView],
364
+ [onLog, wc.resetView, renderConnectError],
320
365
  );
321
366
 
322
367
  // 연결 시도
@@ -327,7 +372,6 @@ const ConnectModalInner: FC<ConnectModalInnerProps> = ({
327
372
  });
328
373
 
329
374
  // wagmi connector icon 매핑
330
- const wagmiConfig = useConfig();
331
375
  const connectorLogoMap = useMemo(() => {
332
376
  const map: Partial<Record<ConnectorId, string>> = {};
333
377
  const connectorIds: ConnectorId[] = [
@@ -349,24 +393,24 @@ const ConnectModalInner: FC<ConnectModalInnerProps> = ({
349
393
  setHasMetaMaskExtension(detectMetaMaskExtension());
350
394
  }, []);
351
395
 
352
- // 모달 닫힐 때 상태 + QR 캐시 전체 초기화
396
+ // 모달 닫힐 때 상태 초기화
353
397
  useEffect(() => {
354
398
  if (!open) {
355
399
  setSelectedConnector(null);
356
- setQrUriMap({});
357
- pendingQrConnectorRef.current = null;
400
+ setQrUri(null);
401
+ setErrorState(null);
358
402
  wc.reset();
359
403
  cancelConnect();
360
404
  }
361
405
  }, [open, cancelConnect, wc.reset]);
362
406
 
363
- // 커넥터 선택 — 같은 커넥터 재선택 무시, 다른 커넥터 선택 시 이전 연결 취소 후 새 연결
407
+ // 커넥터 선택 — 선택할 때마다 QR을 초기화해 세션 URI가 표시되도록
364
408
  const handleSelectConnector = useCallback(
365
409
  (id: ConnectorId): void => {
366
- if (id === selectedConnector) return;
367
-
368
- pendingQrConnectorRef.current = id;
410
+ if (id === selectedConnector && !errorState) return;
369
411
  setSelectedConnector(id);
412
+ setQrUri(null);
413
+ setErrorState(null);
370
414
  wc.resetView();
371
415
  writeRecentConnector(id);
372
416
  setRecentConnector(id);
@@ -380,6 +424,7 @@ const ConnectModalInner: FC<ConnectModalInnerProps> = ({
380
424
  },
381
425
  [
382
426
  selectedConnector,
427
+ errorState,
383
428
  triggerConnect,
384
429
  cancelConnect,
385
430
  wcProjectId,
@@ -388,19 +433,105 @@ const ConnectModalInner: FC<ConnectModalInnerProps> = ({
388
433
  ],
389
434
  );
390
435
 
391
- // 뒤로가기 — QR 캐시는 유지, wagmi만 reset
436
+ // 뒤로가기
392
437
  const handleBack = useCallback((): void => {
438
+ if (errorState) {
439
+ setErrorState(null);
440
+ setSelectedConnector(null);
441
+ return;
442
+ }
393
443
  if (selectedConnector === 'WALLET_CONNECT' && wc.handleBack()) return;
394
- pendingQrConnectorRef.current = null;
395
444
  setSelectedConnector(null);
445
+ setQrUri(null);
396
446
  cancelConnect();
397
- }, [selectedConnector, wc.handleBack, cancelConnect]);
447
+ }, [selectedConnector, errorState, wc.handleBack, cancelConnect]);
448
+
449
+ // chain mismatch 상태에서의 disconnect (Switch chain pane의 cancel)
450
+ const handleSwitchChainDisconnect = useCallback((): void => {
451
+ disconnectAsync().catch((err: unknown) => {
452
+ const normalized = err instanceof Error ? err : new Error(String(err));
453
+ onLog?.(`[connect-kit] disconnect failed: ${normalized.message}`, normalized);
454
+ });
455
+ }, [disconnectAsync, onLog]);
456
+
457
+ // overlay 클릭 — chain mismatch 상태에선 무시 (실수 클릭 방어)
458
+ const handleOverlayClick = useCallback((): void => {
459
+ if (chainMismatch) return;
460
+ onClose();
461
+ }, [chainMismatch, onClose]);
462
+
463
+ // close 버튼 — chain mismatch 상태에선 disconnect
464
+ const handleCloseClick = useCallback((): void => {
465
+ if (chainMismatch) {
466
+ handleSwitchChainDisconnect();
467
+ return;
468
+ }
469
+ onClose();
470
+ }, [chainMismatch, handleSwitchChainDisconnect, onClose]);
471
+
472
+ const renderedError: ReactNode = useMemo(() => {
473
+ if (!errorState || !renderConnectError) return null;
474
+ const ctx: ConnectErrorContext = {
475
+ connectorId: errorState.connectorId,
476
+ reason: classifyError(errorState.error),
477
+ error: errorState.error,
478
+ retry: () => {
479
+ const id = errorState.connectorId;
480
+ setErrorState(null);
481
+ setQrUri(null);
482
+ cancelConnect();
483
+ triggerConnect(id);
484
+ },
485
+ dismiss: () => {
486
+ setErrorState(null);
487
+ setSelectedConnector(null);
488
+ },
489
+ };
490
+ return renderConnectError(ctx);
491
+ }, [errorState, renderConnectError, triggerConnect, cancelConnect]);
492
+
493
+ // chain mismatch면 단일 pane (selector/detail 숨김)
494
+ // requiredChainId != null 재확인은 TS narrowing용 — chainMismatch true면 이미 보장됨
495
+ if (chainMismatch && requiredChainId != null) {
496
+ return (
497
+ <div className="ck-root">
498
+ <div className="ck-overlay" onClick={handleOverlayClick} />
499
+ <div
500
+ className="ck-modal ck-modal--switch-chain"
501
+ role="dialog"
502
+ aria-modal="true"
503
+ aria-label="Switch network"
504
+ >
505
+ <div className="ck-pane">
506
+ <div className="ck-pane__header">
507
+ <h3 className="ck-pane__title">Switch network</h3>
508
+ <button
509
+ type="button"
510
+ className="ck-btn-close"
511
+ onClick={handleCloseClick}
512
+ aria-label="Cancel and disconnect"
513
+ />
514
+ </div>
515
+ <div className="ck-pane__body">
516
+ <SwitchChainView
517
+ currentChainId={account.chainId}
518
+ requiredChainId={requiredChainId}
519
+ requiredChainName={requiredChainName}
520
+ onDisconnect={handleSwitchChainDisconnect}
521
+ onLog={onLog}
522
+ />
523
+ </div>
524
+ </div>
525
+ </div>
526
+ </div>
527
+ );
528
+ }
398
529
 
399
530
  return (
400
531
  <div className="ck-root">
401
- <div className="ck-overlay" onClick={onClose} />
532
+ <div className="ck-overlay" onClick={handleOverlayClick} />
402
533
  <div
403
- className={`ck-modal${selectedConnector ? ' ck-modal--has-detail' : ''}`}
534
+ className={`ck-modal${(selectedConnector ?? errorState) ? ' ck-modal--has-detail' : ''}`}
404
535
  role="dialog"
405
536
  aria-modal="true"
406
537
  aria-label="Connect Wallet"
@@ -410,16 +541,17 @@ const ConnectModalInner: FC<ConnectModalInnerProps> = ({
410
541
  selected={selectedConnector}
411
542
  recentConnector={recentConnector}
412
543
  onSelect={handleSelectConnector}
413
- onClose={onClose}
544
+ onClose={handleCloseClick}
414
545
  />
415
546
  <DetailPane
416
547
  selectedConnector={selectedConnector}
417
- qrUri={selectedConnector ? (qrUriMap[selectedConnector] ?? null) : null}
548
+ qrUri={qrUri}
418
549
  hasMetaMaskExtension={hasMetaMaskExtension}
419
550
  connectorLogoMap={connectorLogoMap}
420
551
  wc={wc}
552
+ errorOverride={renderedError}
421
553
  onBack={handleBack}
422
- onClose={onClose}
554
+ onClose={handleCloseClick}
423
555
  />
424
556
  </div>
425
557
  </div>
@@ -0,0 +1,2 @@
1
+ export { creditConnectConnector } from './creditConnectConnector';
2
+ export type { CreditConnectConnectorOptions } from './creditConnectConnector';
@@ -285,6 +285,7 @@ export const creditConnectConnector = (
285
285
  } finally {
286
286
  isLocalDisconnect = false;
287
287
  }
288
+ provider = null;
288
289
  resetState();
289
290
  config.emitter.emit('disconnect');
290
291
  },
package/src/index.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  export { ConnectModal } from './ConnectModal';
2
- export { creditConnectConnector } from './creditConnectConnector';
3
- export type { CreditConnectConnectorOptions } from './creditConnectConnector';
2
+ export { ConnectKitProvider, useConnectKit } from './ConnectKitProvider';
3
+ export type { ConnectKitContextValue, ConnectKitProviderProps } from './ConnectKitProvider';
4
4
  export type {
5
+ ConnectErrorContext,
6
+ ConnectErrorReason,
5
7
  ConnectModalProps,
6
8
  Connectors,
7
9
  ConnectResult,
package/src/types.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { ReactNode } from 'react';
2
+
1
3
  export type ConnectorId = 'CREDIT_WALLET' | 'CREDIT_CONNECT' | 'METAMASK' | 'WALLET_CONNECT';
2
4
 
3
5
  export type CreditWalletStrategy = 'walletConnect' | 'creditConnect';
@@ -28,6 +30,16 @@ export interface ConnectResult {
28
30
  connectorId: ConnectorId;
29
31
  }
30
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
+
31
43
  export interface ConnectModalProps {
32
44
  open: boolean;
33
45
  connectors: Connectors;
@@ -35,4 +47,6 @@ export interface ConnectModalProps {
35
47
  onClose: () => void;
36
48
  onLog?: (message: string, error?: Error) => void;
37
49
  wcProjectId?: string;
50
+ requiredChainId?: number;
51
+ renderConnectError?: (ctx: ConnectErrorContext) => ReactNode;
38
52
  }
@@ -1,4 +1,4 @@
1
- import { type FC, useEffect, useMemo } from 'react';
1
+ import { type FC, useEffect, useMemo, useRef } from 'react';
2
2
  import { isMobileDevice, tryOpenDeepLink } from '../utils/platform';
3
3
  import { CONNECTOR_META } from '../connector-meta';
4
4
  import { type ConnectorId } from '../types';
@@ -12,9 +12,12 @@ interface CreditWalletViewProps {
12
12
  export const CreditWalletView: FC<CreditWalletViewProps> = ({ connectorId, qrUri }) => {
13
13
  const { downloadUrl, logoUrl, deepLinkBase } = CONNECTOR_META[connectorId];
14
14
  const isMobile = useMemo(() => isMobileDevice(), []);
15
+ // WC 세션 만료로 qrUri가 재발급되더라도 딥링크는 최초 1회만 실행
16
+ const hasRedirectedRef = useRef(false);
15
17
 
16
18
  useEffect(() => {
17
- if (isMobile && qrUri && deepLinkBase) {
19
+ if (isMobile && qrUri && deepLinkBase && !hasRedirectedRef.current) {
20
+ hasRedirectedRef.current = true;
18
21
  tryOpenDeepLink(qrUri, deepLinkBase);
19
22
  }
20
23
  }, [isMobile, qrUri, deepLinkBase]);
@@ -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
+ };
package/tsup.config.ts CHANGED
@@ -2,7 +2,10 @@ import { defineConfig } from 'tsup';
2
2
  import { sassPlugin } from 'esbuild-sass-plugin';
3
3
 
4
4
  export default defineConfig({
5
- entry: { index: 'src/index.ts' },
5
+ entry: {
6
+ index: 'src/index.ts',
7
+ 'credit-connect': 'src/credit-connect.ts',
8
+ },
6
9
  format: ['esm'],
7
10
  dts: true,
8
11
  clean: true,
@@ -13,6 +16,7 @@ export default defineConfig({
13
16
  'wagmi',
14
17
  '@wagmi/core',
15
18
  'viem',
19
+ '@tanstack/react-query',
16
20
  '@gluwa/credit-connect-sdk',
17
21
  '@gluwa/credit-connect-sdk/dapp',
18
22
  '@gluwa/credit-connect-sdk/storage',