@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,49 @@
|
|
|
1
|
+
import { type FC } from 'react';
|
|
2
|
+
import './style.scss';
|
|
3
|
+
import { type ConnectorId } from '../../types';
|
|
4
|
+
|
|
5
|
+
interface WalletItemProps {
|
|
6
|
+
id: ConnectorId;
|
|
7
|
+
label: string;
|
|
8
|
+
logoUrl?: string;
|
|
9
|
+
isSelected: boolean;
|
|
10
|
+
isRecent?: boolean;
|
|
11
|
+
onSelect: (id: ConnectorId) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const WalletItem: FC<WalletItemProps> = ({
|
|
15
|
+
id,
|
|
16
|
+
label,
|
|
17
|
+
logoUrl,
|
|
18
|
+
isSelected,
|
|
19
|
+
isRecent = false,
|
|
20
|
+
onSelect,
|
|
21
|
+
}) => {
|
|
22
|
+
return (
|
|
23
|
+
<button
|
|
24
|
+
type="button"
|
|
25
|
+
className={`ck-wallet-item${isSelected ? ' is-selected' : ''}`}
|
|
26
|
+
onClick={() => onSelect(id)}
|
|
27
|
+
aria-pressed={isSelected}
|
|
28
|
+
>
|
|
29
|
+
{logoUrl ? (
|
|
30
|
+
<img
|
|
31
|
+
className={`ck-wallet-item-icon${id === 'METAMASK' ? ' has-bg' : ''}`}
|
|
32
|
+
src={logoUrl}
|
|
33
|
+
alt=""
|
|
34
|
+
aria-hidden="true"
|
|
35
|
+
/>
|
|
36
|
+
) : (
|
|
37
|
+
<span className="ck-wallet-item-icon" aria-hidden="true" />
|
|
38
|
+
)}
|
|
39
|
+
<span className="ck-wallet-item-txt">
|
|
40
|
+
<span className="ck-wallet-item-label">{label}</span>
|
|
41
|
+
{isRecent && (
|
|
42
|
+
<span className="ck-wallet-item-badge" aria-label="Recently used">
|
|
43
|
+
Recent
|
|
44
|
+
</span>
|
|
45
|
+
)}
|
|
46
|
+
</span>
|
|
47
|
+
</button>
|
|
48
|
+
);
|
|
49
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
@use '../../style/mixin.scss' as *;
|
|
2
|
+
|
|
3
|
+
.ck-root .ck-wallet-item {
|
|
4
|
+
display: flex;
|
|
5
|
+
align-items: center;
|
|
6
|
+
gap: 12px;
|
|
7
|
+
width: 100%;
|
|
8
|
+
padding: 14px 20px;
|
|
9
|
+
border-radius: 12px;
|
|
10
|
+
background: var(--scale-gray-100);
|
|
11
|
+
cursor: pointer;
|
|
12
|
+
transition: background 0.15s ease;
|
|
13
|
+
|
|
14
|
+
&:hover {
|
|
15
|
+
background: var(--scale-gray-200);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
&.is-selected {
|
|
19
|
+
background: var(--semantic-brand-trugi-primary);
|
|
20
|
+
|
|
21
|
+
.ck-wallet-item-label {
|
|
22
|
+
color: var(--static-white-main);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.ck-wallet-item-badge {
|
|
26
|
+
background: var(--static-white-alpha-200);
|
|
27
|
+
color: var(--static-white-main);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.ck-wallet-item-icon {
|
|
33
|
+
width: 36px;
|
|
34
|
+
height: 36px;
|
|
35
|
+
border-radius: 8px;
|
|
36
|
+
flex-shrink: 0;
|
|
37
|
+
overflow: hidden;
|
|
38
|
+
object-fit: cover;
|
|
39
|
+
|
|
40
|
+
&.has-bg {
|
|
41
|
+
background: var(--static-white-main);
|
|
42
|
+
padding: 2px;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.ck-wallet-item-txt {
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
gap: 6px;
|
|
50
|
+
flex: 1;
|
|
51
|
+
min-width: 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
.ck-wallet-item-label {
|
|
55
|
+
@include textToken('label3-bold');
|
|
56
|
+
color: var(--scale-gray-900);
|
|
57
|
+
overflow: hidden;
|
|
58
|
+
text-overflow: ellipsis;
|
|
59
|
+
white-space: nowrap;
|
|
60
|
+
flex-shrink: 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.ck-wallet-item-badge {
|
|
64
|
+
@include textToken('caption3-bold');
|
|
65
|
+
color: var(--semantic-brand-trugi-primary);
|
|
66
|
+
background: var(--scale-blue2-alpha-100);
|
|
67
|
+
padding: 4px 8px;
|
|
68
|
+
border-radius: 500px;
|
|
69
|
+
flex-shrink: 0;
|
|
70
|
+
}
|
package/src/connector-meta.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ConnectorId } from './types';
|
|
2
|
-
import
|
|
2
|
+
import penguinWalletIcon from '../assets/penguinwallet.png';
|
|
3
3
|
import metaMaskIcon from '../assets/metamask.png';
|
|
4
4
|
import wcIcon from '../assets/wc.png';
|
|
5
5
|
|
|
@@ -13,17 +13,10 @@ export interface ConnectorMeta {
|
|
|
13
13
|
|
|
14
14
|
export const CONNECTOR_META: Record<ConnectorId, ConnectorMeta> = {
|
|
15
15
|
CREDIT_CONNECT: {
|
|
16
|
-
label: 'Continue with
|
|
17
|
-
detailTitle: 'Scan with
|
|
16
|
+
label: 'Continue with PenguinWallet',
|
|
17
|
+
detailTitle: 'Scan with PenguinWallet',
|
|
18
18
|
downloadUrl: 'https://creditwallet.onelink.me/Jv54',
|
|
19
|
-
logoUrl:
|
|
20
|
-
// deepLinkBase: TODO — 앱 측 CC 딥링크 스킴 확정 후 추가
|
|
21
|
-
},
|
|
22
|
-
CREDIT_WALLET: {
|
|
23
|
-
label: 'Credit Wallet',
|
|
24
|
-
detailTitle: 'Scan with Credit Wallet',
|
|
25
|
-
downloadUrl: 'https://creditwallet.onelink.me/Jv54',
|
|
26
|
-
logoUrl: creditWalletIcon,
|
|
19
|
+
logoUrl: penguinWalletIcon,
|
|
27
20
|
deepLinkBase: 'creditwallet://',
|
|
28
21
|
},
|
|
29
22
|
METAMASK: {
|
|
@@ -39,3 +32,15 @@ export const CONNECTOR_META: Record<ConnectorId, ConnectorMeta> = {
|
|
|
39
32
|
logoUrl: wcIcon,
|
|
40
33
|
},
|
|
41
34
|
};
|
|
35
|
+
|
|
36
|
+
// wagmi connector id (예: 'credit-connect' · 'metaMaskSDK' · 'walletConnect') → kit ConnectorId.
|
|
37
|
+
// wagmi storage 의 recentConnectorId 복원 · account.connector 식별에 공용 사용.
|
|
38
|
+
export const resolveConnectorId = (wagmiConnectorId: string | undefined): ConnectorId | null => {
|
|
39
|
+
if (!wagmiConnectorId) return null;
|
|
40
|
+
if (wagmiConnectorId === 'credit-connect') return 'CREDIT_CONNECT';
|
|
41
|
+
if (wagmiConnectorId === 'walletConnect') return 'WALLET_CONNECT';
|
|
42
|
+
const lower = wagmiConnectorId.toLowerCase();
|
|
43
|
+
if (lower.includes('metamask') || wagmiConnectorId === 'injected') return 'METAMASK';
|
|
44
|
+
if (lower.includes('walletconnect')) return 'WALLET_CONNECT';
|
|
45
|
+
return null;
|
|
46
|
+
};
|
package/src/core/config.ts
CHANGED
|
@@ -2,6 +2,10 @@ import { createConfig, createStorage } from 'wagmi';
|
|
|
2
2
|
import { metaMask, walletConnect, injected } from 'wagmi/connectors';
|
|
3
3
|
import type { Config } from 'wagmi';
|
|
4
4
|
import type { ConnectKitConfig } from '../types';
|
|
5
|
+
import { creditConnectConnector } from '../creditConnectConnector';
|
|
6
|
+
import { LocalStorageProvider } from '@gluwa/credit-connect-storage-local';
|
|
7
|
+
import { tweetNaclE2EE } from '@gluwa/credit-connect-e2ee-tweetnacl';
|
|
8
|
+
import { wsHubFactory } from '@gluwa/credit-connect-hub-node-ws';
|
|
5
9
|
|
|
6
10
|
type KitSlot = {
|
|
7
11
|
config: Config | null;
|
|
@@ -40,7 +44,22 @@ export const initConfig = (kitConfig: ConnectKitConfig): Config => {
|
|
|
40
44
|
connectors.push(walletConnect({ projectId: kitConfig.wcProjectId, showQrModal: false }));
|
|
41
45
|
}
|
|
42
46
|
|
|
43
|
-
connectors.push(
|
|
47
|
+
connectors.push(
|
|
48
|
+
metaMask({ headless: true, storage: { enabled: true } }),
|
|
49
|
+
injected({ shimDisconnect: false }),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
if (kitConfig.creditConnect) {
|
|
53
|
+
connectors.push(
|
|
54
|
+
creditConnectConnector({
|
|
55
|
+
projectKey: kitConfig.creditConnect.projectId,
|
|
56
|
+
serverUrl: kitConfig.creditConnect.serverUrl,
|
|
57
|
+
storage: new LocalStorageProvider(),
|
|
58
|
+
e2ee: tweetNaclE2EE,
|
|
59
|
+
createHub: wsHubFactory.createHubFromDapp,
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
44
63
|
|
|
45
64
|
if (kitConfig.extraConnectors?.length) {
|
|
46
65
|
connectors.push(...kitConfig.extraConnectors);
|
|
@@ -84,18 +84,35 @@ export const creditConnectConnector = (
|
|
|
84
84
|
});
|
|
85
85
|
|
|
86
86
|
manager.on('changeState', (state) => {
|
|
87
|
+
// eslint-disable-next-line no-console
|
|
88
|
+
console.log('[CC connector] manager state →', state.status, {
|
|
89
|
+
hasConnectingPayload: Boolean(state.connectingPayload),
|
|
90
|
+
hasSession: 'session' in state && Boolean((state as { session?: unknown }).session),
|
|
91
|
+
});
|
|
87
92
|
if (state.status === 'CONNECTING' && state.connectingPayload) {
|
|
93
|
+
// eslint-disable-next-line no-console
|
|
94
|
+
console.log('[CC connector] emit display_uri', {
|
|
95
|
+
payloadPrefix: state.connectingPayload.slice(0, 80),
|
|
96
|
+
});
|
|
88
97
|
options.onDisplayUri?.(state.connectingPayload);
|
|
89
98
|
emitWagmiMessage?.('display_uri', state.connectingPayload);
|
|
90
99
|
}
|
|
91
100
|
|
|
92
101
|
if (state.status === 'DISCONNECTED' && !isLocalDisconnect) {
|
|
102
|
+
// eslint-disable-next-line no-console
|
|
103
|
+
console.log('[CC connector] DISCONNECTED (non-local) → resetState + emit');
|
|
93
104
|
resetState();
|
|
94
105
|
emitProviderEvent('disconnect');
|
|
95
106
|
emitWagmiDisconnect?.();
|
|
96
107
|
}
|
|
97
108
|
});
|
|
98
109
|
|
|
110
|
+
manager.on('sessionExpired', (payload) => {
|
|
111
|
+
// eslint-disable-next-line no-console
|
|
112
|
+
console.log('[CC connector] session expired → emit session_expired', payload);
|
|
113
|
+
emitWagmiMessage?.('session_expired', payload);
|
|
114
|
+
});
|
|
115
|
+
|
|
99
116
|
return manager;
|
|
100
117
|
};
|
|
101
118
|
|
|
@@ -343,32 +360,13 @@ export const creditConnectConnector = (
|
|
|
343
360
|
});
|
|
344
361
|
}
|
|
345
362
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
if (!connectPromise) {
|
|
350
|
-
connectPromise = new Promise<boolean>((resolve) => {
|
|
351
|
-
const unsub = m.on('changeState', (state) => {
|
|
352
|
-
if (state.status === 'CONNECTED') {
|
|
353
|
-
unsub();
|
|
354
|
-
resolve(true);
|
|
355
|
-
} else if (state.status === 'DISCONNECTED') {
|
|
356
|
-
unsub();
|
|
357
|
-
resolve(false);
|
|
358
|
-
}
|
|
359
|
-
});
|
|
360
|
-
});
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
363
|
+
// 새 connect 시도 — stale state (CONNECTING/CONNECTING_WAITING_PAYLOAD) 와
|
|
364
|
+
// stale connectPromise 는 SDK 의 self-heal (cleanupHub + attempt-id) 에 위임.
|
|
365
|
+
// connector 는 단순히 새 connectNewSession() 을 호출 — 이전 attempt 는 자연 정리됨.
|
|
364
366
|
if (m.state.status !== 'CONNECTED') {
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
}
|
|
369
|
-
connectPromise = m.connectNewSession();
|
|
370
|
-
}
|
|
371
|
-
|
|
367
|
+
// 이전 attempt 의 await 가 살아있어도, SDK 의 attempt-id 패턴이
|
|
368
|
+
// 그 결과를 무시하도록 보장한다. connectPromise 는 무조건 새 promise 로.
|
|
369
|
+
connectPromise = m.connectNewSession();
|
|
372
370
|
const connected = await connectPromise;
|
|
373
371
|
connectPromise = null;
|
|
374
372
|
|
package/src/events/account.ts
CHANGED
|
@@ -2,12 +2,14 @@ import {
|
|
|
2
2
|
watchAccount as wagmiWatchAccount,
|
|
3
3
|
watchConnections as wagmiWatchConnections,
|
|
4
4
|
reconnect as wagmiReconnect,
|
|
5
|
+
connect as wagmiConnect,
|
|
6
|
+
getAccount as wagmiGetAccount,
|
|
5
7
|
disconnect as wagmiDisconnect,
|
|
6
8
|
type WatchAccountParameters,
|
|
7
9
|
type WatchConnectionsParameters,
|
|
8
10
|
type ReconnectReturnType,
|
|
9
11
|
} from '@wagmi/core';
|
|
10
|
-
import { getConfig } from '../core/config';
|
|
12
|
+
import { getConfig, getKitConfig } from '../core/config';
|
|
11
13
|
|
|
12
14
|
export const watchAccount = (params: WatchAccountParameters): (() => void) => {
|
|
13
15
|
return wagmiWatchAccount(getConfig(), params);
|
|
@@ -17,8 +19,33 @@ export const watchConnections = (params: WatchConnectionsParameters): (() => voi
|
|
|
17
19
|
return wagmiWatchConnections(getConfig(), params);
|
|
18
20
|
};
|
|
19
21
|
|
|
20
|
-
export const reconnect = (): Promise<ReconnectReturnType> => {
|
|
21
|
-
|
|
22
|
+
export const reconnect = async (): Promise<ReconnectReturnType> => {
|
|
23
|
+
const config = getConfig();
|
|
24
|
+
|
|
25
|
+
if (getKitConfig().autoConnectInjected !== false) {
|
|
26
|
+
try {
|
|
27
|
+
const injected = config.connectors.find((connector) => connector.type === 'injected');
|
|
28
|
+
if (injected) {
|
|
29
|
+
const provider = (await injected.getProvider()) as
|
|
30
|
+
| { request?: (args: { method: string }) => Promise<unknown> }
|
|
31
|
+
| undefined;
|
|
32
|
+
const accounts = provider?.request
|
|
33
|
+
? await provider.request({ method: 'eth_accounts' })
|
|
34
|
+
: [];
|
|
35
|
+
if (Array.isArray(accounts) && accounts.length > 0) {
|
|
36
|
+
await wagmiConnect(config, { connector: injected });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
// eslint-disable-next-line no-console
|
|
41
|
+
console.warn('injected auto-connect skipped (non-fatal):', error);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (wagmiGetAccount(config).status === 'connected') {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
return wagmiReconnect(config);
|
|
22
49
|
};
|
|
23
50
|
|
|
24
51
|
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
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { type ConnectorId } from '../types';
|
|
3
|
+
|
|
4
|
+
// MM / CC 폴백용 기본 타임아웃. (CC 는 원칙적으로 서버 push 로 만료가 오지만,
|
|
5
|
+
// 서버 값이 없거나 신호가 유실된 경우를 대비한 폴백 용도로도 쓸 수 있음.)
|
|
6
|
+
export const DEFAULT_CONNECT_TIMEOUT_MS = 300_000;
|
|
7
|
+
|
|
8
|
+
interface UseConnectTimeoutParams {
|
|
9
|
+
/** 현재 연결 시도 중인 커넥터. null = 유휴 상태. */
|
|
10
|
+
connectorId: ConnectorId | null;
|
|
11
|
+
/** 타임아웃 감시 활성화 여부 — pending/QR 표시 중일 때 true. */
|
|
12
|
+
active: boolean;
|
|
13
|
+
/** Wagmi connect promise가 실제로 대기 중인지 여부. */
|
|
14
|
+
isConnecting: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* 외부 이벤트 기반 실패/만료 신호. 값이 증가할 때마다 연결 실패로 간주.
|
|
17
|
+
* - CC: dialog 가 connector 의 `session_expired`(서버 push) 를 받아 올림.
|
|
18
|
+
* - MM: connect promise reject.
|
|
19
|
+
* - WC: dialog 가 WC connect promise reject(프로토콜 ~5분 만료 등) 를 받아 올림.
|
|
20
|
+
*/
|
|
21
|
+
expiredSignal?: number;
|
|
22
|
+
/** "Try Again" 시 실행할 재연결 로직 (dialog 가 주입). */
|
|
23
|
+
onRetry?: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface UseConnectTimeoutResult {
|
|
27
|
+
/** 타임아웃 발생 여부 — true 면 dialog 가 <Timeout> 을 렌더. */
|
|
28
|
+
timedOut: boolean;
|
|
29
|
+
/** 다른 지갑 연결을 시작하기 전에 이전 타임아웃 상태를 즉시 초기화. */
|
|
30
|
+
reset: () => void;
|
|
31
|
+
/** "Try Again" 버튼에 연결할 함수 — 상태 리셋 + onRetry 실행. */
|
|
32
|
+
retry: () => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 커넥터별로 타임아웃 신호원을 분기해 하나의 `timedOut` 으로 수렴시키는 훅.
|
|
37
|
+
* - CC: 서버 push (`OnSessionExpired` → connector → expiredSignal).
|
|
38
|
+
* - MM: 네이티브 타임아웃 없음 → client 타이머(DEFAULT_CONNECT_TIMEOUT_MS).
|
|
39
|
+
* - WC: 프로토콜 자체 ~5분 proposal reject → connect promise reject → expiredSignal.
|
|
40
|
+
*/
|
|
41
|
+
export const useConnectTimeout = ({
|
|
42
|
+
connectorId,
|
|
43
|
+
active,
|
|
44
|
+
isConnecting,
|
|
45
|
+
expiredSignal = 0,
|
|
46
|
+
onRetry,
|
|
47
|
+
}: UseConnectTimeoutParams): UseConnectTimeoutResult => {
|
|
48
|
+
const [timedOut, setTimedOut] = useState(false);
|
|
49
|
+
|
|
50
|
+
const fire = useCallback(() => {
|
|
51
|
+
setTimedOut(true);
|
|
52
|
+
}, []);
|
|
53
|
+
|
|
54
|
+
// 커넥터가 바뀌면(=새 연결 시도) 타임아웃 상태 리셋.
|
|
55
|
+
// active 가 false 로 떨어질 때는 리셋하지 않는다 — timeout UI 표시 중 상태가 날아가지 않도록.
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
setTimedOut(false);
|
|
58
|
+
}, [connectorId]);
|
|
59
|
+
|
|
60
|
+
// MM 만 client 타이머로 강제 (승인 대기에 네이티브 상한이 없음).
|
|
61
|
+
// CC(서버 push) / WC(connect reject) 는 아래 expiredSignal effect 에서 처리.
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!active || !isConnecting || connectorId !== 'METAMASK') return;
|
|
64
|
+
const timer = setTimeout(fire, DEFAULT_CONNECT_TIMEOUT_MS);
|
|
65
|
+
return () => clearTimeout(timer);
|
|
66
|
+
}, [active, isConnecting, connectorId, fire]);
|
|
67
|
+
|
|
68
|
+
// 외부 실패/만료 신호 처리 (CC 서버 push / MM·WC connect reject).
|
|
69
|
+
// signal 값이 올라가면(새 만료 이벤트) 발화.
|
|
70
|
+
const prevSignal = useRef(expiredSignal);
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
if (!active || !connectorId) {
|
|
73
|
+
prevSignal.current = expiredSignal;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (expiredSignal !== prevSignal.current) {
|
|
77
|
+
prevSignal.current = expiredSignal;
|
|
78
|
+
fire();
|
|
79
|
+
}
|
|
80
|
+
}, [expiredSignal, connectorId, active, fire]);
|
|
81
|
+
|
|
82
|
+
const retry = useCallback(() => {
|
|
83
|
+
setTimedOut(false);
|
|
84
|
+
onRetry?.();
|
|
85
|
+
}, [onRetry]);
|
|
86
|
+
|
|
87
|
+
const reset = useCallback(() => {
|
|
88
|
+
setTimedOut(false);
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
return { timedOut, reset, retry };
|
|
92
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
const METAMASK_RDNS = 'io.metamask';
|
|
4
|
+
|
|
5
|
+
// EIP-6963 응답의 rdns 로 MetaMask 확장을 식별.
|
|
6
|
+
const isMetaMaskAnnouncement = (event: Event): boolean => {
|
|
7
|
+
const detail = (event as CustomEvent<{ info?: { rdns?: string }; provider?: unknown }>).detail;
|
|
8
|
+
return detail?.info?.rdns?.toLowerCase() === METAMASK_RDNS && detail.provider != null;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const useMetaMaskExtension = (): boolean => {
|
|
12
|
+
const [isInstalled, setIsInstalled] = useState(false);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const handleAnnouncement = (event: Event): void => {
|
|
16
|
+
if (isMetaMaskAnnouncement(event)) setIsInstalled(true);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// 응답 listener를 먼저 등록한 뒤 설치된 provider 목록을 요청.
|
|
20
|
+
window.addEventListener('eip6963:announceProvider', handleAnnouncement);
|
|
21
|
+
window.dispatchEvent(new Event('eip6963:requestProvider'));
|
|
22
|
+
|
|
23
|
+
return () => {
|
|
24
|
+
window.removeEventListener('eip6963:announceProvider', handleAnnouncement);
|
|
25
|
+
};
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
return isInstalled;
|
|
29
|
+
};
|
package/src/hooks/useWCState.ts
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
2
2
|
import type { WCWallet, WCSubView } from '../types';
|
|
3
|
+
// TODO-CRCN-11: WC에 PenguinWallet 추가 시 변경
|
|
4
|
+
import { CREDIT_WALLET_LISTING_ID, overrideWalletDisplay } from '../wallet-display-override';
|
|
5
|
+
|
|
6
|
+
const sortCreditWalletFirst = (list: WCWallet[]): WCWallet[] =>
|
|
7
|
+
[...list].sort((a, b) => {
|
|
8
|
+
// TODO-CRCN-11: WC에 PenguinWallet 추가 시 변경
|
|
9
|
+
if (a.id === CREDIT_WALLET_LISTING_ID) return -1;
|
|
10
|
+
if (b.id === CREDIT_WALLET_LISTING_ID) return 1;
|
|
11
|
+
return 0;
|
|
12
|
+
});
|
|
3
13
|
|
|
4
14
|
const fetchWCWalletList = async (projectId: string, signal?: AbortSignal): Promise<WCWallet[]> => {
|
|
5
|
-
|
|
15
|
+
// WC Explorer API 가 page 경계에서 같은 wallet 을 중복 emit 하는 케이스 관찰됨
|
|
16
|
+
// (paginate 사이 sort/dedup 보장 X). id 기준 first-write-wins dedup.
|
|
17
|
+
const byId = new Map<string, WCWallet>();
|
|
6
18
|
let page = 1;
|
|
7
19
|
const entries = 100;
|
|
8
20
|
|
|
@@ -34,7 +46,8 @@ const fetchWCWalletList = async (projectId: string, signal?: AbortSignal): Promi
|
|
|
34
46
|
|
|
35
47
|
const listings = Object.values(json.listings ?? {});
|
|
36
48
|
for (const w of listings) {
|
|
37
|
-
|
|
49
|
+
if (byId.has(w.id)) continue;
|
|
50
|
+
byId.set(w.id, {
|
|
38
51
|
id: w.id,
|
|
39
52
|
name: w.name,
|
|
40
53
|
imageUrl: w.image_url?.md ?? w.image_url?.sm ?? '',
|
|
@@ -48,11 +61,11 @@ const fetchWCWalletList = async (projectId: string, signal?: AbortSignal): Promi
|
|
|
48
61
|
});
|
|
49
62
|
}
|
|
50
63
|
|
|
51
|
-
if (listings.length < entries ||
|
|
64
|
+
if (listings.length < entries || byId.size >= json.total) break;
|
|
52
65
|
page += 1;
|
|
53
66
|
}
|
|
54
67
|
|
|
55
|
-
return
|
|
68
|
+
return Array.from(byId.values());
|
|
56
69
|
};
|
|
57
70
|
|
|
58
71
|
export interface WCState {
|
|
@@ -100,7 +113,10 @@ export const useWCState = (): WCState => {
|
|
|
100
113
|
setWalletListLoading(true);
|
|
101
114
|
try {
|
|
102
115
|
const list = await fetchWCWalletList(projectId, controller.signal);
|
|
103
|
-
|
|
116
|
+
// TODO-CRCN-11: WC에 PenguinWallet 추가 시 변경
|
|
117
|
+
const sorted = sortCreditWalletFirst(list).map(overrideWalletDisplay);
|
|
118
|
+
// Credit Wallet 을 최상단으로 정렬한 상태로 state 저장 — view 측은 정렬 신경 X.
|
|
119
|
+
if (isMountedRef.current) setWalletList(sorted);
|
|
104
120
|
} catch (err) {
|
|
105
121
|
const isAbort = err instanceof DOMException && err.name === 'AbortError';
|
|
106
122
|
if (!isAbort) loadedRef.current = false;
|
|
@@ -13,15 +13,10 @@ const hasMetaMaskKeyword = (value: string): boolean => value.toLowerCase().inclu
|
|
|
13
13
|
const hasWalletConnectKeyword = (value: string): boolean =>
|
|
14
14
|
value.toLowerCase().includes('walletconnect');
|
|
15
15
|
|
|
16
|
+
// 전역 provider 대신 wagmi connector 메타데이터로 MetaMask를 판별.
|
|
16
17
|
const isMetaMaskConnector = (connector: WagmiConnectorLike): boolean => {
|
|
17
18
|
if (connector.id === 'metaMaskSDK') return true;
|
|
18
19
|
|
|
19
|
-
if (connector.id === 'injected') {
|
|
20
|
-
if (typeof window === 'undefined') return false;
|
|
21
|
-
const eth = (window as Window & { ethereum?: { isMetaMask?: boolean } }).ethereum;
|
|
22
|
-
return eth?.isMetaMask === true;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
20
|
if (hasMetaMaskKeyword(connector.id) || hasMetaMaskKeyword(connector.name)) return true;
|
|
26
21
|
|
|
27
22
|
if (connector.rdns) {
|
|
@@ -49,7 +44,6 @@ export const resolveWagmiConnector = <T extends WagmiConnectorLike>(
|
|
|
49
44
|
case 'METAMASK':
|
|
50
45
|
return connectors.find(isMetaMaskConnector);
|
|
51
46
|
|
|
52
|
-
case 'CREDIT_WALLET':
|
|
53
47
|
case 'WALLET_CONNECT':
|
|
54
48
|
return connectors.find(isWalletConnectConnector);
|
|
55
49
|
|
|
@@ -62,11 +56,13 @@ interface Options {
|
|
|
62
56
|
onConnect: (result: ConnectResult) => void;
|
|
63
57
|
onError: (error: Error, connectorId: ConnectorId) => void;
|
|
64
58
|
onQrUri: (uri: string | null) => void;
|
|
59
|
+
onSessionExpired?: () => void;
|
|
65
60
|
}
|
|
66
61
|
|
|
67
62
|
interface UseWagmiConnectResult {
|
|
68
63
|
triggerConnect: (connectorId: ConnectorId) => void;
|
|
69
64
|
cancelConnect: () => void;
|
|
65
|
+
restartConnect: (connectorId: ConnectorId) => Promise<void>;
|
|
70
66
|
isConnecting: boolean;
|
|
71
67
|
}
|
|
72
68
|
|
|
@@ -74,6 +70,7 @@ export const useWagmiConnect = ({
|
|
|
74
70
|
onConnect,
|
|
75
71
|
onError,
|
|
76
72
|
onQrUri,
|
|
73
|
+
onSessionExpired,
|
|
77
74
|
}: Options): UseWagmiConnectResult => {
|
|
78
75
|
const config = useConfig();
|
|
79
76
|
const pendingConnectorId = useRef<ConnectorId | null>(null);
|
|
@@ -82,11 +79,13 @@ export const useWagmiConnect = ({
|
|
|
82
79
|
const onConnectRef = useRef(onConnect);
|
|
83
80
|
const onErrorRef = useRef(onError);
|
|
84
81
|
const onQrUriRef = useRef(onQrUri);
|
|
82
|
+
const onSessionExpiredRef = useRef(onSessionExpired);
|
|
85
83
|
useEffect(() => {
|
|
86
84
|
onConnectRef.current = onConnect;
|
|
87
85
|
onErrorRef.current = onError;
|
|
88
86
|
onQrUriRef.current = onQrUri;
|
|
89
|
-
|
|
87
|
+
onSessionExpiredRef.current = onSessionExpired;
|
|
88
|
+
}, [onConnect, onError, onQrUri, onSessionExpired]);
|
|
90
89
|
|
|
91
90
|
const { connectAsync, reset, isPending } = useConnect();
|
|
92
91
|
|
|
@@ -96,6 +95,9 @@ export const useWagmiConnect = ({
|
|
|
96
95
|
if (msg?.type === 'display_uri' && typeof msg.data === 'string') {
|
|
97
96
|
onQrUriRef.current(msg.data);
|
|
98
97
|
}
|
|
98
|
+
if (msg?.type === 'session_expired') {
|
|
99
|
+
onSessionExpiredRef.current?.();
|
|
100
|
+
}
|
|
99
101
|
};
|
|
100
102
|
|
|
101
103
|
const unsubscribers = config.connectors.map((connector) => {
|
|
@@ -149,5 +151,29 @@ export const useWagmiConnect = ({
|
|
|
149
151
|
reset();
|
|
150
152
|
}, [reset]);
|
|
151
153
|
|
|
152
|
-
|
|
154
|
+
const restartConnect = useCallback(
|
|
155
|
+
async (connectorId: ConnectorId): Promise<void> => {
|
|
156
|
+
const connector = resolveWagmiConnector(config.connectors, connectorId);
|
|
157
|
+
|
|
158
|
+
cancelConnect();
|
|
159
|
+
const restartAttempt = attemptCounter.current;
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
// MetaMask SDK connector.disconnect()는 sdk.terminate()를 호출해
|
|
163
|
+
// 기존 QR/Deeplink 연결 채널을 정리한다.
|
|
164
|
+
await connector?.disconnect();
|
|
165
|
+
} catch (error) {
|
|
166
|
+
onErrorRef.current(error as Error, connectorId);
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// SDK 정리 중 다른 지갑을 선택하거나 모달을 닫았으면 재연결하지 않는다.
|
|
171
|
+
if (attemptCounter.current !== restartAttempt) return;
|
|
172
|
+
|
|
173
|
+
triggerConnect(connectorId);
|
|
174
|
+
},
|
|
175
|
+
[config.connectors, cancelConnect, triggerConnect],
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
return { triggerConnect, cancelConnect, restartConnect, isConnecting: isPending };
|
|
153
179
|
};
|