@gluwa/connect-kit 0.1.0-next.3 → 0.2.0-next.10

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 (104) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/assets/fonts/Inter-Medium.ttf +0 -0
  3. package/assets/fonts/Inter-Medium.woff2 +0 -0
  4. package/assets/fonts/Inter-Regular.ttf +0 -0
  5. package/assets/fonts/Inter-Regular.woff2 +0 -0
  6. package/assets/fonts/Inter-SemiBold.ttf +0 -0
  7. package/assets/fonts/Inter-SemiBold.woff2 +0 -0
  8. package/assets/graphic.png +0 -0
  9. package/assets/metamask.png +0 -0
  10. package/assets/penguinwallet.png +0 -0
  11. package/assets/wc-logo.svg +3 -0
  12. package/assets/wc-search.png +0 -0
  13. package/dist/chunk-VE5LB7BN.js +405 -0
  14. package/dist/credit-connect.js +2 -394
  15. package/dist/index.css +2075 -0
  16. package/dist/index.d.ts +58 -33
  17. package/dist/index.js +1748 -1503
  18. package/dist/package.json +16 -10
  19. package/package.json +14 -11
  20. package/src/ConnectKitProvider.tsx +133 -75
  21. package/src/api/account.ts +21 -0
  22. package/src/api/asset.ts +10 -0
  23. package/src/api/balance.ts +10 -0
  24. package/src/api/chain.ts +17 -0
  25. package/src/api/contract.ts +33 -0
  26. package/src/api/transaction.ts +12 -0
  27. package/src/assets.d.ts +5 -0
  28. package/src/components/actionItem/index.tsx +50 -0
  29. package/src/components/actionItem/style.scss +47 -0
  30. package/src/components/copyLink/index.tsx +16 -0
  31. package/src/components/copyLink/style.scss +16 -0
  32. package/src/components/qrArea/index.tsx +72 -0
  33. package/src/components/qrArea/style.scss +122 -0
  34. package/src/components/searchBar/asset/Search.tsx +20 -0
  35. package/src/components/searchBar/asset/XMarkCircle.tsx +24 -0
  36. package/src/components/searchBar/index.tsx +44 -0
  37. package/src/components/searchBar/style.scss +58 -0
  38. package/src/components/toast/index.tsx +41 -0
  39. package/src/components/toast/style.scss +108 -0
  40. package/src/components/toggle/index.tsx +27 -0
  41. package/src/components/toggle/style.scss +53 -0
  42. package/src/components/walletGrid/index.tsx +64 -0
  43. package/src/components/walletGrid/style.scss +108 -0
  44. package/src/components/walletItem/index.tsx +49 -0
  45. package/src/components/walletItem/style.scss +70 -0
  46. package/src/connector-meta.ts +16 -11
  47. package/src/core/config.ts +102 -0
  48. package/src/creditConnectConnector.ts +198 -184
  49. package/src/events/account.ts +76 -0
  50. package/src/hooks/useConnectBanner.ts +79 -0
  51. package/src/hooks/useConnectTimeout.ts +92 -0
  52. package/src/hooks/useMetaMaskExtension.ts +29 -0
  53. package/src/hooks/useWCState.ts +45 -14
  54. package/src/hooks/useWagmiConnect.ts +42 -11
  55. package/src/index.ts +38 -2
  56. package/src/layout/allWallets/index.tsx +52 -0
  57. package/src/layout/allWallets/style.scss +57 -0
  58. package/src/layout/connectDialog/asset/backIcon.tsx +24 -0
  59. package/src/layout/connectDialog/asset/checkIcon.tsx +28 -0
  60. package/src/layout/connectDialog/asset/closeIcon.tsx +21 -0
  61. package/src/layout/connectDialog/asset/copyIcon.tsx +28 -0
  62. package/src/layout/connectDialog/asset/linkIcon.tsx +24 -0
  63. package/src/layout/connectDialog/asset/spinner.tsx +21 -0
  64. package/src/layout/connectDialog/asset/warningIcon.tsx +21 -0
  65. package/src/layout/connectDialog/idle/assets/banner-placeholder.png +0 -0
  66. package/src/layout/connectDialog/idle/index.tsx +55 -0
  67. package/src/layout/connectDialog/idle/style.scss +52 -0
  68. package/src/layout/connectDialog/index.tsx +432 -0
  69. package/src/layout/connectDialog/loading/index.tsx +13 -0
  70. package/src/layout/connectDialog/loading/style.scss +19 -0
  71. package/src/layout/connectDialog/metaMask/index.tsx +61 -0
  72. package/src/layout/connectDialog/metaMask/style.scss +65 -0
  73. package/src/layout/connectDialog/qr/index.tsx +33 -0
  74. package/src/layout/connectDialog/style.scss +48 -0
  75. package/src/layout/connectDialog/timeout/index.tsx +31 -0
  76. package/src/layout/connectDialog/timeout/style.scss +64 -0
  77. package/src/layout/connectDialog/walletConnect/index.tsx +45 -0
  78. package/src/layout/connectDialog/walletConnect/style.scss +48 -0
  79. package/src/layout/connectDialog/walletQR/index.tsx +37 -0
  80. package/src/layout/detailPanel/index.tsx +175 -0
  81. package/src/layout/detailPanel/style.scss +70 -0
  82. package/src/layout/selectorPanel/index.tsx +100 -0
  83. package/src/layout/selectorPanel/style.scss +113 -0
  84. package/src/style/color.scss +417 -0
  85. package/src/style/font.scss +28 -0
  86. package/src/style/index.scss +5 -0
  87. package/src/style/mixin.scss +217 -0
  88. package/src/style/reset.scss +110 -0
  89. package/src/style/variables.scss +13 -0
  90. package/src/types.ts +40 -7
  91. package/src/utils/platform.ts +6 -7
  92. package/src/wallet-display-override.ts +15 -0
  93. package/tsconfig.json +5 -1
  94. package/tsup.config.ts +32 -2
  95. package/assets/creditwallet.png +0 -0
  96. package/dist/chunk-DDA6FP6U.js +0 -7
  97. package/src/ConnectModal.scss +0 -665
  98. package/src/ConnectModal.tsx +0 -559
  99. package/src/components/QRFrame.tsx +0 -43
  100. package/src/views/CreditWalletView.tsx +0 -59
  101. package/src/views/IdleView.tsx +0 -10
  102. package/src/views/MetaMaskView.tsx +0 -64
  103. package/src/views/SwitchChainView.tsx +0 -67
  104. package/src/views/WalletConnectView.tsx +0 -206
@@ -0,0 +1,21 @@
1
+ import { type FC, type SVGProps } from 'react';
2
+
3
+ export const Spinner: FC<SVGProps<SVGSVGElement>> = (props) => {
4
+ return (
5
+ <svg
6
+ width={40}
7
+ height={40}
8
+ viewBox="0 0 40 40"
9
+ fill="none"
10
+ xmlns="http://www.w3.org/2000/svg"
11
+ aria-hidden="true"
12
+ focusable="false"
13
+ {...props}
14
+ >
15
+ <path
16
+ d="M19.995 3.34375C20.9155 3.34375 21.6617 4.08995 21.6617 5.01042V10.0104C21.6617 10.9309 20.9155 11.6771 19.995 11.6771C19.0745 11.6771 18.3284 10.9309 18.3284 10.0104V5.01042C18.3284 4.08995 19.0745 3.34375 19.995 3.34375ZM19.995 28.3438C20.9155 28.3438 21.6617 29.0899 21.6617 30.0104V35.0104C21.6617 35.9309 20.9155 36.6771 19.995 36.6771C19.0745 36.6771 18.3284 35.9309 18.3284 35.0104V30.0104C18.3284 29.0899 19.0745 28.3438 19.995 28.3438ZM36.6617 20.0104C36.6617 20.9309 35.9155 21.6771 34.995 21.6771H29.995C29.0745 21.6771 28.3284 20.9309 28.3284 20.0104C28.3284 19.0899 29.0745 18.3438 29.995 18.3438H34.995C35.9155 18.3438 36.6617 19.0899 36.6617 20.0104ZM11.6617 20.0104C11.6617 20.9309 10.9155 21.6771 9.99504 21.6771H4.99504C4.07457 21.6771 3.32837 20.9309 3.32837 20.0104C3.32837 19.0899 4.07457 18.3438 4.99504 18.3438H9.99504C10.9155 18.3438 11.6617 19.0899 11.6617 20.0104ZM31.7802 31.7956C31.1292 32.4464 30.074 32.4464 29.4232 31.7956L25.8875 28.2599C25.2367 27.6091 25.2367 26.5539 25.8875 25.9029C26.5385 25.2521 27.5937 25.2521 28.2445 25.9029L31.7802 29.4386C32.431 30.0894 32.431 31.1446 31.7802 31.7956ZM14.1025 14.1179C13.4516 14.7687 12.3963 14.7687 11.7455 14.1179L8.20992 10.5823C7.55905 9.93145 7.55905 8.87618 8.20992 8.2253C8.8608 7.57443 9.91607 7.57443 10.567 8.2253L14.1025 11.7608C14.7534 12.4117 14.7534 13.467 14.1025 14.1179ZM8.20992 31.7956C7.55905 31.1446 7.55905 30.0894 8.20992 29.4386L11.7455 25.9029C12.3963 25.2521 13.4516 25.2521 14.1025 25.9029C14.7534 26.5539 14.7534 27.6091 14.1025 28.2599L10.567 31.7956C9.91607 32.4464 8.8608 32.4464 8.20992 31.7956ZM25.8875 14.1179C25.2367 13.467 25.2367 12.4117 25.8875 11.7608L29.4232 8.2253C30.074 7.57443 31.1292 7.57443 31.7802 8.2253C32.431 8.87618 32.431 9.93145 31.7802 10.5823L28.2445 14.1179C27.5937 14.7687 26.5385 14.7687 25.8875 14.1179Z"
17
+ fill="currentColor"
18
+ />
19
+ </svg>
20
+ );
21
+ };
@@ -0,0 +1,21 @@
1
+ import { type FC, type SVGProps } from 'react';
2
+
3
+ export const WarningIcon: FC<SVGProps<SVGSVGElement>> = (props) => {
4
+ return (
5
+ <svg
6
+ width={20}
7
+ height={18}
8
+ viewBox="0 0 20 18"
9
+ fill="none"
10
+ xmlns="http://www.w3.org/2000/svg"
11
+ aria-hidden="true"
12
+ focusable="false"
13
+ {...props}
14
+ >
15
+ <path
16
+ d="M9.05688 0.833252C9.47596 0.107398 10.5234 0.107541 10.9426 0.833252L19.6663 15.9417C20.0852 16.6675 19.561 17.5744 18.7229 17.5745H1.27759C0.43938 17.5745 -0.0848319 16.6676 0.334229 15.9417L9.05688 0.833252ZM9.26685 13.0491V14.5149H10.7327V13.0491H9.26685ZM9.26685 6.65747V12.1018H10.7327V6.65747H9.26685Z"
17
+ fill="currentColor"
18
+ />
19
+ </svg>
20
+ );
21
+ };
@@ -0,0 +1,55 @@
1
+ import { type FC } from 'react';
2
+ import { LinkIcon } from '../asset/linkIcon';
3
+ import placeholderImage from './assets/banner-placeholder.png';
4
+ import './style.scss';
5
+
6
+ interface IdleProps {
7
+ title?: string;
8
+ description?: string;
9
+ linkText?: string;
10
+ linkUrl?: string;
11
+ imageUrl?: string;
12
+ titleColor?: string;
13
+ descriptionColor?: string;
14
+ linkTextColor?: string;
15
+ }
16
+
17
+ export const Idle: FC<IdleProps> = ({
18
+ title = 'PenguinWallet',
19
+ description = `Your non-custodial gateway to the Creditcoin network.
20
+ Hold tokens, swap, and play ecosystem games in one app.`,
21
+ linkText = 'Download',
22
+ linkUrl = 'https://creditcoin.org/Penguin-Wallet',
23
+ imageUrl = placeholderImage,
24
+ titleColor = '#FFFFFF',
25
+ descriptionColor = '#888A8C',
26
+ linkTextColor = '#3374FF',
27
+ }) => {
28
+ return (
29
+ <div className="ck-idle">
30
+ <img className="ck-idle-bg" src={imageUrl} alt="" />
31
+ {title && (
32
+ <h3 className="ck-idle-title" style={{ color: titleColor }}>
33
+ {title}
34
+ </h3>
35
+ )}
36
+ {description && (
37
+ <p className="ck-idle-desc" style={{ color: descriptionColor }}>
38
+ {description}
39
+ </p>
40
+ )}
41
+ {linkText && linkUrl && (
42
+ <a
43
+ className="ck-idle-link"
44
+ href={linkUrl}
45
+ target="_blank"
46
+ rel="noopener noreferrer"
47
+ style={{ color: linkTextColor }}
48
+ >
49
+ <span>{linkText}</span>
50
+ <LinkIcon />
51
+ </a>
52
+ )}
53
+ </div>
54
+ );
55
+ };
@@ -0,0 +1,52 @@
1
+ @use '../../../style/mixin.scss' as *;
2
+ .ck-root {
3
+ .ck-idle {
4
+ display: flex;
5
+ flex-direction: column;
6
+ align-items: flex-start;
7
+ gap: 6px;
8
+ position: absolute;
9
+ inset: 0;
10
+ z-index: 0;
11
+ padding: 60px;
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ .ck-idle-bg {
16
+ position: absolute;
17
+ inset: 0;
18
+ width: 100%;
19
+ height: 100%;
20
+ object-fit: cover;
21
+ z-index: -1;
22
+ }
23
+
24
+ .ck-idle-title {
25
+ @include textToken('title1-bold');
26
+ color: var(--scale-gray-900);
27
+ }
28
+
29
+ .ck-idle-desc {
30
+ color: var(--scale-gray-600);
31
+ margin-top: 6px;
32
+ white-space: pre-line;
33
+ @include textToken('body4-l1-regular');
34
+ }
35
+
36
+ .ck-idle-link {
37
+ display: flex;
38
+ align-items: center;
39
+ margin: -2px -8px -8px -8px;
40
+ padding: 8px;
41
+
42
+ span {
43
+ @include textToken('body4-l1-bold');
44
+ color: inherit;
45
+ }
46
+
47
+ svg {
48
+ flex-shrink: 0;
49
+ color: inherit;
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,432 @@
1
+ import { type FC, useEffect, useRef, useState } from 'react';
2
+ import { useConfig } from 'wagmi';
3
+ import './style.scss';
4
+ import {
5
+ type ConnectBannerSiteKey,
6
+ type ConnectorId,
7
+ type ConnectKitTheme,
8
+ type WCWallet,
9
+ type ConnectResult,
10
+ } from '../../types';
11
+ import { type DetailContent } from '../detailPanel';
12
+ import { SelectorPanel } from '../selectorPanel';
13
+ import { DetailPanel } from '../detailPanel';
14
+ import { Toast } from '../../components/toast';
15
+ import { resolveConnectorId } from '../../connector-meta';
16
+ import { useMetaMaskExtension } from '../../hooks/useMetaMaskExtension';
17
+ import { useWagmiConnect } from '../../hooks/useWagmiConnect';
18
+ import { useConnectTimeout } from '../../hooks/useConnectTimeout';
19
+ import { useConnectBanner } from '../../hooks/useConnectBanner';
20
+ import { useWCState } from '../../hooks/useWCState';
21
+ import { isMobileDevice, tryOpenDeepLink, tryOpenCreditWalletDeeplink } from '../../utils/platform';
22
+
23
+ export type DialogStep = 'selector' | 'detail';
24
+
25
+ export interface ConnectDialogProps {
26
+ open: boolean;
27
+ siteKey: ConnectBannerSiteKey;
28
+ connectBannerUrl: string;
29
+ theme?: ConnectKitTheme;
30
+ connectors?: ConnectorId[];
31
+ selectedConnector?: ConnectorId | null;
32
+ step?: DialogStep;
33
+ /** DEV: force initial content state for UI preview */
34
+ initialContent?: DetailContent;
35
+ /** WalletConnect Cloud projectId — useWCState.loadWalletList 호출 시 필요. */
36
+ wcProjectId?: string;
37
+ onSelectConnector?: (id: ConnectorId) => void;
38
+ onBack?: () => void;
39
+ onClose?: () => void;
40
+ onConnect?: (result: ConnectResult) => void;
41
+ onError?: (error: Error, connectorId: ConnectorId) => void;
42
+ }
43
+
44
+ const getContentForConnector = (id: ConnectorId): DetailContent => {
45
+ if (id === 'CREDIT_CONNECT') return 'qr';
46
+ if (id === 'METAMASK') return 'metamask-ext';
47
+ if (id === 'WALLET_CONNECT') return 'walletconnect';
48
+ return 'idle';
49
+ };
50
+
51
+ // MM 은 확장 감지 시 즉시 extension 연결, 아니면 QR. (select / retry 공통)
52
+ const getInitialContentForConnector = (
53
+ id: ConnectorId,
54
+ isMetaMaskExtensionInstalled: boolean,
55
+ ): DetailContent =>
56
+ id === 'METAMASK' && !isMetaMaskExtensionInstalled ? 'metamask-qr' : getContentForConnector(id);
57
+
58
+ const getErrorCode = (error: unknown, depth = 0): number | undefined => {
59
+ if (depth > 4 || !error || typeof error !== 'object') return undefined;
60
+
61
+ const candidate = error as { code?: unknown; cause?: unknown; error?: unknown };
62
+ const code = Number(candidate.code);
63
+ if (Number.isFinite(code)) return code;
64
+
65
+ return getErrorCode(candidate.cause, depth + 1) ?? getErrorCode(candidate.error, depth + 1);
66
+ };
67
+
68
+ const isMetaMaskPendingRequestError = (error: unknown): boolean => getErrorCode(error) === -32002;
69
+
70
+ export const ConnectDialog: FC<ConnectDialogProps> = ({
71
+ open,
72
+ siteKey,
73
+ connectBannerUrl,
74
+ theme = 'dark',
75
+ connectors = ['CREDIT_CONNECT', 'METAMASK', 'WALLET_CONNECT'],
76
+ selectedConnector: selectedConnectorProp = null,
77
+ // NOTE: default values below are for dev/markup preview only
78
+ step: stepProp = 'detail',
79
+ wcProjectId,
80
+ onSelectConnector,
81
+ onBack,
82
+ onClose = () => undefined,
83
+ onConnect,
84
+ onError,
85
+ }) => {
86
+ const dialogRef = useRef<HTMLDialogElement>(null);
87
+ const [selectedConnector, setSelectedConnector] = useState<ConnectorId | null>(
88
+ selectedConnectorProp,
89
+ );
90
+ const [recentConnector, setRecentConnector] = useState<ConnectorId | null>(null);
91
+ const [selectedWallet, setSelectedWallet] = useState<WCWallet | null>(null);
92
+ const [step, setStep] = useState<DialogStep>(stepProp);
93
+ /**
94
+ * change this initial value to preview a specific content state.
95
+ * - 'idle' selector만 표시 (detail 없음)
96
+ * - 'loading' 스피너
97
+ * - 'qr' Credit Wallet QR
98
+ * - 'timeout' 연결 실패 화면
99
+ * - 'metamask-ext' MetaMask 익스텐션 연결 요청
100
+ * - 'metamask-qr' MetaMask QR
101
+ * - 'walletconnect' WalletConnect QR + All Wallets 버튼
102
+ * - 'all-wallets' 지갑 목록 그리드
103
+ * - 'wallet-qr' 개별 지갑 QR
104
+ */
105
+ const [content, setContent] = useState<DetailContent>('idle');
106
+ const [showCopyToast, setShowCopyToast] = useState(false);
107
+ const [showMetaMaskPendingRequestToast, setShowMetaMaskPendingRequestToast] = useState(false);
108
+ const [qrUri, setQrUri] = useState<string | null>(null);
109
+ // 외부 만료 신호 카운터 — CC(서버 push session_expired) / WC(connect reject) 수신 시 증가.
110
+ const [expiredSignal, setExpiredSignal] = useState(0);
111
+ // EIP-6963 기반 MetaMask 확장 설치 상태.
112
+ const isMetaMaskExtensionInstalled = useMetaMaskExtension();
113
+
114
+ // 현재 content 를 ref 로 추적 (effect deps 없이 최신값 참조용).
115
+ const contentRef = useRef<DetailContent>(content);
116
+ contentRef.current = content;
117
+ // 타임아웃 직전 화면 — retry 시 이 화면으로 복원 (예: WC 의 wallet-qr).
118
+ const preTimeoutContentRef = useRef<DetailContent>('idle');
119
+
120
+ const config = useConfig();
121
+ const { banner: connectBanner, loading: connectBannerLoading } = useConnectBanner({
122
+ open,
123
+ siteKey,
124
+ url: connectBannerUrl,
125
+ });
126
+
127
+ // wagmi connect + display_uri 이벤트 → qrUri state.
128
+ // 모든 wagmi connector 의 message 이벤트를 구독하므로 CC connector 의 display_uri emit 도 자동 포착.
129
+ const { triggerConnect, cancelConnect, restartConnect, isConnecting } = useWagmiConnect({
130
+ onConnect: (result) => {
131
+ // recent = 연결 성공 기준. wagmi 가 recentConnectorId 를 localStorage 에 저장하므로
132
+ // UI state 만 동기화. 다음 마운트 시 아래 effect 가 storage 에서 복원.
133
+ setRecentConnector(result.connectorId);
134
+ onConnect?.(result);
135
+ },
136
+ onError: (err, connectorId) => {
137
+ // MetaMask는 pending 요청을 사전에 조회할 수 없어 새 요청의 -32002 응답으로만 판별한다.
138
+ if (
139
+ connectorId === 'METAMASK' &&
140
+ isMetaMaskExtensionInstalled &&
141
+ isMetaMaskPendingRequestError(err)
142
+ ) {
143
+ setShowMetaMaskPendingRequestToast(true);
144
+ }
145
+
146
+ // MM/WC connect reject는 타이머를 기다리지 않고 즉시 실패 화면으로 전환.
147
+ if (connectorId === 'METAMASK' || connectorId === 'WALLET_CONNECT') {
148
+ setExpiredSignal((n) => n + 1);
149
+ }
150
+ onError?.(err, connectorId);
151
+ },
152
+ onQrUri: (uri) => setQrUri(uri),
153
+ // CC connector 가 서버 OnSessionExpired 를 릴레이 → 만료 신호 증가.
154
+ onSessionExpired: () => setExpiredSignal((n) => n + 1),
155
+ });
156
+
157
+ // 연결 타임아웃 (CC=서버 push / MM=client 타이머 / WC=connect reject) → timedOut.
158
+ const timeoutActive = step === 'detail' && !!selectedConnector && content !== 'timeout';
159
+ const {
160
+ timedOut,
161
+ reset: resetTimeout,
162
+ retry,
163
+ } = useConnectTimeout({
164
+ connectorId: selectedConnector,
165
+ active: timeoutActive,
166
+ isConnecting,
167
+ expiredSignal,
168
+ onRetry: () => {
169
+ if (!selectedConnector) return;
170
+ // 타임아웃 직전 화면으로 복원 (예: WC 의 wallet-qr → 선택 지갑 QR 유지).
171
+ // idle/timeout 처럼 복원 불가한 값이면 커넥터 초기 화면으로.
172
+ const restore = preTimeoutContentRef.current;
173
+ const nextContent: DetailContent =
174
+ restore !== 'idle' && restore !== 'timeout'
175
+ ? restore
176
+ : getInitialContentForConnector(selectedConnector, isMetaMaskExtensionInstalled);
177
+
178
+ // MetaMask 확장 pending 요청은 dApp에서 종료하거나 다시 요청할 수 없다.
179
+ // 실패 화면을 유지하고 기존 요청을 먼저 처리하도록 안내한다.
180
+ if (selectedConnector === 'METAMASK' && restore === 'metamask-ext' && isConnecting) {
181
+ setContent('timeout');
182
+ setShowMetaMaskPendingRequestToast(true);
183
+ return;
184
+ }
185
+
186
+ setContent(nextContent);
187
+ setQrUri(null);
188
+
189
+ // MetaMask QR/Deeplink는 SDK 채널을 종료한 뒤 새 요청을 생성한다.
190
+ if (selectedConnector === 'METAMASK' && restore === 'metamask-qr') {
191
+ restartConnect(selectedConnector).catch(() => undefined);
192
+ return;
193
+ }
194
+
195
+ cancelConnect();
196
+ triggerConnect(selectedConnector);
197
+ },
198
+ });
199
+
200
+ // 타임아웃 발생 → 진행 중 연결 취소 + timeout 화면 표시.
201
+ // 전환 전 화면을 저장해 retry 시 복원 (WC wallet-qr 등).
202
+ useEffect(() => {
203
+ // selector로 돌아간 렌더에 이전 timedOut이 잠시 남아도 실패 화면을 다시 덮지 않는다.
204
+ if (!timedOut || !selectedConnector || step !== 'detail') return;
205
+ // cancelConnect로 isConnecting이 바뀌어 effect가 다시 실행되어도
206
+ // 기록한 이전 화면을 timeout으로 덮어쓰지 않는다.
207
+ if (contentRef.current === 'timeout') return;
208
+ preTimeoutContentRef.current = contentRef.current;
209
+
210
+ // MetaMask 확장 pending 요청만 보존한다. QR/Deeplink는 Retry에서 SDK를 재시작한다.
211
+ const shouldPreserveMetaMaskRequest =
212
+ selectedConnector === 'METAMASK' && contentRef.current === 'metamask-ext' && isConnecting;
213
+ // 확장 승인 요청은 dApp에서 종료할 수 없으므로 원래 요청을 유지한다.
214
+ if (!shouldPreserveMetaMaskRequest) cancelConnect();
215
+ setContent('timeout');
216
+ }, [timedOut, selectedConnector, step, isConnecting, cancelConnect]);
217
+
218
+ // provider 응답이 늦게 도착하면 QR에서 확장 연결 화면으로 전환.
219
+ useEffect(() => {
220
+ if (
221
+ isMetaMaskExtensionInstalled &&
222
+ selectedConnector === 'METAMASK' &&
223
+ content === 'metamask-qr'
224
+ ) {
225
+ setContent('metamask-ext');
226
+ }
227
+ }, [isMetaMaskExtensionInstalled, selectedConnector, content]);
228
+
229
+ // 마운트 시 wagmi storage 의 recentConnectorId(연결 성공 시 wagmi 가 저장) 복원.
230
+ useEffect(() => {
231
+ const restoreRecent = async (): Promise<void> => {
232
+ const wagmiId = await config.storage?.getItem('recentConnectorId');
233
+ const resolved = resolveConnectorId(wagmiId ?? undefined);
234
+ if (resolved) setRecentConnector(resolved);
235
+ };
236
+ restoreRecent().catch(() => undefined);
237
+ }, [config]);
238
+
239
+ // WalletConnect Explorer API (walletList + loading + 등)
240
+ const wc = useWCState();
241
+
242
+ const handleSelect = (id: ConnectorId) => {
243
+ // 다른 지갑 선택은 이전 시도와 실패 상태를 이어받지 않고 항상 새 연결로 시작한다.
244
+ cancelConnect();
245
+ resetTimeout();
246
+ setQrUri(null);
247
+ setSelectedWallet(null);
248
+ setShowCopyToast(false);
249
+ setShowMetaMaskPendingRequestToast(false);
250
+ setSelectedConnector(id);
251
+ // recent 는 클릭이 아니라 연결 성공(onConnect) 시점에 기록 — 위 useWagmiConnect onConnect 참조.
252
+ setContent(getInitialContentForConnector(id, isMetaMaskExtensionInstalled));
253
+ setStep('detail');
254
+ // wagmi connect 시작 (display_uri 도착 시 setQrUri 자동 호출)
255
+ triggerConnect(id);
256
+ onSelectConnector?.(id);
257
+ };
258
+
259
+ const handleSelectWallet = (wallet: WCWallet) => {
260
+ setSelectedWallet(wallet);
261
+ setContent('wallet-qr');
262
+ // 자동 deeplink 는 아래 useEffect (selectedWallet + qrUri + content === 'wallet-qr') 에서.
263
+ // 모바일 + display_uri 도착 시 native/universal scheme 으로 redirect, 아니면 WalletQR fallback.
264
+ };
265
+
266
+ const handleBack = () => {
267
+ if (content === 'wallet-qr') {
268
+ setSelectedWallet(null);
269
+ setContent('all-wallets');
270
+ return;
271
+ }
272
+ if (content === 'all-wallets') {
273
+ setContent('walletconnect');
274
+ return;
275
+ }
276
+ // selector 로 복귀 → wagmi connect attempt 취소 + qrUri 초기화
277
+ cancelConnect();
278
+ setQrUri(null);
279
+ setSelectedConnector(null);
280
+ setContent('idle');
281
+ setStep('selector');
282
+ onBack?.();
283
+ };
284
+
285
+ const handleShowAllWallets = () => {
286
+ setContent('all-wallets');
287
+ // 처음 진입 시 WC Explorer API fetch (loadedRef guard 로 중복 호출 없음)
288
+ if (wcProjectId) {
289
+ wc.loadWalletList(wcProjectId).catch(() => undefined);
290
+ }
291
+ };
292
+
293
+ // showModal / close driven by open prop
294
+ useEffect(() => {
295
+ const dialog = dialogRef.current;
296
+ if (!dialog) return;
297
+ if (open && !dialog.open) dialog.showModal();
298
+ if (!open && dialog.open) dialog.close();
299
+ }, [open]);
300
+
301
+ // selectedWallet 가 먼저 set 됐고 qrUri 가 뒤늦게 도착한 경우 자동 deeplink.
302
+ // 같은 지갑·URI 조합만 중복 방지하고, Retry로 새 URI가 오면 다시 실행한다.
303
+ const lastWalletDeeplinkKeyRef = useRef<string | null>(null);
304
+ useEffect(() => {
305
+ lastWalletDeeplinkKeyRef.current = null;
306
+ }, [selectedWallet]);
307
+ useEffect(() => {
308
+ if (!selectedWallet || !qrUri || content !== 'wallet-qr') return;
309
+ if (!isMobileDevice()) return;
310
+ const base = selectedWallet.mobileNative || selectedWallet.mobileUniversal;
311
+ if (!base) return;
312
+ const deeplinkKey = `${selectedWallet.id}:${qrUri}`;
313
+ if (lastWalletDeeplinkKeyRef.current === deeplinkKey) return;
314
+ lastWalletDeeplinkKeyRef.current = deeplinkKey;
315
+ tryOpenDeepLink(qrUri, base);
316
+ }, [selectedWallet, qrUri, content]);
317
+
318
+ // CC connector direct path: selector 의 'Continue with Credit Wallet' → content='qr'.
319
+ // wagmi MM connector 는 SDK 가 자체 deeplink 하지만 CC connector 는 emit display_uri 만 함 —
320
+ // kit 가 mobile redirect 책임. payload 는 full URL (`https://app.creditcoin.org/?creditconnect=...`).
321
+ const lastCreditConnectDeeplinkUriRef = useRef<string | null>(null);
322
+ useEffect(() => {
323
+ lastCreditConnectDeeplinkUriRef.current = null;
324
+ }, [selectedConnector]);
325
+ useEffect(() => {
326
+ if (content !== 'qr' || !qrUri) return;
327
+ if (selectedConnector !== 'CREDIT_CONNECT') return;
328
+ if (!isMobileDevice()) return;
329
+ if (lastCreditConnectDeeplinkUriRef.current === qrUri) return;
330
+ lastCreditConnectDeeplinkUriRef.current = qrUri;
331
+ tryOpenCreditWalletDeeplink(qrUri);
332
+ }, [content, qrUri, selectedConnector]);
333
+
334
+ // Prevent background scroll while dialog is open
335
+ useEffect(() => {
336
+ const prev = document.body.style.overflow;
337
+ if (open) document.body.style.overflow = 'hidden';
338
+ return () => {
339
+ document.body.style.overflow = prev;
340
+ };
341
+ }, [open]);
342
+
343
+ // Reset on close
344
+ useEffect(() => {
345
+ if (!open) {
346
+ cancelConnect();
347
+ setQrUri(null);
348
+ setSelectedConnector(null);
349
+ setContent('idle');
350
+ setStep('selector');
351
+ setShowCopyToast(false);
352
+ setShowMetaMaskPendingRequestToast(false);
353
+ }
354
+ }, [open, cancelConnect]);
355
+
356
+ // Intercept native Escape (cancel event) and delegate to onClose
357
+ useEffect(() => {
358
+ const dialog = dialogRef.current;
359
+ if (!dialog) return undefined;
360
+ const handleCancel = (e: Event) => {
361
+ e.preventDefault();
362
+ setShowCopyToast(false);
363
+ setShowMetaMaskPendingRequestToast(false);
364
+ onClose();
365
+ };
366
+ dialog.addEventListener('cancel', handleCancel);
367
+ return () => dialog.removeEventListener('cancel', handleCancel);
368
+ }, [onClose]);
369
+
370
+ return (
371
+ <div className="ck-root" data-theme={theme} data-open={open}>
372
+ <dialog
373
+ ref={dialogRef}
374
+ className="ck-dialog"
375
+ data-step={step}
376
+ aria-label="Connect Wallet"
377
+ onClick={(e) => {
378
+ if (e.target === dialogRef.current) onClose();
379
+ }}
380
+ >
381
+ <SelectorPanel
382
+ connectors={connectors}
383
+ selectedConnector={selectedConnector}
384
+ recentConnector={recentConnector}
385
+ onSelect={handleSelect}
386
+ onClose={onClose}
387
+ />
388
+ <DetailPanel
389
+ connectBanner={connectBanner}
390
+ connectBannerLoading={connectBannerLoading}
391
+ selectedConnector={selectedConnector}
392
+ selectedWallet={selectedWallet}
393
+ content={content}
394
+ qrUri={qrUri}
395
+ wallets={wc.walletList}
396
+ walletListLoading={wc.walletListLoading}
397
+ walletCount={wc.walletList.length}
398
+ onBack={handleBack}
399
+ onClose={onClose}
400
+ onRetry={retry}
401
+ onShowAllWallets={handleShowAllWallets}
402
+ onSelectWallet={handleSelectWallet}
403
+ onCopyLink={() => {
404
+ // qrUri 미도착 시 silent no-op (사용자가 잘못 누른 게 아니라 wagmi 가 아직 안 emit).
405
+ if (!qrUri) return;
406
+ navigator.clipboard
407
+ .writeText(qrUri)
408
+ .then(() => setShowCopyToast(true))
409
+ .catch((err) => {
410
+ // eslint-disable-next-line no-console
411
+ console.warn('[copyLink] clipboard write failed', err);
412
+ });
413
+ }}
414
+ />
415
+ {(showCopyToast || showMetaMaskPendingRequestToast) && (
416
+ <div className="ck-toast-stack">
417
+ {showCopyToast && (
418
+ <Toast message="Link copied" onClose={() => setShowCopyToast(false)} />
419
+ )}
420
+ {showMetaMaskPendingRequestToast && (
421
+ <Toast
422
+ variant="warning"
423
+ message="Clear the pending request in your wallet first."
424
+ onClose={() => setShowMetaMaskPendingRequestToast(false)}
425
+ />
426
+ )}
427
+ </div>
428
+ )}
429
+ </dialog>
430
+ </div>
431
+ );
432
+ };
@@ -0,0 +1,13 @@
1
+ import { type FC } from 'react';
2
+ import './style.scss';
3
+ import { Spinner } from '../asset/spinner';
4
+
5
+ export const Loading: FC = () => {
6
+ return (
7
+ <div className="ck-loading">
8
+ <span className="ck-loading-spinner" role="status" aria-label="Connecting...">
9
+ <Spinner />
10
+ </span>
11
+ </div>
12
+ );
13
+ };
@@ -0,0 +1,19 @@
1
+ @use '../../../style/mixin.scss' as *;
2
+
3
+ .ck-loading {
4
+ display: flex;
5
+ align-items: center;
6
+ justify-content: center;
7
+ flex: 1;
8
+ min-height: 200px;
9
+ }
10
+
11
+ .ck-loading-spinner {
12
+ display: flex;
13
+ color: var(--scale-gray-400);
14
+ animation: ck-spin 2.5s linear infinite;
15
+ }
16
+
17
+ @keyframes ck-spin {
18
+ to { transform: rotate(360deg); }
19
+ }
@@ -0,0 +1,61 @@
1
+ import { type FC } from 'react';
2
+ import './style.scss';
3
+ import { QrArea } from '../../../components/qrArea';
4
+ import { ActionItem } from '../../../components/actionItem';
5
+ import { Spinner } from '../asset/spinner';
6
+
7
+ type MetaMaskVariant = 'extension' | 'qr';
8
+
9
+ interface MetaMaskProps {
10
+ variant: MetaMaskVariant;
11
+ uri?: string | null;
12
+ logoUrl?: string;
13
+ downloadUrl?: string;
14
+ onCopy?: () => void;
15
+ }
16
+
17
+ export const MetaMask: FC<MetaMaskProps> = ({
18
+ variant,
19
+ uri,
20
+ logoUrl,
21
+ downloadUrl = '',
22
+ onCopy,
23
+ }) => {
24
+ if (variant === 'extension') {
25
+ return (
26
+ <div className="ck-metamask ck-metamask-extension">
27
+ {logoUrl && (
28
+ <img className="ck-metamask-logo" src={logoUrl} alt="MetaMask" width={48} height={48} />
29
+ )}
30
+ <div className="ck-metamask-text">
31
+ <h3 className="ck-metamask-title">Open MetaMask</h3>
32
+ <p className="ck-metamask-desc">Check the connection in your extension</p>
33
+ </div>
34
+ <span className="ck-metamask-spinner" aria-label="Connecting...">
35
+ <Spinner />
36
+ </span>
37
+ </div>
38
+ );
39
+ }
40
+
41
+ return (
42
+ <>
43
+ <QrArea
44
+ uri={uri}
45
+ status={!uri ? 'loading' : 'ready'}
46
+ logoUrl={logoUrl}
47
+ hasBg
48
+ label="Scan this QR Code with your phone"
49
+ onCopy={onCopy}
50
+ />
51
+ <ActionItem
52
+ as="a"
53
+ href={downloadUrl}
54
+ target="_blank"
55
+ rel="noopener noreferrer"
56
+ label="Don't have MetaMask Wallet?"
57
+ action="Download"
58
+ />
59
+ </>
60
+ );
61
+ };