@gluwa/connect-kit 0.2.0-next.8 → 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 (45) hide show
  1. package/CHANGELOG.md +37 -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 +211 -156
  6. package/dist/index.d.ts +4 -1
  7. package/dist/index.js +521 -197
  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 +28 -4
  12. package/src/components/qrArea/style.scss +85 -34
  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 +30 -0
  22. package/src/hooks/useWCState.ts +14 -10
  23. package/src/hooks/useWagmiConnect.ts +42 -20
  24. package/src/index.ts +1 -0
  25. package/src/layout/allWallets/index.tsx +17 -3
  26. package/src/layout/allWallets/style.scss +2 -7
  27. package/src/layout/connectDialog/asset/warningIcon.tsx +21 -0
  28. package/src/layout/connectDialog/idle/assets/banner-placeholder.png +0 -0
  29. package/src/layout/connectDialog/idle/index.tsx +40 -16
  30. package/src/layout/connectDialog/idle/style.scss +42 -51
  31. package/src/layout/connectDialog/index.tsx +196 -47
  32. package/src/layout/connectDialog/metaMask/index.tsx +3 -2
  33. package/src/layout/connectDialog/qr/index.tsx +3 -1
  34. package/src/layout/connectDialog/style.scss +2 -49
  35. package/src/layout/connectDialog/timeout/index.tsx +0 -5
  36. package/src/layout/connectDialog/timeout/style.scss +0 -5
  37. package/src/layout/connectDialog/walletQR/index.tsx +3 -1
  38. package/src/layout/detailPanel/index.tsx +39 -6
  39. package/src/layout/detailPanel/style.scss +7 -1
  40. package/src/layout/selectorPanel/index.tsx +1 -0
  41. package/src/types.ts +20 -0
  42. package/src/utils/platform.ts +7 -4
  43. package/src/wallet-display-override.ts +15 -0
  44. package/src/components/snackbar/index.tsx +0 -25
  45. package/src/components/snackbar/style.scss +0 -51
@@ -2,6 +2,7 @@ import { type FC, useEffect, useRef, useState } from 'react';
2
2
  import { useConfig } from 'wagmi';
3
3
  import './style.scss';
4
4
  import {
5
+ type ConnectBannerSiteKey,
5
6
  type ConnectorId,
6
7
  type ConnectKitTheme,
7
8
  type WCWallet,
@@ -10,22 +11,22 @@ import {
10
11
  import { type DetailContent } from '../detailPanel';
11
12
  import { SelectorPanel } from '../selectorPanel';
12
13
  import { DetailPanel } from '../detailPanel';
13
- import { Snackbar } from '../../components/snackbar';
14
- import { resolveConnectorId } from '../../connector-meta';
14
+ import { Toast } from '../../components/toast';
15
+ import { CONNECTOR_META, resolveConnectorId } from '../../connector-meta';
16
+ import { useMetaMaskExtension } from '../../hooks/useMetaMaskExtension';
15
17
  import { useWagmiConnect } from '../../hooks/useWagmiConnect';
16
18
  import { useConnectTimeout } from '../../hooks/useConnectTimeout';
19
+ import { useConnectBanner } from '../../hooks/useConnectBanner';
17
20
  import { useWCState } from '../../hooks/useWCState';
18
- import {
19
- detectMetaMaskExtension,
20
- isMobileDevice,
21
- tryOpenDeepLink,
22
- tryOpenCreditWalletDeeplink,
23
- } from '../../utils/platform';
21
+ import { isMobileDevice, tryOpenDeepLink, tryOpenCreditWalletDeeplink } from '../../utils/platform';
22
+ import { hasDeeplinkOpened, markDeeplinkOpened } from '../../core/deeplinkSignal';
24
23
 
25
24
  export type DialogStep = 'selector' | 'detail';
26
25
 
27
26
  export interface ConnectDialogProps {
28
27
  open: boolean;
28
+ siteKey: ConnectBannerSiteKey;
29
+ connectBannerUrl: string;
29
30
  theme?: ConnectKitTheme;
30
31
  connectors?: ConnectorId[];
31
32
  selectedConnector?: ConnectorId | null;
@@ -49,11 +50,28 @@ const getContentForConnector = (id: ConnectorId): DetailContent => {
49
50
  };
50
51
 
51
52
  // MM 은 확장 감지 시 즉시 extension 연결, 아니면 QR. (select / retry 공통)
52
- const getInitialContentForConnector = (id: ConnectorId): DetailContent =>
53
- id === 'METAMASK' && !detectMetaMaskExtension() ? 'metamask-qr' : getContentForConnector(id);
53
+ const getInitialContentForConnector = (
54
+ id: ConnectorId,
55
+ isMetaMaskExtensionInstalled: boolean,
56
+ ): DetailContent =>
57
+ id === 'METAMASK' && !isMetaMaskExtensionInstalled ? 'metamask-qr' : getContentForConnector(id);
58
+
59
+ const getErrorCode = (error: unknown, depth = 0): number | undefined => {
60
+ if (depth > 4 || !error || typeof error !== 'object') return undefined;
61
+
62
+ const candidate = error as { code?: unknown; cause?: unknown; error?: unknown };
63
+ const code = Number(candidate.code);
64
+ if (Number.isFinite(code)) return code;
65
+
66
+ return getErrorCode(candidate.cause, depth + 1) ?? getErrorCode(candidate.error, depth + 1);
67
+ };
68
+
69
+ const isMetaMaskPendingRequestError = (error: unknown): boolean => getErrorCode(error) === -32002;
54
70
 
55
71
  export const ConnectDialog: FC<ConnectDialogProps> = ({
56
72
  open,
73
+ siteKey,
74
+ connectBannerUrl,
57
75
  theme = 'dark',
58
76
  connectors = ['CREDIT_CONNECT', 'METAMASK', 'WALLET_CONNECT'],
59
77
  selectedConnector: selectedConnectorProp = null,
@@ -86,10 +104,13 @@ export const ConnectDialog: FC<ConnectDialogProps> = ({
86
104
  * - 'wallet-qr' 개별 지갑 QR
87
105
  */
88
106
  const [content, setContent] = useState<DetailContent>('idle');
89
- const [showCopySnackbar, setShowCopySnackbar] = useState(false);
107
+ const [showCopyToast, setShowCopyToast] = useState(false);
108
+ const [showMetaMaskPendingRequestToast, setShowMetaMaskPendingRequestToast] = useState(false);
90
109
  const [qrUri, setQrUri] = useState<string | null>(null);
91
110
  // 외부 만료 신호 카운터 — CC(서버 push session_expired) / WC(connect reject) 수신 시 증가.
92
111
  const [expiredSignal, setExpiredSignal] = useState(0);
112
+ // EIP-6963 기반 MetaMask 확장 설치 상태.
113
+ const isMetaMaskExtensionInstalled = useMetaMaskExtension();
93
114
 
94
115
  // 현재 content 를 ref 로 추적 (effect deps 없이 최신값 참조용).
95
116
  const contentRef = useRef<DetailContent>(content);
@@ -98,10 +119,15 @@ export const ConnectDialog: FC<ConnectDialogProps> = ({
98
119
  const preTimeoutContentRef = useRef<DetailContent>('idle');
99
120
 
100
121
  const config = useConfig();
122
+ const { banner: connectBanner, loading: connectBannerLoading } = useConnectBanner({
123
+ open,
124
+ siteKey,
125
+ url: connectBannerUrl,
126
+ });
101
127
 
102
128
  // wagmi connect + display_uri 이벤트 → qrUri state.
103
129
  // 모든 wagmi connector 의 message 이벤트를 구독하므로 CC connector 의 display_uri emit 도 자동 포착.
104
- const { triggerConnect, cancelConnect } = useWagmiConnect({
130
+ const { triggerConnect, cancelConnect, isConnecting } = useWagmiConnect({
105
131
  onConnect: (result) => {
106
132
  // recent = 연결 성공 기준. wagmi 가 recentConnectorId 를 localStorage 에 저장하므로
107
133
  // UI state 만 동기화. 다음 마운트 시 아래 effect 가 storage 에서 복원.
@@ -109,10 +135,25 @@ export const ConnectDialog: FC<ConnectDialogProps> = ({
109
135
  onConnect?.(result);
110
136
  },
111
137
  onError: (err, connectorId) => {
112
- // WC: connect reject(사용자 거절 / 프로토콜 ~5분 만료 / 네트워크 등)는
113
- // 모두 timeout UI("Connection Failed")로 통일.
114
- if (connectorId === 'WALLET_CONNECT') {
138
+ // MetaMask는 pending 요청을 사전에 조회할 없어 요청의 -32002 응답으로만 판별한다.
139
+ if (
140
+ connectorId === 'METAMASK' &&
141
+ isMetaMaskExtensionInstalled &&
142
+ isMetaMaskPendingRequestError(err)
143
+ ) {
144
+ setShowMetaMaskPendingRequestToast(true);
145
+ }
146
+
147
+ // MM/WC/CC connect reject·실패는 타이머를 기다리지 않고 즉시 실패 화면으로 전환.
148
+ // (CC 는 서버 push 만료와 별개로, 지갑 앱에서 거절하면 connect promise 가 reject 된다.)
149
+ if (
150
+ connectorId === 'METAMASK' ||
151
+ connectorId === 'WALLET_CONNECT' ||
152
+ connectorId === 'CREDIT_CONNECT'
153
+ ) {
115
154
  setExpiredSignal((n) => n + 1);
155
+ } else {
156
+ connectorId satisfies never;
116
157
  }
117
158
  onError?.(err, connectorId);
118
159
  },
@@ -123,22 +164,45 @@ export const ConnectDialog: FC<ConnectDialogProps> = ({
123
164
 
124
165
  // 연결 타임아웃 (CC=서버 push / MM=client 타이머 / WC=connect reject) → timedOut.
125
166
  const timeoutActive = step === 'detail' && !!selectedConnector && content !== 'timeout';
126
- const { timedOut, retry } = useConnectTimeout({
167
+ const {
168
+ timedOut,
169
+ reset: resetTimeout,
170
+ retry,
171
+ } = useConnectTimeout({
127
172
  connectorId: selectedConnector,
128
173
  active: timeoutActive,
174
+ isConnecting,
129
175
  expiredSignal,
130
176
  onRetry: () => {
131
177
  if (!selectedConnector) return;
132
- cancelConnect();
133
- setQrUri(null);
134
178
  // 타임아웃 직전 화면으로 복원 (예: WC 의 wallet-qr → 선택 지갑 QR 유지).
135
179
  // idle/timeout 처럼 복원 불가한 값이면 커넥터 초기 화면으로.
136
180
  const restore = preTimeoutContentRef.current;
137
181
  const nextContent: DetailContent =
138
182
  restore !== 'idle' && restore !== 'timeout'
139
183
  ? restore
140
- : getInitialContentForConnector(selectedConnector);
184
+ : getInitialContentForConnector(selectedConnector, isMetaMaskExtensionInstalled);
185
+
186
+ // MetaMask 확장 pending 요청은 dApp에서 종료하거나 다시 요청할 수 없다.
187
+ // 실패 화면을 유지하고 기존 요청을 먼저 처리하도록 안내한다.
188
+ if (selectedConnector === 'METAMASK' && restore === 'metamask-ext' && isConnecting) {
189
+ setContent('timeout');
190
+ setShowMetaMaskPendingRequestToast(true);
191
+ return;
192
+ }
193
+
141
194
  setContent(nextContent);
195
+ setQrUri(null);
196
+
197
+ // 재시도는 채널을 파괴하지 않는다.
198
+ // kit 의 타임아웃(DEFAULT_CONNECT_TIMEOUT_MS)은 '오래 걸린다'는 UI 신호일 뿐이고,
199
+ // 그 시점에 커넥터의 세션은 대체로 아직 유효하다. MM 의 경우 SDK 가 저장된 채널을
200
+ // validUntil 까지 재사용하므로(originatorSessionConnect) 같은 QR/URI 가 다시 나오는데,
201
+ // 채널이 살아있으니 스캔·딥링크·Open app 이 모두 정상 동작한다.
202
+ // 반대로 sdk.terminate() 로 채널을 지우면, 이전 시도가 미완료라 SDK 가 startConnection
203
+ // 을 타지 못하고 캐시된 옛 qrcodeLink 를 재방출한다 → 죽은 채널의 QR 이 그려진다.
204
+ // 그래서 여기서는 평범한 재시도만 한다. CC/WC 는 커넥터가 매 connect 마다 새 세션을 만든다.
205
+ cancelConnect();
142
206
  triggerConnect(selectedConnector);
143
207
  },
144
208
  });
@@ -146,18 +210,44 @@ export const ConnectDialog: FC<ConnectDialogProps> = ({
146
210
  // 타임아웃 발생 → 진행 중 연결 취소 + timeout 화면 표시.
147
211
  // 전환 전 화면을 저장해 retry 시 복원 (WC wallet-qr 등).
148
212
  useEffect(() => {
149
- if (!timedOut) return;
213
+ // selector로 돌아간 렌더에 이전 timedOut 잠시 남아도 실패 화면을 다시 덮지 않는다.
214
+ if (!timedOut || !selectedConnector || step !== 'detail') return;
215
+ // cancelConnect로 isConnecting이 바뀌어 effect가 다시 실행되어도
216
+ // 기록한 이전 화면을 timeout으로 덮어쓰지 않는다.
217
+ if (contentRef.current === 'timeout') return;
150
218
  preTimeoutContentRef.current = contentRef.current;
151
- cancelConnect();
219
+
220
+ // MetaMask 확장 pending 요청만 보존한다. QR/Deeplink는 Retry에서 SDK를 재시작한다.
221
+ const shouldPreserveMetaMaskRequest =
222
+ selectedConnector === 'METAMASK' && contentRef.current === 'metamask-ext' && isConnecting;
223
+ // 확장 승인 요청은 dApp에서 종료할 수 없으므로 원래 요청을 유지한다.
224
+ if (!shouldPreserveMetaMaskRequest) {
225
+ cancelConnect();
226
+ }
152
227
  setContent('timeout');
153
- }, [timedOut, cancelConnect]);
228
+ }, [timedOut, selectedConnector, step, isConnecting, cancelConnect]);
229
+
230
+ // provider 응답이 늦게 도착하면 QR에서 확장 연결 화면으로 전환.
231
+ useEffect(() => {
232
+ if (
233
+ isMetaMaskExtensionInstalled &&
234
+ selectedConnector === 'METAMASK' &&
235
+ content === 'metamask-qr'
236
+ ) {
237
+ setContent('metamask-ext');
238
+ }
239
+ }, [isMetaMaskExtensionInstalled, selectedConnector, content]);
154
240
 
155
241
  // 마운트 시 wagmi storage 의 recentConnectorId(연결 성공 시 wagmi 가 저장) 복원.
156
242
  useEffect(() => {
157
243
  const restoreRecent = async (): Promise<void> => {
158
- const wagmiId = await config.storage?.getItem('recentConnectorId');
159
- const resolved = resolveConnectorId(wagmiId ?? undefined);
160
- if (resolved) setRecentConnector(resolved);
244
+ try {
245
+ const wagmiId = await config.storage?.getItem('recentConnectorId');
246
+ const resolved = resolveConnectorId(wagmiId ?? undefined);
247
+ if (resolved) setRecentConnector(resolved);
248
+ } catch {
249
+ // 무시 — 실패해도 진행한다.
250
+ }
161
251
  };
162
252
  restoreRecent().catch(() => undefined);
163
253
  }, [config]);
@@ -166,9 +256,16 @@ export const ConnectDialog: FC<ConnectDialogProps> = ({
166
256
  const wc = useWCState();
167
257
 
168
258
  const handleSelect = (id: ConnectorId) => {
259
+ // 다른 지갑 선택은 이전 시도와 실패 상태를 이어받지 않고 항상 새 연결로 시작한다.
260
+ cancelConnect();
261
+ resetTimeout();
262
+ setQrUri(null);
263
+ setSelectedWallet(null);
264
+ setShowCopyToast(false);
265
+ setShowMetaMaskPendingRequestToast(false);
169
266
  setSelectedConnector(id);
170
267
  // recent 는 클릭이 아니라 연결 성공(onConnect) 시점에 기록 — 위 useWagmiConnect onConnect 참조.
171
- setContent(getInitialContentForConnector(id));
268
+ setContent(getInitialContentForConnector(id, isMetaMaskExtensionInstalled));
172
269
  setStep('detail');
173
270
  // wagmi connect 시작 (display_uri 도착 시 setQrUri 자동 호출)
174
271
  triggerConnect(id);
@@ -218,37 +315,73 @@ export const ConnectDialog: FC<ConnectDialogProps> = ({
218
315
  }, [open]);
219
316
 
220
317
  // selectedWallet 가 먼저 set 됐고 qrUri 가 뒤늦게 도착한 경우 자동 deeplink.
221
- // handleSelectWallet inline 호출과 중복 방지를 위해 deeplinkTriedRef 가드.
222
- const deeplinkTriedRef = useRef(false);
318
+ // 자동 deeplink best-effort 횟수 제한을 두지 않는다.
319
+ // iOS WebKit 사용자 제스처 없는 커스텀 스킴 이동을 억제한다. display_uri 는 네트워크
320
+ // 왕복 뒤에 도착하므로 지갑 탭의 제스처 활성 창(수백 ms)을 이미 넘긴 상태이고,
321
+ // 따라서 이 시도는 브라우저가 허용할 때만 통과한다. dApp 이 제어할 수 있는 영역이 아니다.
322
+ // 막힌 경우의 확실한 경로는 'Open app' 버튼(제스처) 이다.
223
323
  useEffect(() => {
224
- deeplinkTriedRef.current = false;
225
- }, [selectedWallet]);
226
- useEffect(() => {
227
- if (deeplinkTriedRef.current) return;
228
324
  if (!selectedWallet || !qrUri || content !== 'wallet-qr') return;
229
325
  if (!isMobileDevice()) return;
230
326
  const base = selectedWallet.mobileNative || selectedWallet.mobileUniversal;
231
327
  if (!base) return;
232
- deeplinkTriedRef.current = true;
233
328
  tryOpenDeepLink(qrUri, base);
234
329
  }, [selectedWallet, qrUri, content]);
235
330
 
236
331
  // CC connector direct path: selector 의 'Continue with Credit Wallet' → content='qr'.
237
- // wagmi MM connector 는 SDK 가 자체 deeplink 하지만 CC connector 는 emit display_uri 만 함 —
238
- // kit 가 mobile redirect 책임. payload 는 full URL (`https://app.creditcoin.org/?creditconnect=...`).
239
- const creditConnectDeeplinkTriedRef = useRef(false);
240
- useEffect(() => {
241
- creditConnectDeeplinkTriedRef.current = false;
242
- }, [selectedConnector]);
243
332
  useEffect(() => {
244
- if (creditConnectDeeplinkTriedRef.current) return;
245
333
  if (content !== 'qr' || !qrUri) return;
246
334
  if (selectedConnector !== 'CREDIT_CONNECT') return;
247
335
  if (!isMobileDevice()) return;
248
- creditConnectDeeplinkTriedRef.current = true;
249
336
  tryOpenCreditWalletDeeplink(qrUri);
250
337
  }, [content, qrUri, selectedConnector]);
251
338
 
339
+ // MetaMask 는 기본적으로 SDK 가 앱을 연다 (core/config.ts 의 openDeeplink 콜백).
340
+ // 그런데 SDK 는 취소된 connect 가 남아있는 동안(initializationOngoing) 그 콜백을 호출하지 않는다.
341
+ // 그 래치를 dApp 이 강제로 풀 방법은 커넥터에 'rejected' 를 쏘는 것뿐인데, 그러면 SDK 가
342
+ // 4001 경로를 타며 handleDisconnect() 로 provider._state.isConnected 를 false 로 내리고,
343
+ // 이후 switchChain 등 모든 RPC 의 딥링크 리다이렉트가 write() 의 isMetaMaskInstalled()
344
+ // 가드에서 조용히 스킵된다. 그래서 SDK 상태는 건드리지 않는다.
345
+ // 대신 SDK 가 열지 않는 시도에서는 kit 이 직접 연다. 재방출되는 qrUri 는 terminate 를 하지
346
+ // 않는 한 살아있는 채널이므로('Open app' 이 같은 URI 로 정상 동작하는 것이 근거) 유효하다.
347
+ // 이중 발사는 deeplinkSignal 이 막는다 — connect 시도당 1회.
348
+ useEffect(() => {
349
+ if (content !== 'metamask-qr' || !qrUri) return;
350
+ if (selectedConnector !== 'METAMASK') return;
351
+ if (!isMobileDevice()) return;
352
+ if (hasDeeplinkOpened()) {
353
+ return;
354
+ }
355
+ const base = CONNECTOR_META.METAMASK.deepLinkBase;
356
+ if (!base) return;
357
+ markDeeplinkOpened();
358
+ tryOpenDeepLink(qrUri, base);
359
+ }, [content, qrUri, selectedConnector]);
360
+
361
+ // 'Open app' 버튼 — 사용자 제스처이므로 iOS 의 비제스처 이동 억제에 걸리지 않는다.
362
+ // 자동 시도가 막혔을 때의 확실한 경로.
363
+ const handleOpenApp = (): void => {
364
+ if (!qrUri) return;
365
+
366
+ if (content === 'qr' && selectedConnector === 'CREDIT_CONNECT') {
367
+ tryOpenCreditWalletDeeplink(qrUri);
368
+ return;
369
+ }
370
+
371
+ if (content === 'metamask-qr' && selectedConnector === 'METAMASK') {
372
+ const base = CONNECTOR_META.METAMASK.deepLinkBase;
373
+ if (!base) return;
374
+ tryOpenDeepLink(qrUri, base);
375
+ return;
376
+ }
377
+
378
+ if (content === 'wallet-qr' && selectedWallet) {
379
+ const base = selectedWallet.mobileNative || selectedWallet.mobileUniversal;
380
+ if (!base) return;
381
+ tryOpenDeepLink(qrUri, base);
382
+ }
383
+ };
384
+
252
385
  // Prevent background scroll while dialog is open
253
386
  useEffect(() => {
254
387
  const prev = document.body.style.overflow;
@@ -266,7 +399,8 @@ export const ConnectDialog: FC<ConnectDialogProps> = ({
266
399
  setSelectedConnector(null);
267
400
  setContent('idle');
268
401
  setStep('selector');
269
- setShowCopySnackbar(false);
402
+ setShowCopyToast(false);
403
+ setShowMetaMaskPendingRequestToast(false);
270
404
  }
271
405
  }, [open, cancelConnect]);
272
406
 
@@ -276,7 +410,8 @@ export const ConnectDialog: FC<ConnectDialogProps> = ({
276
410
  if (!dialog) return undefined;
277
411
  const handleCancel = (e: Event) => {
278
412
  e.preventDefault();
279
- setShowCopySnackbar(false);
413
+ setShowCopyToast(false);
414
+ setShowMetaMaskPendingRequestToast(false);
280
415
  onClose();
281
416
  };
282
417
  dialog.addEventListener('cancel', handleCancel);
@@ -302,6 +437,8 @@ export const ConnectDialog: FC<ConnectDialogProps> = ({
302
437
  onClose={onClose}
303
438
  />
304
439
  <DetailPanel
440
+ connectBanner={connectBanner}
441
+ connectBannerLoading={connectBannerLoading}
305
442
  selectedConnector={selectedConnector}
306
443
  selectedWallet={selectedWallet}
307
444
  content={content}
@@ -310,6 +447,7 @@ export const ConnectDialog: FC<ConnectDialogProps> = ({
310
447
  walletListLoading={wc.walletListLoading}
311
448
  walletCount={wc.walletList.length}
312
449
  onBack={handleBack}
450
+ onOpenApp={handleOpenApp}
313
451
  onClose={onClose}
314
452
  onRetry={retry}
315
453
  onShowAllWallets={handleShowAllWallets}
@@ -319,16 +457,27 @@ export const ConnectDialog: FC<ConnectDialogProps> = ({
319
457
  if (!qrUri) return;
320
458
  navigator.clipboard
321
459
  .writeText(qrUri)
322
- .then(() => setShowCopySnackbar(true))
460
+ .then(() => {
461
+ setShowCopyToast(true);
462
+ })
323
463
  .catch((err) => {
324
464
  // eslint-disable-next-line no-console
325
465
  console.warn('[copyLink] clipboard write failed', err);
326
466
  });
327
467
  }}
328
468
  />
329
- {showCopySnackbar && (
330
- <div className="ck-dialog-snackbar">
331
- <Snackbar message="Link copied" onClose={() => setShowCopySnackbar(false)} />
469
+ {(showCopyToast || showMetaMaskPendingRequestToast) && (
470
+ <div className="ck-toast-stack">
471
+ {showCopyToast && (
472
+ <Toast message="Link copied" onClose={() => setShowCopyToast(false)} />
473
+ )}
474
+ {showMetaMaskPendingRequestToast && (
475
+ <Toast
476
+ variant="warning"
477
+ message="Clear the pending request in your wallet first."
478
+ onClose={() => setShowMetaMaskPendingRequestToast(false)}
479
+ />
480
+ )}
332
481
  </div>
333
482
  )}
334
483
  </dialog>
@@ -12,6 +12,7 @@ interface MetaMaskProps {
12
12
  logoUrl?: string;
13
13
  downloadUrl?: string;
14
14
  onCopy?: () => void;
15
+ onOpenApp?: () => void;
15
16
  }
16
17
 
17
18
  export const MetaMask: FC<MetaMaskProps> = ({
@@ -19,7 +20,7 @@ export const MetaMask: FC<MetaMaskProps> = ({
19
20
  uri,
20
21
  logoUrl,
21
22
  downloadUrl = '',
22
- onCopy,
23
+ onOpenApp,
23
24
  }) => {
24
25
  if (variant === 'extension') {
25
26
  return (
@@ -46,7 +47,7 @@ export const MetaMask: FC<MetaMaskProps> = ({
46
47
  logoUrl={logoUrl}
47
48
  hasBg
48
49
  label="Scan this QR Code with your phone"
49
- onCopy={onCopy}
50
+ onOpenApp={onOpenApp}
50
51
  />
51
52
  <ActionItem
52
53
  as="a"
@@ -6,9 +6,10 @@ interface QRProps {
6
6
  uri?: string | null;
7
7
  logoUrl?: string;
8
8
  downloadUrl?: string;
9
+ onOpenApp?: () => void;
9
10
  }
10
11
 
11
- export const QR: FC<QRProps> = ({ uri, logoUrl, downloadUrl }) => {
12
+ export const QR: FC<QRProps> = ({ uri, logoUrl, downloadUrl, onOpenApp }) => {
12
13
  const status = !uri ? 'loading' : 'ready';
13
14
 
14
15
  return (
@@ -18,6 +19,7 @@ export const QR: FC<QRProps> = ({ uri, logoUrl, downloadUrl }) => {
18
19
  status={status}
19
20
  logoUrl={logoUrl}
20
21
  label="Open the PenguinWallet app and scan the QR"
22
+ onOpenApp={onOpenApp}
21
23
  />
22
24
  <ActionItem
23
25
  as="a"
@@ -37,59 +37,12 @@
37
37
  width: 100%;
38
38
  max-width: none;
39
39
  grid-template-columns: 1fr;
40
+ grid-template-rows: minmax(0, 1fr);
40
41
  max-height: 82dvh;
41
- height: 100%;
42
+ height: auto;
42
43
  border-radius: 12px 12px 0 0;
43
44
 
44
45
  &[data-step='selector'] .ck-detail { display: none !important; }
45
46
  &[data-step='detail'] .ck-selector { display: none !important; }
46
47
  }
47
48
  }
48
-
49
- .ck-root .ck-dialog-snackbar {
50
- position: fixed;
51
- top: clamp(20px, 9.2vh, 99px);
52
- left: 50%;
53
- z-index: 1001;
54
- width: min(calc(100vw - 40px), 400px);
55
- transform: translateX(-50%);
56
- animation: ck-dialog-snackbar-in 0.2s ease;
57
-
58
- @include tablet {
59
- top: clamp(
60
- calc(env(safe-area-inset-top, 0px) + 20px),
61
- calc(env(safe-area-inset-top, 0px) + 20px),
62
- calc(env(safe-area-inset-top, 0px) + 20px)
63
- );
64
- left: 20px;
65
- right: 20px;
66
- width: auto;
67
- transform: none;
68
- }
69
- }
70
-
71
- @keyframes ck-dialog-snackbar-in {
72
- from {
73
- opacity: 0;
74
- transform: translateX(-50%) translateY(8px);
75
- }
76
-
77
- to {
78
- opacity: 1;
79
- transform: translateX(-50%) translateY(0);
80
- }
81
- }
82
-
83
- @include tablet {
84
- @keyframes ck-dialog-snackbar-in {
85
- from {
86
- opacity: 0;
87
- transform: translateY(8px);
88
- }
89
-
90
- to {
91
- opacity: 1;
92
- transform: translateY(0);
93
- }
94
- }
95
- }
@@ -10,11 +10,6 @@ export const Timeout: FC<TimeoutProps> = ({ onRetry }) => {
10
10
  <div className="ck-timeout">
11
11
  <div className="ck-timeout-text">
12
12
  <h3 className="ck-timeout-title">Connection Failed</h3>
13
- <p className="ck-timeout-desc">
14
- The connection took too long to respond.
15
- <br />
16
- Please check your network and try again.
17
- </p>
18
13
  </div>
19
14
  <button type="button" className="ck-timeout-retry-btn" onClick={onRetry}>
20
15
  Try Again
@@ -21,11 +21,6 @@
21
21
  color: var(--scale-gray-900);
22
22
  }
23
23
 
24
- .ck-timeout-desc {
25
- @include textToken('body4-l1-regular');
26
- color: var(--scale-gray-600);
27
- }
28
-
29
24
  .ck-root .ck-timeout-retry-btn {
30
25
  display: inline-flex;
31
26
  align-items: center;
@@ -7,9 +7,10 @@ interface WalletQRProps {
7
7
  wallet: WCWallet;
8
8
  uri?: string | null;
9
9
  onCopy?: () => void;
10
+ onOpenApp?: () => void;
10
11
  }
11
12
 
12
- export const WalletQR: FC<WalletQRProps> = ({ wallet, uri = null, onCopy }) => {
13
+ export const WalletQR: FC<WalletQRProps> = ({ wallet, uri = null, onCopy, onOpenApp }) => {
13
14
  const downloadUrl = wallet.appIos || wallet.appAndroid || wallet.homepage;
14
15
 
15
16
  return (
@@ -21,6 +22,7 @@ export const WalletQR: FC<WalletQRProps> = ({ wallet, uri = null, onCopy }) => {
21
22
  hasBg
22
23
  label={`Scan with ${wallet.name}`}
23
24
  onCopy={onCopy}
25
+ onOpenApp={onOpenApp}
24
26
  />
25
27
  {downloadUrl && (
26
28
  <ActionItem
@@ -2,7 +2,7 @@ import { type FC } from 'react';
2
2
  import { CloseIcon } from '../connectDialog/asset/closeIcon';
3
3
  import { BackIcon } from '../connectDialog/asset/backIcon';
4
4
  import './style.scss';
5
- import { type ConnectorId, type WCWallet } from '../../types';
5
+ import { type ConnectBanner, type ConnectorId, type WCWallet } from '../../types';
6
6
  import { CONNECTOR_META } from '../../connector-meta';
7
7
  import { Idle } from '../connectDialog/idle';
8
8
  import { Loading } from '../connectDialog/loading';
@@ -25,6 +25,8 @@ export type DetailContent =
25
25
  | 'wallet-qr';
26
26
 
27
27
  interface DetailPanelProps {
28
+ connectBanner?: ConnectBanner | null;
29
+ connectBannerLoading?: boolean;
28
30
  selectedConnector: ConnectorId | null;
29
31
  selectedWallet?: WCWallet | null;
30
32
  content?: DetailContent;
@@ -40,6 +42,8 @@ interface DetailPanelProps {
40
42
  onShowAllWallets?: () => void;
41
43
  onSelectWallet?: (wallet: WCWallet) => void;
42
44
  onCopyLink?: () => void;
45
+ /** 모바일 QR 화면의 'Open app' 버튼 — 해당 지갑 앱을 deeplink 로 연다. */
46
+ onOpenApp?: () => void;
43
47
  /** timeout 화면의 "Try Again" 버튼 핸들러. */
44
48
  onRetry?: () => void;
45
49
  }
@@ -68,6 +72,8 @@ const getTitle = (content: DetailContent, connector: ConnectorId | null): string
68
72
  };
69
73
 
70
74
  export const DetailPanel: FC<DetailPanelProps> = ({
75
+ connectBanner = null,
76
+ connectBannerLoading = false,
71
77
  selectedConnector,
72
78
  selectedWallet = null,
73
79
  content = 'idle',
@@ -80,6 +86,7 @@ export const DetailPanel: FC<DetailPanelProps> = ({
80
86
  onShowAllWallets,
81
87
  onSelectWallet,
82
88
  onCopyLink,
89
+ onOpenApp,
83
90
  onRetry,
84
91
  }) => {
85
92
  // CONNECTOR_META 에서 활성 connector 의 logo / downloadUrl 가져옴.
@@ -91,6 +98,10 @@ export const DetailPanel: FC<DetailPanelProps> = ({
91
98
  ? `Scan with ${selectedWallet.name}`
92
99
  : getTitle(content, selectedConnector);
93
100
  const showBack = content !== 'idle';
101
+ const closeColor =
102
+ content === 'idle' && !connectBannerLoading
103
+ ? (connectBanner?.closeColor ?? '#FFFFFF')
104
+ : undefined;
94
105
 
95
106
  return (
96
107
  <section className="ck-detail">
@@ -105,15 +116,37 @@ export const DetailPanel: FC<DetailPanelProps> = ({
105
116
  ) : (
106
117
  <span className="ck-detail-header-spacer" aria-hidden="true" />
107
118
  )}
108
- <button type="button" className="ck-btn-close" onClick={onClose} aria-label="Close">
119
+ <button
120
+ type="button"
121
+ className="ck-btn-close"
122
+ onClick={onClose}
123
+ aria-label="Close"
124
+ style={{ color: closeColor }}
125
+ >
109
126
  <CloseIcon />
110
127
  </button>
111
128
  </header>
112
129
  <div className="ck-detail-body">
113
- {content === 'idle' && <Idle />}
130
+ {content === 'idle' && !connectBannerLoading && (
131
+ <Idle
132
+ title={connectBanner?.title}
133
+ description={connectBanner?.subtitle}
134
+ linkText={connectBanner?.linkText}
135
+ linkUrl={connectBanner?.linkUrl}
136
+ imageUrl={connectBanner?.imageUrl}
137
+ titleColor={connectBanner?.titleColor}
138
+ descriptionColor={connectBanner?.subtitleColor}
139
+ linkTextColor={connectBanner?.linkTextColor}
140
+ />
141
+ )}
114
142
  {content === 'loading' && <Loading />}
115
143
  {content === 'qr' && (
116
- <QR uri={qrUri} logoUrl={connectorLogoUrl} downloadUrl={connectorDownloadUrl} />
144
+ <QR
145
+ uri={qrUri}
146
+ logoUrl={connectorLogoUrl}
147
+ downloadUrl={connectorDownloadUrl}
148
+ onOpenApp={onOpenApp}
149
+ />
117
150
  )}
118
151
  {content === 'timeout' && <Timeout onRetry={onRetry} />}
119
152
  {(content === 'metamask-ext' || content === 'metamask-qr') && (
@@ -122,7 +155,7 @@ export const DetailPanel: FC<DetailPanelProps> = ({
122
155
  logoUrl={connectorLogoUrl}
123
156
  uri={qrUri}
124
157
  downloadUrl={connectorDownloadUrl}
125
- onCopy={onCopyLink}
158
+ onOpenApp={onOpenApp}
126
159
  />
127
160
  )}
128
161
  {content === 'walletconnect' && (
@@ -142,7 +175,7 @@ export const DetailPanel: FC<DetailPanelProps> = ({
142
175
  />
143
176
  )}
144
177
  {content === 'wallet-qr' && selectedWallet && (
145
- <WalletQR wallet={selectedWallet} uri={qrUri} onCopy={onCopyLink} />
178
+ <WalletQR wallet={selectedWallet} uri={qrUri} onCopy={onCopyLink} onOpenApp={onOpenApp} />
146
179
  )}
147
180
  </div>
148
181
  </section>