@gluwa/connect-kit 0.2.0-next.9 → 0.2.1-next.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 (43) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/assets/wc.png +0 -0
  3. package/dist/{chunk-VE5LB7BN.js → chunk-CJANPJHY.js} +4 -2
  4. package/dist/credit-connect.js +1 -1
  5. package/dist/index.css +178 -122
  6. package/dist/index.d.ts +4 -1
  7. package/dist/index.js +471 -182
  8. package/dist/package.json +1 -1
  9. package/package.json +6 -6
  10. package/src/ConnectKitProvider.tsx +8 -1
  11. package/src/components/qrArea/index.tsx +25 -3
  12. package/src/components/qrArea/style.scss +61 -2
  13. package/src/components/toast/index.tsx +41 -0
  14. package/src/components/toast/style.scss +108 -0
  15. package/src/core/config.ts +23 -2
  16. package/src/core/deeplinkSignal.ts +25 -0
  17. package/src/creditConnectConnector.ts +15 -5
  18. package/src/events/account.ts +15 -6
  19. package/src/hooks/useConnectBanner.ts +79 -0
  20. package/src/hooks/useConnectTimeout.ts +21 -9
  21. package/src/hooks/useMetaMaskExtension.ts +2 -1
  22. package/src/hooks/useWCState.ts +7 -2
  23. package/src/hooks/useWagmiConnect.ts +41 -14
  24. package/src/index.ts +1 -0
  25. package/src/layout/allWallets/index.tsx +16 -2
  26. package/src/layout/connectDialog/asset/warningIcon.tsx +21 -0
  27. package/src/layout/connectDialog/idle/assets/banner-placeholder.png +0 -0
  28. package/src/layout/connectDialog/idle/index.tsx +40 -16
  29. package/src/layout/connectDialog/idle/style.scss +42 -51
  30. package/src/layout/connectDialog/index.tsx +174 -37
  31. package/src/layout/connectDialog/metaMask/index.tsx +3 -2
  32. package/src/layout/connectDialog/qr/index.tsx +3 -1
  33. package/src/layout/connectDialog/style.scss +0 -48
  34. package/src/layout/connectDialog/timeout/index.tsx +0 -5
  35. package/src/layout/connectDialog/timeout/style.scss +0 -5
  36. package/src/layout/connectDialog/walletQR/index.tsx +3 -1
  37. package/src/layout/detailPanel/index.tsx +39 -6
  38. package/src/layout/detailPanel/style.scss +2 -1
  39. package/src/layout/selectorPanel/index.tsx +1 -0
  40. package/src/types.ts +20 -0
  41. package/src/utils/platform.ts +7 -0
  42. package/src/components/snackbar/index.tsx +0 -25
  43. package/src/components/snackbar/style.scss +0 -51
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gluwa/connect-kit",
3
- "version": "0.2.0-next.9",
3
+ "version": "0.2.1-next.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gluwa/connect-kit",
3
- "version": "0.2.0-next.9",
3
+ "version": "0.2.1-next.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -26,10 +26,10 @@
26
26
  "qrcode.react": "^4.2.0",
27
27
  "wagmi": "^2.15.6",
28
28
  "@wagmi/core": "^2.16.7",
29
- "@gluwa/credit-connect-e2ee-tweetnacl": "0.2.0-next.4",
30
- "@gluwa/credit-connect-hub-node-ws": "0.1.0-next.4",
31
- "@gluwa/credit-connect-sdk": "0.2.0-next.4",
32
- "@gluwa/credit-connect-storage-local": "0.2.0-next.4"
29
+ "@gluwa/credit-connect-storage-local": "0.2.1-next.7",
30
+ "@gluwa/credit-connect-e2ee-tweetnacl": "0.2.1-next.7",
31
+ "@gluwa/credit-connect-sdk": "0.2.1-next.7",
32
+ "@gluwa/credit-connect-hub-node-ws": "0.1.1-next.1"
33
33
  },
34
34
  "devDependencies": {
35
35
  "@tanstack/react-query": "^5.62.0",
@@ -43,7 +43,7 @@
43
43
  "typescript-eslint": "^8.18.2",
44
44
  "viem": "^2.31.7",
45
45
  "wagmi": "^2.15.6",
46
- "@gluwa/credit-connect-sdk": "0.2.0-next.4"
46
+ "@gluwa/credit-connect-sdk": "0.2.1-next.7"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@tanstack/react-query": ">=5.0.0",
@@ -18,6 +18,7 @@ import { getConfig, getKitConfig, initConfig } from './core/config';
18
18
  import { resolveConnectorId } from './connector-meta';
19
19
  import type {
20
20
  ConnectErrorContext,
21
+ ConnectBannerSiteKey,
21
22
  ConnectKitConfig,
22
23
  ConnectKitTheme,
23
24
  ConnectResult,
@@ -30,6 +31,8 @@ const internalQueryClient = new QueryClient();
30
31
 
31
32
  export interface ConnectKitProviderProps {
32
33
  children: ReactNode;
34
+ siteKey: ConnectBannerSiteKey;
35
+ connectBannerUrl: string;
33
36
  kitConfig?: ConnectKitConfig;
34
37
  connectors: Connectors;
35
38
  requiredChainId?: number;
@@ -90,6 +93,8 @@ type ConnectKitProviderInnerProps = Omit<ConnectKitProviderProps, 'kitConfig'> &
90
93
 
91
94
  const ConnectKitProviderInner: FC<ConnectKitProviderInnerProps> = ({
92
95
  children,
96
+ siteKey,
97
+ connectBannerUrl,
93
98
  connectors,
94
99
  kitConfig,
95
100
  requiredChainId,
@@ -154,7 +159,7 @@ const ConnectKitProviderInner: FC<ConnectKitProviderInnerProps> = ({
154
159
  } finally {
155
160
  if (isMountedRef.current) setIsSwitchingChain(false);
156
161
  }
157
- }, [requiredChainId, switchChainAsync]);
162
+ }, [requiredChainId, switchChainAsync, account.chainId]);
158
163
 
159
164
  // ConnectDialog 의 wagmi connect 성공 → pendingResult set → 아래 useEffect 가
160
165
  // account.status === 'connected' 확인 후 외부 onConnect 호출 + modal close.
@@ -229,6 +234,8 @@ const ConnectKitProviderInner: FC<ConnectKitProviderInnerProps> = ({
229
234
  {children}
230
235
  <ConnectDialog
231
236
  open={effectiveOpen}
237
+ siteKey={siteKey}
238
+ connectBannerUrl={connectBannerUrl}
232
239
  theme={theme}
233
240
  connectors={resolveConnectorsFromConfig(connectors)}
234
241
  wcProjectId={kitConfig.wcProjectId}
@@ -12,13 +12,22 @@ interface QrAreaProps {
12
12
  hasBg?: boolean;
13
13
  label?: string;
14
14
  onCopy?: () => void;
15
+ onOpenApp?: () => void;
15
16
  }
16
17
 
17
18
  const BG_COLOR = '#ffffff';
18
19
  const FG_COLOR = '#000000';
19
20
  const FG_GRADIENT = 'url(#ck-qr-gradient)';
20
21
 
21
- export const QrArea: FC<QrAreaProps> = ({ uri, status, logoUrl, hasBg = false, label, onCopy }) => {
22
+ export const QrArea: FC<QrAreaProps> = ({
23
+ uri,
24
+ status,
25
+ logoUrl,
26
+ hasBg = false,
27
+ label,
28
+ onCopy,
29
+ onOpenApp,
30
+ }) => {
22
31
  const bgColor = hasBg ? BG_COLOR : 'transparent';
23
32
  const fgColor = hasBg ? FG_COLOR : FG_GRADIENT;
24
33
 
@@ -60,10 +69,23 @@ export const QrArea: FC<QrAreaProps> = ({ uri, status, logoUrl, hasBg = false, l
60
69
  </div>
61
70
  )}
62
71
  </figure>
63
- {(label || onCopy) && (
72
+ {(label || onCopy || onOpenApp) && (
64
73
  <div className="ck-qr-text">
65
74
  {label && <p className="ck-qr-label">{label}</p>}
66
- {onCopy && <CopyLink onCopy={onCopy} />}
75
+ {onOpenApp &&
76
+ (status === 'loading' ? (
77
+ <span className="ck-qr-open-app-skeleton" aria-hidden="true" />
78
+ ) : (
79
+ <button type="button" className="ck-qr-open-app" onClick={onOpenApp}>
80
+ Open app
81
+ </button>
82
+ ))}
83
+ {onCopy &&
84
+ (status === 'loading' ? (
85
+ <span className="ck-qr-copy-skeleton" aria-hidden="true" />
86
+ ) : (
87
+ <CopyLink onCopy={onCopy} />
88
+ ))}
67
89
  </div>
68
90
  )}
69
91
  </div>
@@ -2,11 +2,11 @@
2
2
 
3
3
  .ck-qr-area {
4
4
  display: flex;
5
+ flex-direction: column;
5
6
  align-items: center;
6
- justify-content: center;
7
7
  flex: 1;
8
8
  width: 100%;
9
- padding-bottom: 32px;
9
+ padding: 32px 0;
10
10
  }
11
11
 
12
12
  // ── Text ──────────────────────────────────────────────────────────────────────
@@ -17,6 +17,10 @@
17
17
  gap: 6px;
18
18
  width: 100%;
19
19
  margin-top: 24px;
20
+
21
+ @include tablet {
22
+ gap: 12px;
23
+ }
20
24
  }
21
25
 
22
26
  .ck-qr-label {
@@ -25,6 +29,46 @@
25
29
  text-align: center;
26
30
  }
27
31
 
32
+ .ck-root .ck-qr-open-app {
33
+ display: none;
34
+ align-items: center;
35
+ justify-content: center;
36
+ padding: 12px 28px;
37
+ border: 1px solid var(--semantic-divider-divider-4);
38
+ border-radius: 500px;
39
+ @include textToken('label4-bold');
40
+ color: var(--scale-gray-900);
41
+ cursor: pointer;
42
+ transition: background 0.15s ease;
43
+
44
+ @include tablet {
45
+ display: inline-flex;
46
+ }
47
+
48
+ &:hover {
49
+ background: var(--scale-gray-100);
50
+ }
51
+ }
52
+
53
+ .ck-qr-open-app-skeleton {
54
+ display: none;
55
+ width: 116px;
56
+ height: 44px;
57
+ border-radius: 500px;
58
+ background: linear-gradient(
59
+ 132deg,
60
+ var(--scale-gray-100) 36%,
61
+ var(--scale-gray-200) 51%,
62
+ var(--scale-gray-100) 66%
63
+ );
64
+ background-size: 200% 100%;
65
+ animation: ck-qr-shimmer 1.5s linear infinite;
66
+
67
+ @include tablet {
68
+ display: block;
69
+ }
70
+ }
71
+
28
72
  .ck-qr {
29
73
  display: flex;
30
74
  flex-direction: column;
@@ -76,6 +120,21 @@
76
120
  }
77
121
  }
78
122
 
123
+ .ck-qr-copy-skeleton {
124
+ display: block;
125
+ width: 84px;
126
+ height: 20px;
127
+ border-radius: 4px;
128
+ background: linear-gradient(
129
+ 132deg,
130
+ var(--scale-gray-100) 36%,
131
+ var(--scale-gray-200) 51%,
132
+ var(--scale-gray-100) 66%
133
+ );
134
+ background-size: 200% 100%;
135
+ animation: ck-qr-shimmer 1.5s linear infinite;
136
+ }
137
+
79
138
  @keyframes ck-qr-shimmer {
80
139
  0% { background-position: 200% 0; }
81
140
  100% { background-position: -200% 0; }
@@ -0,0 +1,41 @@
1
+ import { type FC, useEffect } from 'react';
2
+ import './style.scss';
3
+ import { CloseIcon } from '../../layout/connectDialog/asset/closeIcon';
4
+ import { CheckIcon } from '../../layout/connectDialog/asset/checkIcon';
5
+ import { WarningIcon } from '../../layout/connectDialog/asset/warningIcon';
6
+
7
+ export type ToastVariant = 'success' | 'warning';
8
+
9
+ const AUTO_CLOSE_MS = 3000;
10
+
11
+ interface ToastProps {
12
+ message: string;
13
+ variant?: ToastVariant;
14
+ onClose?: () => void;
15
+ }
16
+
17
+ export const Toast: FC<ToastProps> = ({ message, variant = 'success', onClose }) => {
18
+ useEffect(() => {
19
+ if (!onClose) return;
20
+ const timer = setTimeout(onClose, AUTO_CLOSE_MS);
21
+ return () => clearTimeout(timer);
22
+ }, [variant, onClose, message]);
23
+
24
+ return (
25
+ <div className="ck-toast" data-variant={variant} role="status" aria-live="polite">
26
+ <div className="ck-toast-content">
27
+ {variant === 'warning' ? (
28
+ <WarningIcon className="ck-toast-icon ck-toast-icon--warning" />
29
+ ) : (
30
+ <CheckIcon className="ck-toast-icon ck-toast-icon--success" />
31
+ )}
32
+ <p className="ck-toast-message">{message}</p>
33
+ </div>
34
+ {onClose && (
35
+ <button type="button" className="ck-toast-close" onClick={onClose} aria-label="Close">
36
+ <CloseIcon width={20} height={20} />
37
+ </button>
38
+ )}
39
+ </div>
40
+ );
41
+ };
@@ -0,0 +1,108 @@
1
+ @use '../../style/mixin.scss' as *;
2
+
3
+ .ck-toast-stack {
4
+ position: fixed;
5
+ top: clamp(20px, 9.2vh, 99px);
6
+ left: 50%;
7
+ z-index: 1001;
8
+ display: flex;
9
+ flex-direction: column;
10
+ align-items: center;
11
+ gap: 8px;
12
+ max-width: calc(100vw - 40px);
13
+ transform: translateX(-50%);
14
+
15
+ @include tablet {
16
+ top: clamp(
17
+ calc(env(safe-area-inset-top, 0px) + 20px),
18
+ calc(env(safe-area-inset-top, 0px) + 20px),
19
+ calc(env(safe-area-inset-top, 0px) + 20px)
20
+ );
21
+ left: 20px;
22
+ right: 20px;
23
+ align-items: stretch;
24
+ max-width: none;
25
+ transform: none;
26
+ }
27
+ }
28
+
29
+ .ck-toast {
30
+ display: flex;
31
+ align-items: center;
32
+ justify-content: space-between;
33
+ gap: 16px;
34
+ width: max-content;
35
+ min-width: min(400px, calc(100vw - 40px));
36
+ max-width: 100%;
37
+ padding: 12px 16px;
38
+ border-radius: 8px;
39
+ background: var(--semantic-paper-sheet);
40
+ border: 1px solid var(--semantic-divider-divider-1);
41
+ animation: ck-toast-in 0.2s ease;
42
+
43
+ @include tablet {
44
+ width: 100%;
45
+ min-width: 0;
46
+ }
47
+ }
48
+
49
+ @keyframes ck-toast-in {
50
+ from {
51
+ opacity: 0;
52
+ transform: translateY(8px);
53
+ }
54
+
55
+ to {
56
+ opacity: 1;
57
+ transform: translateY(0);
58
+ }
59
+ }
60
+
61
+ .ck-toast-content {
62
+ display: flex;
63
+ align-items: center;
64
+ gap: 8px;
65
+
66
+ @include tablet {
67
+ flex: 1;
68
+ min-width: 0;
69
+ }
70
+ }
71
+
72
+ .ck-toast-icon {
73
+ display: flex;
74
+ align-items: center;
75
+ justify-content: center;
76
+ width: 24px;
77
+ height: 24px;
78
+ flex-shrink: 0;
79
+ }
80
+
81
+ .ck-toast-icon--success {
82
+ color: var(--semantic-information-success);
83
+ }
84
+
85
+ .ck-toast-icon--warning {
86
+ color: var(--semantic-information-caution);
87
+ }
88
+
89
+ .ck-toast-message {
90
+ @include textToken('body3-l1-medium');
91
+ color: var(--scale-gray-900);
92
+ white-space: nowrap;
93
+
94
+ @include tablet {
95
+ flex: 1;
96
+ min-width: 0;
97
+ white-space: normal;
98
+ }
99
+ }
100
+
101
+ .ck-root .ck-toast-close {
102
+ display: flex;
103
+ align-items: center;
104
+ justify-content: center;
105
+ flex-shrink: 0;
106
+ color: var(--scale-gray-700);
107
+ cursor: pointer;
108
+ }
@@ -1,5 +1,7 @@
1
1
  import { createConfig, createStorage } from 'wagmi';
2
2
  import { metaMask, walletConnect, injected } from 'wagmi/connectors';
3
+ import { hasInjectedMobileProvider } from '../utils/platform';
4
+ import { markDeeplinkOpened } from './deeplinkSignal';
3
5
  import type { Config } from 'wagmi';
4
6
  import type { ConnectKitConfig } from '../types';
5
7
  import { creditConnectConnector } from '../creditConnectConnector';
@@ -44,11 +46,26 @@ export const initConfig = (kitConfig: ConnectKitConfig): Config => {
44
46
  connectors.push(walletConnect({ projectId: kitConfig.wcProjectId, showQrModal: false }));
45
47
  }
46
48
 
49
+ // MetaMask는 metaMaskSDK 커넥터 하나로만 연결한다.
50
+ // generic injected 커넥터를 함께 두면 같은 window.ethereum에 이중 바인딩되어
51
+ // wagmi connections 가 2개가 되고, dApp의 다중연결 감지 로직이 오작동한다.
47
52
  connectors.push(
48
- metaMask({ headless: true, storage: { enabled: true } }),
49
- injected({ shimDisconnect: false }),
53
+ metaMask({
54
+ headless: true,
55
+ useDeeplink: true,
56
+ storage: { enabled: true },
57
+ openDeeplink: (url) => {
58
+ markDeeplinkOpened();
59
+ window.location.replace(url);
60
+ },
61
+ }),
50
62
  );
51
63
 
64
+ // 인앱 지갑 웹뷰(모바일 + 호스트 window.ethereum 주입)
65
+ if (hasInjectedMobileProvider()) {
66
+ connectors.push(injected({ shimDisconnect: false }));
67
+ }
68
+
52
69
  if (kitConfig.creditConnect) {
53
70
  connectors.push(
54
71
  creditConnectConnector({
@@ -73,6 +90,10 @@ export const initConfig = (kitConfig: ConnectKitConfig): Config => {
73
90
  connectors,
74
91
  transports: kitConfig.transports,
75
92
  storage: isBrowser ? createStorage({ storage: window.localStorage }) : undefined,
93
+ // EIP-6963 자동 탐지 비활성화: 켜두면 MetaMask 가 io.metamask(type='injected')로
94
+ // 다시 발견되어 metaMaskSDK 와 이중 커넥터가 되고 connections 가 2개로 잡힌다.
95
+ // MetaMask 는 위 metaMaskSDK 커넥터로만 붙이므로 탐지가 필요 없다.
96
+ multiInjectedProviderDiscovery: false,
76
97
  });
77
98
  slot.kitConfig = kitConfig;
78
99
 
@@ -0,0 +1,25 @@
1
+ type Signal = { opened: boolean };
2
+
3
+ const SIGNAL_KEY = Symbol.for('@gluwa/connect-kit:deeplink-signal');
4
+
5
+ type GlobalWithSignal = typeof globalThis & {
6
+ [SIGNAL_KEY]?: Signal;
7
+ };
8
+
9
+ const getSignal = (): Signal => {
10
+ const g = globalThis as GlobalWithSignal;
11
+ if (!g[SIGNAL_KEY]) {
12
+ g[SIGNAL_KEY] = { opened: false };
13
+ }
14
+ return g[SIGNAL_KEY];
15
+ };
16
+
17
+ export const resetDeeplinkSignal = (): void => {
18
+ getSignal().opened = false;
19
+ };
20
+
21
+ export const markDeeplinkOpened = (): void => {
22
+ getSignal().opened = true;
23
+ };
24
+
25
+ export const hasDeeplinkOpened = (): boolean => getSignal().opened;
@@ -29,7 +29,10 @@ const CONNECTOR_TYPE = 'credit-connect';
29
29
  const castConnectResult = <withCapabilities extends boolean>(value: unknown) =>
30
30
  value as {
31
31
  accounts: withCapabilities extends true
32
- ? ReadonlyArray<{ address: Address; capabilities: Record<string, unknown> }>
32
+ ? ReadonlyArray<{
33
+ address: Address;
34
+ capabilities: Record<string, unknown>;
35
+ }>
33
36
  : readonly Address[];
34
37
  chainId: number;
35
38
  };
@@ -92,7 +95,7 @@ export const creditConnectConnector = (
92
95
  if (state.status === 'CONNECTING' && state.connectingPayload) {
93
96
  // eslint-disable-next-line no-console
94
97
  console.log('[CC connector] emit display_uri', {
95
- payloadPrefix: state.connectingPayload.slice(0, 80),
98
+ ready: true,
96
99
  });
97
100
  options.onDisplayUri?.(state.connectingPayload);
98
101
  emitWagmiMessage?.('display_uri', state.connectingPayload);
@@ -109,7 +112,9 @@ export const creditConnectConnector = (
109
112
 
110
113
  manager.on('sessionExpired', (payload) => {
111
114
  // eslint-disable-next-line no-console
112
- console.log('[CC connector] session expired → emit session_expired', payload);
115
+ console.log('[CC connector] session expired → emit session_expired', {
116
+ reason: payload.reason,
117
+ });
113
118
  emitWagmiMessage?.('session_expired', payload);
114
119
  });
115
120
 
@@ -318,7 +323,10 @@ export const creditConnectConnector = (
318
323
  withCapabilities?: withCapabilities | boolean;
319
324
  }): Promise<{
320
325
  accounts: withCapabilities extends true
321
- ? ReadonlyArray<{ address: Address; capabilities: Record<string, unknown> }>
326
+ ? ReadonlyArray<{
327
+ address: Address;
328
+ capabilities: Record<string, unknown>;
329
+ }>
322
330
  : readonly Address[];
323
331
  chainId: number;
324
332
  }> {
@@ -412,7 +420,9 @@ export const creditConnectConnector = (
412
420
  );
413
421
 
414
422
  if (!supported) {
415
- const err = new Error(`Unsupported chain: ${chainId}`) as Error & { code?: number };
423
+ const err = new Error(`Unsupported chain: ${chainId}`) as Error & {
424
+ code?: number;
425
+ };
416
426
  err.code = 4902;
417
427
  throw err;
418
428
  }
@@ -24,28 +24,37 @@ export const reconnect = async (): Promise<ReconnectReturnType> => {
24
24
 
25
25
  if (getKitConfig().autoConnectInjected !== false) {
26
26
  try {
27
- const injected = config.connectors.find((connector) => connector.type === 'injected');
28
- if (injected) {
29
- const provider = (await injected.getProvider()) as
27
+ // 자동 재연결
28
+ // 인앱 지갑 웹뷰: 호스트 주입 provider 는 injected 커넥터로만
29
+ const injectedConnector = config.connectors.find(
30
+ (connector) => connector.type === 'injected',
31
+ );
32
+ const metaMaskConnector = config.connectors.find(
33
+ (connector) => connector.id === 'metaMaskSDK',
34
+ );
35
+ const target = injectedConnector ?? metaMaskConnector;
36
+ if (target) {
37
+ const provider = (await target.getProvider()) as
30
38
  | { request?: (args: { method: string }) => Promise<unknown> }
31
39
  | undefined;
32
40
  const accounts = provider?.request
33
41
  ? await provider.request({ method: 'eth_accounts' })
34
42
  : [];
35
43
  if (Array.isArray(accounts) && accounts.length > 0) {
36
- await wagmiConnect(config, { connector: injected });
44
+ await wagmiConnect(config, { connector: target });
37
45
  }
38
46
  }
39
47
  } catch (error) {
40
48
  // eslint-disable-next-line no-console
41
- console.warn('injected auto-connect skipped (non-fatal):', error);
49
+ console.warn('auto-connect skipped (non-fatal):', error);
42
50
  }
43
51
  }
44
52
 
45
53
  if (wagmiGetAccount(config).status === 'connected') {
46
54
  return [];
47
55
  }
48
- return wagmiReconnect(config);
56
+ const result = await wagmiReconnect(config);
57
+ return result;
49
58
  };
50
59
 
51
60
  export const disconnectAll = async (
@@ -0,0 +1,79 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import type { ConnectBanner, ConnectBannerSiteKey } from '../types';
3
+
4
+ interface ConnectBannersResponse {
5
+ banners: ConnectBanner[];
6
+ }
7
+
8
+ interface UseConnectBannerParams {
9
+ open: boolean;
10
+ siteKey: ConnectBannerSiteKey;
11
+ url: string;
12
+ }
13
+
14
+ interface UseConnectBannerResult {
15
+ banner: ConnectBanner | null;
16
+ loading: boolean;
17
+ }
18
+
19
+ const pickRandomBanner = (banners: ConnectBanner[]): ConnectBanner | null =>
20
+ banners[Math.floor(Math.random() * banners.length)] ?? null;
21
+
22
+ export const useConnectBanner = ({
23
+ open,
24
+ siteKey,
25
+ url,
26
+ }: UseConnectBannerParams): UseConnectBannerResult => {
27
+ const [candidates, setCandidates] = useState<ConnectBanner[]>([]);
28
+ const [banner, setBanner] = useState<ConnectBanner | null>(null);
29
+ const [loading, setLoading] = useState(true);
30
+ const wasOpenRef = useRef(open);
31
+
32
+ useEffect(() => {
33
+ if (!url) {
34
+ setCandidates([]);
35
+ setBanner(null);
36
+ setLoading(false);
37
+ return;
38
+ }
39
+
40
+ const controller = new AbortController();
41
+ setCandidates([]);
42
+ setBanner(null);
43
+ setLoading(true);
44
+
45
+ fetch(url, { signal: controller.signal })
46
+ .then(async (response) => {
47
+ if (!response.ok) throw new Error(`Failed to fetch connect banners: ${response.status}`);
48
+ return (await response.json()) as ConnectBannersResponse;
49
+ })
50
+ .then(({ banners }) => {
51
+ if (controller.signal.aborted) return;
52
+ if (!Array.isArray(banners)) {
53
+ setLoading(false);
54
+ return;
55
+ }
56
+
57
+ const nextCandidates = banners.filter((item) => item.siteKey !== siteKey);
58
+ setCandidates(nextCandidates);
59
+ setBanner(pickRandomBanner(nextCandidates));
60
+ setLoading(false);
61
+ })
62
+ .catch(() => {
63
+ // API 실패 시 기존 Idle placeholder를 유지한다.
64
+ if (!controller.signal.aborted) setLoading(false);
65
+ });
66
+
67
+ return () => controller.abort();
68
+ }, [siteKey, url]);
69
+
70
+ useEffect(() => {
71
+ const wasOpen = wasOpenRef.current;
72
+ wasOpenRef.current = open;
73
+
74
+ // 닫힌 dialog 안에서 다음 배너 이미지를 미리 렌더링한다.
75
+ if (wasOpen && !open) setBanner(pickRandomBanner(candidates));
76
+ }, [candidates, open]);
77
+
78
+ return { banner, loading };
79
+ };