@gluwa/connect-kit 0.2.0-next.1 → 0.2.0-next.11
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.
- package/CHANGELOG.md +72 -0
- package/assets/fonts/Inter-Medium.ttf +0 -0
- package/assets/fonts/Inter-Medium.woff2 +0 -0
- package/assets/fonts/Inter-Regular.ttf +0 -0
- package/assets/fonts/Inter-Regular.woff2 +0 -0
- package/assets/fonts/Inter-SemiBold.ttf +0 -0
- package/assets/fonts/Inter-SemiBold.woff2 +0 -0
- package/assets/graphic.png +0 -0
- package/assets/metamask.png +0 -0
- package/assets/penguinwallet.png +0 -0
- package/assets/wc-logo.svg +3 -0
- package/assets/wc-search.png +0 -0
- package/dist/chunk-VE5LB7BN.js +405 -0
- package/dist/credit-connect.js +2 -409
- package/dist/index.css +1903 -389
- package/dist/index.d.ts +21 -10
- package/dist/index.js +1507 -922
- package/dist/package.json +7 -6
- package/package.json +8 -8
- package/src/ConnectKitProvider.tsx +97 -65
- package/src/api/asset.ts +10 -0
- package/src/assets.d.ts +5 -0
- package/src/components/actionItem/index.tsx +50 -0
- package/src/components/actionItem/style.scss +47 -0
- package/src/components/copyLink/index.tsx +16 -0
- package/src/components/copyLink/style.scss +16 -0
- package/src/components/qrArea/index.tsx +72 -0
- package/src/components/qrArea/style.scss +122 -0
- package/src/components/searchBar/asset/Search.tsx +20 -0
- package/src/components/searchBar/asset/XMarkCircle.tsx +24 -0
- package/src/components/searchBar/index.tsx +44 -0
- package/src/components/searchBar/style.scss +58 -0
- package/src/components/toast/index.tsx +41 -0
- package/src/components/toast/style.scss +108 -0
- package/src/components/toggle/index.tsx +27 -0
- package/src/components/toggle/style.scss +53 -0
- package/src/components/walletGrid/index.tsx +64 -0
- package/src/components/walletGrid/style.scss +108 -0
- package/src/components/walletItem/index.tsx +49 -0
- package/src/components/walletItem/style.scss +70 -0
- package/src/connector-meta.ts +16 -11
- package/src/core/config.ts +20 -1
- package/src/creditConnectConnector.ts +23 -25
- package/src/events/account.ts +30 -3
- package/src/hooks/useConnectBanner.ts +79 -0
- package/src/hooks/useConnectTimeout.ts +92 -0
- package/src/hooks/useMetaMaskExtension.ts +29 -0
- package/src/hooks/useWCState.ts +21 -5
- package/src/hooks/useWagmiConnect.ts +35 -9
- package/src/index.ts +5 -2
- package/src/layout/allWallets/index.tsx +52 -0
- package/src/layout/allWallets/style.scss +57 -0
- package/src/layout/connectDialog/asset/backIcon.tsx +24 -0
- package/src/layout/connectDialog/asset/checkIcon.tsx +28 -0
- package/src/layout/connectDialog/asset/closeIcon.tsx +21 -0
- package/src/layout/connectDialog/asset/copyIcon.tsx +28 -0
- package/src/layout/connectDialog/asset/linkIcon.tsx +24 -0
- package/src/layout/connectDialog/asset/spinner.tsx +21 -0
- package/src/layout/connectDialog/asset/warningIcon.tsx +21 -0
- package/src/layout/connectDialog/idle/assets/banner-placeholder.png +0 -0
- package/src/layout/connectDialog/idle/index.tsx +55 -0
- package/src/layout/connectDialog/idle/style.scss +52 -0
- package/src/layout/connectDialog/index.tsx +432 -0
- package/src/layout/connectDialog/loading/index.tsx +13 -0
- package/src/layout/connectDialog/loading/style.scss +19 -0
- package/src/layout/connectDialog/metaMask/index.tsx +61 -0
- package/src/layout/connectDialog/metaMask/style.scss +65 -0
- package/src/layout/connectDialog/qr/index.tsx +33 -0
- package/src/layout/connectDialog/style.scss +48 -0
- package/src/layout/connectDialog/timeout/index.tsx +31 -0
- package/src/layout/connectDialog/timeout/style.scss +64 -0
- package/src/layout/connectDialog/walletConnect/index.tsx +45 -0
- package/src/layout/connectDialog/walletConnect/style.scss +48 -0
- package/src/layout/connectDialog/walletQR/index.tsx +37 -0
- package/src/layout/detailPanel/index.tsx +175 -0
- package/src/layout/detailPanel/style.scss +70 -0
- package/src/layout/selectorPanel/index.tsx +100 -0
- package/src/layout/selectorPanel/style.scss +113 -0
- package/src/style/color.scss +417 -0
- package/src/style/font.scss +28 -0
- package/src/style/index.scss +5 -0
- package/src/style/mixin.scss +217 -0
- package/src/style/reset.scss +110 -0
- package/src/style/variables.scss +13 -0
- package/src/types.ts +32 -8
- package/src/utils/platform.ts +5 -3
- package/src/wallet-display-override.ts +15 -0
- package/tsconfig.json +5 -1
- package/tsup.config.ts +32 -2
- package/assets/creditwallet.png +0 -0
- package/dist/chunk-DDA6FP6U.js +0 -7
- package/src/ConnectModal.scss +0 -665
- package/src/ConnectModal.tsx +0 -573
- package/src/components/QRFrame.tsx +0 -43
- package/src/views/CreditWalletView.tsx +0 -58
- package/src/views/IdleView.tsx +0 -10
- package/src/views/MetaMaskView.tsx +0 -66
- package/src/views/SwitchChainView.tsx +0 -72
- package/src/views/WalletConnectView.tsx +0 -206
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
@use '../../../style/mixin.scss' as *;
|
|
2
|
+
|
|
3
|
+
// ── Extension ─────────────────────────────────────────────────────────────────
|
|
4
|
+
.ck-metamask-extension {
|
|
5
|
+
display: flex;
|
|
6
|
+
flex-direction: column;
|
|
7
|
+
align-items: center;
|
|
8
|
+
justify-content: center;
|
|
9
|
+
gap: 16px;
|
|
10
|
+
flex: 1;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.ck-metamask-logo {
|
|
14
|
+
width: 48px;
|
|
15
|
+
height: 48px;
|
|
16
|
+
border-radius: 12px;
|
|
17
|
+
object-fit: cover;
|
|
18
|
+
flex-shrink: 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.ck-metamask-text {
|
|
22
|
+
display: flex;
|
|
23
|
+
flex-direction: column;
|
|
24
|
+
align-items: center;
|
|
25
|
+
gap: 0;
|
|
26
|
+
text-align: center;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.ck-metamask-title {
|
|
30
|
+
@include textToken('subtitle2-bold');
|
|
31
|
+
color: var(--scale-gray-900);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.ck-metamask-desc {
|
|
35
|
+
@include textToken('body3-l1-regular');
|
|
36
|
+
color: var(--scale-gray-600);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.ck-metamask-spinner {
|
|
40
|
+
display: flex;
|
|
41
|
+
color: var(--scale-gray-400);
|
|
42
|
+
animation: ck-spin 2.5s linear infinite;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@keyframes ck-spin {
|
|
46
|
+
to { transform: rotate(360deg); }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
.ck-root .ck-metamask-copy-btn {
|
|
52
|
+
display: inline-flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
gap: 6px;
|
|
55
|
+
@include textToken('body3-l1-regular');
|
|
56
|
+
color: var(--scale-gray-500);
|
|
57
|
+
cursor: pointer;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.ck-metamask-copy-icon {
|
|
61
|
+
display: block;
|
|
62
|
+
width: 20px;
|
|
63
|
+
height: 20px;
|
|
64
|
+
// Icon SVG will be added later
|
|
65
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type FC } from 'react';
|
|
2
|
+
import { QrArea } from '../../../components/qrArea';
|
|
3
|
+
import { ActionItem } from '../../../components/actionItem';
|
|
4
|
+
|
|
5
|
+
interface QRProps {
|
|
6
|
+
uri?: string | null;
|
|
7
|
+
logoUrl?: string;
|
|
8
|
+
downloadUrl?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const QR: FC<QRProps> = ({ uri, logoUrl, downloadUrl }) => {
|
|
12
|
+
const status = !uri ? 'loading' : 'ready';
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<>
|
|
16
|
+
<QrArea
|
|
17
|
+
uri={uri}
|
|
18
|
+
status={status}
|
|
19
|
+
logoUrl={logoUrl}
|
|
20
|
+
label="Open the PenguinWallet app and scan the QR"
|
|
21
|
+
/>
|
|
22
|
+
<ActionItem
|
|
23
|
+
as="a"
|
|
24
|
+
href={downloadUrl ?? '#'}
|
|
25
|
+
target="_blank"
|
|
26
|
+
rel="noopener noreferrer"
|
|
27
|
+
label="Get PenguinWallet app"
|
|
28
|
+
description="Once connected, you can log in with it next time"
|
|
29
|
+
action="Download"
|
|
30
|
+
/>
|
|
31
|
+
</>
|
|
32
|
+
);
|
|
33
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
@use '../../style/index.scss';
|
|
2
|
+
@use '../../style/mixin.scss' as *;
|
|
3
|
+
|
|
4
|
+
.ck-root {
|
|
5
|
+
display: contents;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.ck-dialog::backdrop {
|
|
9
|
+
background: var(--dialog-backdrop-color);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.ck-root .ck-dialog:not([open]) {
|
|
13
|
+
display: none;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.ck-root .ck-dialog {
|
|
17
|
+
--dialog-backdrop-color: var(--semantic-overlay-overlay-dim);
|
|
18
|
+
position: fixed;
|
|
19
|
+
inset: 0;
|
|
20
|
+
margin: auto;
|
|
21
|
+
display: grid;
|
|
22
|
+
grid-template-columns: 1fr 1fr;
|
|
23
|
+
width: calc(100% - 40px);
|
|
24
|
+
max-width: 1120px;
|
|
25
|
+
height: min(620px, calc(100dvh - 40px));
|
|
26
|
+
|
|
27
|
+
border-radius: 12px;
|
|
28
|
+
overflow: hidden;
|
|
29
|
+
background: var(--semantic-paper-dialog);
|
|
30
|
+
border: none;
|
|
31
|
+
padding: 0;
|
|
32
|
+
|
|
33
|
+
@include tablet {
|
|
34
|
+
position: fixed;
|
|
35
|
+
inset: auto 0 0 0;
|
|
36
|
+
margin: 0;
|
|
37
|
+
width: 100%;
|
|
38
|
+
max-width: none;
|
|
39
|
+
grid-template-columns: 1fr;
|
|
40
|
+
grid-template-rows: minmax(0, 1fr);
|
|
41
|
+
max-height: 82dvh;
|
|
42
|
+
height: auto;
|
|
43
|
+
border-radius: 12px 12px 0 0;
|
|
44
|
+
|
|
45
|
+
&[data-step='selector'] .ck-detail { display: none !important; }
|
|
46
|
+
&[data-step='detail'] .ck-selector { display: none !important; }
|
|
47
|
+
}
|
|
48
|
+
}
|