@gluwa/connect-kit 0.1.0-next.1 → 0.1.0-next.2

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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # @gluwa/wallet-kit
2
2
 
3
+ ## 0.1.0-next.2
4
+
5
+ ### Patch Changes
6
+
7
+ - d44580e: add provider
8
+
3
9
  ## 0.1.0-next.1
4
10
 
5
11
  ### Patch Changes
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { FC } from 'react';
1
+ import { ReactNode, FC } from 'react';
2
2
  import { createConnector } from '@wagmi/core';
3
3
  import { StorageProvider } from '@gluwa/credit-connect-sdk/storage';
4
4
  import { E2EEProvider } from '@gluwa/credit-connect-sdk/e2ee';
@@ -28,6 +28,14 @@ interface ConnectResult {
28
28
  address: `0x${string}`;
29
29
  connectorId: ConnectorId;
30
30
  }
31
+ type ConnectErrorReason = 'rejected' | 'unsupported' | 'unknown';
32
+ interface ConnectErrorContext {
33
+ connectorId: ConnectorId;
34
+ reason: ConnectErrorReason;
35
+ error: Error;
36
+ retry: () => void;
37
+ dismiss: () => void;
38
+ }
31
39
  interface ConnectModalProps {
32
40
  open: boolean;
33
41
  connectors: Connectors;
@@ -35,10 +43,45 @@ interface ConnectModalProps {
35
43
  onClose: () => void;
36
44
  onLog?: (message: string, error?: Error) => void;
37
45
  wcProjectId?: string;
46
+ requiredChainId?: number;
47
+ renderConnectError?: (ctx: ConnectErrorContext) => ReactNode;
38
48
  }
39
49
 
40
50
  declare const ConnectModal: FC<ConnectModalProps>;
41
51
 
52
+ interface ConnectKitProviderProps {
53
+ children: ReactNode;
54
+ connectors: Connectors;
55
+ wcProjectId?: string;
56
+ /**
57
+ * 연결돼 있는 동안 강제할 체인 ID. 다른 체인에 연결돼 있으면
58
+ * 자동으로 스위치 모달이 떠서 사용자에게 전환 또는 disconnect를 요구.
59
+ */
60
+ requiredChainId?: number;
61
+ /**
62
+ * 연결이 정상적으로 끝나고(체인 검증까지 통과) "사용 가능한 상태"가 됐을 때 한 번 호출.
63
+ * 재연결(reconnect)에서는 호출되지 않음 — 그쪽은 dApp의 watchAccount 책임.
64
+ */
65
+ onConnect?: (result: ConnectResult) => void;
66
+ onLog?: (message: string, error?: Error) => void;
67
+ /**
68
+ * 연결 실패(거절/미설치/기타) 시 모달 안에 표시할 fallback UI를 dApp이 직접 그리고 싶을 때.
69
+ * 미제공 시 기본 동작은 selector로 복귀.
70
+ */
71
+ renderConnectError?: (ctx: ConnectErrorContext) => ReactNode;
72
+ }
73
+ interface ConnectKitContextValue {
74
+ isOpen: boolean;
75
+ open: () => void;
76
+ close: () => void;
77
+ isConnected: boolean;
78
+ address: `0x${string}` | undefined;
79
+ connectorId: ConnectorId | null;
80
+ disconnect: () => Promise<void>;
81
+ }
82
+ declare const ConnectKitProvider: FC<ConnectKitProviderProps>;
83
+ declare const useConnectKit: () => ConnectKitContextValue;
84
+
42
85
  type CreditConnectConnectorOptions = {
43
86
  projectKey: string;
44
87
  serverUrl: string;
@@ -49,4 +92,4 @@ type CreditConnectConnectorOptions = {
49
92
  };
50
93
  declare const creditConnectConnector: (options: CreditConnectConnectorOptions) => ReturnType<typeof createConnector>;
51
94
 
52
- export { ConnectModal, type ConnectModalProps, type ConnectResult, type ConnectorId, type Connectors, type CreditConnectConnectorOptions, type CreditWalletStrategy, type WCSubView, type WCWallet, creditConnectConnector };
95
+ export { type ConnectErrorContext, type ConnectErrorReason, type ConnectKitContextValue, ConnectKitProvider, type ConnectKitProviderProps, ConnectModal, type ConnectModalProps, type ConnectResult, type ConnectorId, type Connectors, type CreditConnectConnectorOptions, type CreditWalletStrategy, type WCSubView, type WCWallet, creditConnectConnector, useConnectKit };
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ var __defProp = Object.defineProperty;
3
3
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
4
4
 
5
5
  // src/ConnectModal.tsx
6
- import { useState as useState2, useEffect as useEffect4, useCallback as useCallback3, useRef as useRef3, useMemo as useMemo4 } from "react";
6
+ import { useState as useState3, useEffect as useEffect4, useCallback as useCallback3, useRef as useRef3, useMemo as useMemo4 } from "react";
7
7
 
8
8
  // src/ConnectModal.scss
9
9
  var css = `.ck-root {
@@ -141,6 +141,42 @@ var css = `.ck-root {
141
141
  .ck-btn-primary:hover {
142
142
  background: rgba(255, 255, 255, 0.22);
143
143
  }
144
+ .ck-btn-primary:disabled {
145
+ opacity: 0.6;
146
+ cursor: not-allowed;
147
+ }
148
+
149
+ .ck-btn-secondary {
150
+ display: inline-flex;
151
+ align-items: center;
152
+ justify-content: center;
153
+ padding: 8px 16px;
154
+ border-radius: 8px;
155
+ font-size: 14px;
156
+ font-weight: 500;
157
+ cursor: pointer;
158
+ white-space: nowrap;
159
+ color: rgba(255, 255, 255, 0.75);
160
+ background: transparent;
161
+ border: 1px solid rgba(255, 255, 255, 0.18);
162
+ }
163
+ .ck-btn-secondary:hover {
164
+ color: #fff;
165
+ border-color: rgba(255, 255, 255, 0.32);
166
+ }
167
+
168
+ .ck-view__actions {
169
+ display: flex;
170
+ flex-direction: column;
171
+ gap: 8px;
172
+ width: 100%;
173
+ margin-top: 16px;
174
+ }
175
+
176
+ .ck-view__caption--error {
177
+ color: #ff8080;
178
+ margin-top: 8px;
179
+ }
144
180
 
145
181
  .ck-connector-list {
146
182
  display: flex;
@@ -584,7 +620,7 @@ var css = `.ck-root {
584
620
  document.head.appendChild(document.createElement("style")).appendChild(document.createTextNode(css));
585
621
 
586
622
  // src/ConnectModal.tsx
587
- import { useConfig as useConfig2 } from "wagmi";
623
+ import { useAccount, useConfig as useConfig2, useDisconnect } from "wagmi";
588
624
 
589
625
  // assets/creditwallet.png
590
626
  var creditwallet_default = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACQAAAAkCAYAAADhAJiYAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAOdEVYdFNvZnR3YXJlAEZpZ21hnrGWYwAAAtNJREFUeAHVWDFs00AUfWe7oYVSWdAxQEfUCZiQqEQykDUdGBAL0IERlQGxMbAhBpDYGEBMMDCEtUtbqZXYuiHGCDEymDZqqZr4+N/OuW579jmO5aRPcs8+n68v/7//798BYwah65x23Rp8NOltjQa4UmIORfwzgbYE2kJSa+FTx/PWToyJP7iuO9eV+AiJGsqAQMsReOp5XvuwK0amJ7FalDWyc0LbtlBXpCz1gizzpmwyDHJh6JVDgpFmVjFKkJVYUw7fCx8P5GCfY/r9Ezjzl7TvvIVnGBg+FulvSEgKclUGRhM3r0b3cnsX/u8/xnE8ppcw7ggEbnMTWoj8aOJjVWdx/svz6Hnn3iscfP+pHXuhHUkCe2+/0dWCkY+E2+dC5p9xU/lMLjUgZs4e6dv/upFooanlxRN9+x9W4JNV09DZ9kQmQu7G66DNow0mN7XcDL71Da4zEjpzd4GuW2SNTXR//EKPrkHBrrbpqtA8bOXO43dII2SZJnNIoKyVPGQYbBX+noXuzF82jrcwZnB0nXbfMgwWLwxizIKDlS3KLObcotUQm5dD3BSylcYNbfTlBWvIwRCYXLoTWbIIQoyhCOmgUoQJOxRtukAZmaitY65WKNxCuRbWGIYixKK3qpsoEtoo48jhCOLEaFUvYu/lZ+M6ZMK5F/eDsN+luZKQmKm5tFDRwssHEvw9CCYa14MfacLpyNQKvA51aR2y+wtkUv2TBjtw+2zmQu10lR8KZRZoYQnLO8qULdA/mozNHs/C7MpkQs3oPnMJS/uzfhtYaIuaa6kfkIXi9QwXXKwPHZiEAuvGz1bkr3X+enUl6nUTIU4FcVFXKB1YCYTyiJ/3+yEvjNdGMchDwSkEmQyjAh06qJOQKDHSKcQjFjdKBouZT0DUc0SITx9sgTqzRVkgr8RPPsIuDVhTtN9/SFvsK8GuttgDK4/W2HUyRUt3YDV2+A99bkdtt/T4QQAAAABJRU5ErkJggg==";
@@ -1168,6 +1204,50 @@ var WalletQRView = /* @__PURE__ */ __name(({ wallet, qrUri }) => {
1168
1204
  }, "Download")));
1169
1205
  }, "WalletQRView");
1170
1206
 
1207
+ // src/views/SwitchChainView.tsx
1208
+ import { useState as useState2 } from "react";
1209
+ import { useSwitchChain } from "wagmi";
1210
+ var SwitchChainView = /* @__PURE__ */ __name(({ currentChainId, requiredChainId, requiredChainName, onDisconnect, onLog }) => {
1211
+ const { switchChainAsync, isPending } = useSwitchChain();
1212
+ const [error, setError] = useState2(null);
1213
+ const targetLabel = requiredChainName != null ? requiredChainName : `chain ${requiredChainId}`;
1214
+ const handleSwitch = /* @__PURE__ */ __name(() => {
1215
+ setError(null);
1216
+ switchChainAsync({
1217
+ chainId: requiredChainId
1218
+ }).catch((err) => {
1219
+ const normalized = err instanceof Error ? err : new Error(String(err));
1220
+ const code = typeof err === "object" && err !== null && "code" in err ? err.code : void 0;
1221
+ if (code === 4902) {
1222
+ onLog == null ? void 0 : onLog("[connect-kit] chain not registered in wallet (4902)", normalized);
1223
+ } else {
1224
+ onLog == null ? void 0 : onLog(`[connect-kit] switch chain failed: ${normalized.message}`, normalized);
1225
+ }
1226
+ setError(normalized);
1227
+ });
1228
+ }, "handleSwitch");
1229
+ return /* @__PURE__ */ React.createElement("div", {
1230
+ className: "ck-view ck-view--switch-chain"
1231
+ }, /* @__PURE__ */ React.createElement("h4", {
1232
+ className: "ck-view__title"
1233
+ }, "Wrong network"), /* @__PURE__ */ React.createElement("p", {
1234
+ className: "ck-view__description"
1235
+ }, currentChainId != null ? `Connected to chain ${currentChainId}. Please switch to ${targetLabel} to continue.` : `Please switch to ${targetLabel} to continue.`), /* @__PURE__ */ React.createElement("div", {
1236
+ className: "ck-view__actions"
1237
+ }, /* @__PURE__ */ React.createElement("button", {
1238
+ type: "button",
1239
+ className: "ck-btn-primary",
1240
+ onClick: handleSwitch,
1241
+ disabled: isPending
1242
+ }, isPending ? "Waiting for wallet\u2026" : `Switch to ${targetLabel}`), /* @__PURE__ */ React.createElement("button", {
1243
+ type: "button",
1244
+ className: "ck-btn-secondary",
1245
+ onClick: onDisconnect
1246
+ }, "Disconnect")), error && /* @__PURE__ */ React.createElement("p", {
1247
+ className: "ck-view__caption ck-view__caption--error"
1248
+ }, error.message));
1249
+ }, "SwitchChainView");
1250
+
1171
1251
  // src/ConnectModal.tsx
1172
1252
  var RECENT_KEY = "connect-kit:recent";
1173
1253
  var readRecentConnector = /* @__PURE__ */ __name(() => {
@@ -1183,6 +1263,15 @@ var writeRecentConnector = /* @__PURE__ */ __name((id) => {
1183
1263
  } catch {
1184
1264
  }
1185
1265
  }, "writeRecentConnector");
1266
+ var classifyError = /* @__PURE__ */ __name((error) => {
1267
+ var _a, _b;
1268
+ const code = typeof error === "object" && error !== null && "code" in error ? error.code : void 0;
1269
+ if (code === 4001) return "rejected";
1270
+ const message = (_b = (_a = error.message) == null ? void 0 : _a.toLowerCase()) != null ? _b : "";
1271
+ if (message.includes("user rejected") || message.includes("user denied")) return "rejected";
1272
+ if (message.includes("unsupported") || message.includes("not installed")) return "unsupported";
1273
+ return "unknown";
1274
+ }, "classifyError");
1186
1275
  var SelectorPane = /* @__PURE__ */ __name(({ connectors, selected, recentConnector, onSelect, onClose }) => {
1187
1276
  const hasRecent = recentConnector !== null && connectors.includes(recentConnector);
1188
1277
  const recentConnectors = hasRecent ? [
@@ -1248,7 +1337,7 @@ var ConnectorItem = /* @__PURE__ */ __name(({ id, isSelected, isRecent, onSelect
1248
1337
  className: "ck-connector-item__badge"
1249
1338
  }, "Recent"));
1250
1339
  }, "ConnectorItem");
1251
- var DetailPane = /* @__PURE__ */ __name(({ selectedConnector, qrUri, hasMetaMaskExtension, connectorLogoMap, wc, onBack, onClose }) => {
1340
+ var DetailPane = /* @__PURE__ */ __name(({ selectedConnector, qrUri, hasMetaMaskExtension, connectorLogoMap, wc, errorOverride, onBack, onClose }) => {
1252
1341
  const title = getDetailTitle(selectedConnector, hasMetaMaskExtension, wc.subView, wc.selectedWallet);
1253
1342
  const showBack = selectedConnector !== null;
1254
1343
  return /* @__PURE__ */ React.createElement("div", {
@@ -1269,7 +1358,7 @@ var DetailPane = /* @__PURE__ */ __name(({ selectedConnector, qrUri, hasMetaMask
1269
1358
  "aria-label": "Close"
1270
1359
  })), /* @__PURE__ */ React.createElement("div", {
1271
1360
  className: "ck-pane__body"
1272
- }, !selectedConnector && /* @__PURE__ */ React.createElement(IdleView, null), (selectedConnector === "CREDIT_CONNECT" || selectedConnector === "CREDIT_WALLET") && /* @__PURE__ */ React.createElement(CreditWalletView, {
1361
+ }, errorOverride || /* @__PURE__ */ React.createElement(React.Fragment, null, !selectedConnector && /* @__PURE__ */ React.createElement(IdleView, null), (selectedConnector === "CREDIT_CONNECT" || selectedConnector === "CREDIT_WALLET") && /* @__PURE__ */ React.createElement(CreditWalletView, {
1273
1362
  connectorId: selectedConnector,
1274
1363
  qrUri
1275
1364
  }), selectedConnector === "METAMASK" && /* @__PURE__ */ React.createElement(MetaMaskView, {
@@ -1289,7 +1378,7 @@ var DetailPane = /* @__PURE__ */ __name(({ selectedConnector, qrUri, hasMetaMask
1289
1378
  onSelectWallet: wc.onSelectWallet,
1290
1379
  onSearchChange: wc.onSearchChange,
1291
1380
  onFilterToggle: wc.onFilterToggle
1292
- })));
1381
+ }))));
1293
1382
  }, "DetailPane");
1294
1383
  var getDetailTitle = /* @__PURE__ */ __name((connector, hasMetaMaskExtension, wcSubView, wcSelectedWallet) => {
1295
1384
  if (!connector) return "";
@@ -1324,14 +1413,27 @@ var ConnectModal = /* @__PURE__ */ __name((props) => {
1324
1413
  connectors: resolvedConnectors
1325
1414
  });
1326
1415
  }, "ConnectModal");
1327
- var ConnectModalInner = /* @__PURE__ */ __name(({ open, connectors, onConnect, onClose, onLog, wcProjectId }) => {
1416
+ var ConnectModalInner = /* @__PURE__ */ __name(({ open, connectors, onConnect, onClose, onLog, wcProjectId, requiredChainId, renderConnectError }) => {
1328
1417
  var _a;
1329
- const [selectedConnector, setSelectedConnector] = useState2(null);
1330
- const [qrUriMap, setQrUriMap] = useState2({});
1418
+ const [selectedConnector, setSelectedConnector] = useState3(null);
1419
+ const [qrUriMap, setQrUriMap] = useState3({});
1331
1420
  const pendingQrConnectorRef = useRef3(null);
1332
- const [recentConnector, setRecentConnector] = useState2(null);
1333
- const [hasMetaMaskExtension, setHasMetaMaskExtension] = useState2(false);
1421
+ const [recentConnector, setRecentConnector] = useState3(null);
1422
+ const [hasMetaMaskExtension, setHasMetaMaskExtension] = useState3(false);
1423
+ const [errorState, setErrorState] = useState3(null);
1334
1424
  const wc = useWCState();
1425
+ const account = useAccount();
1426
+ const { disconnectAsync } = useDisconnect();
1427
+ const wagmiConfig = useConfig2();
1428
+ const chainMismatch = requiredChainId != null && account.status === "connected" && account.chainId != null && account.chainId !== requiredChainId;
1429
+ const requiredChainName = useMemo4(() => {
1430
+ var _a2;
1431
+ if (requiredChainId == null) return void 0;
1432
+ return (_a2 = wagmiConfig.chains.find((chain) => chain.id === requiredChainId)) == null ? void 0 : _a2.name;
1433
+ }, [
1434
+ wagmiConfig.chains,
1435
+ requiredChainId
1436
+ ]);
1335
1437
  const handleQrUri = useCallback3((uri) => {
1336
1438
  if (uri) {
1337
1439
  const id = pendingQrConnectorRef.current;
@@ -1342,6 +1444,7 @@ var ConnectModalInner = /* @__PURE__ */ __name(({ open, connectors, onConnect, o
1342
1444
  }
1343
1445
  }, []);
1344
1446
  const handleConnect = useCallback3((result) => {
1447
+ setErrorState(null);
1345
1448
  onConnect(result);
1346
1449
  onClose();
1347
1450
  }, [
@@ -1349,8 +1452,8 @@ var ConnectModalInner = /* @__PURE__ */ __name(({ open, connectors, onConnect, o
1349
1452
  onClose
1350
1453
  ]);
1351
1454
  const handleError = useCallback3((error, connectorId) => {
1352
- onLog == null ? void 0 : onLog(`[connect-kit] connection failed (${connectorId}): ${error.message}`, error);
1353
- setSelectedConnector(null);
1455
+ const normalizedError = error instanceof Error ? error : new Error(String(error));
1456
+ onLog == null ? void 0 : onLog(`[connect-kit] connection failed (${connectorId}): ${normalizedError.message}`, normalizedError);
1354
1457
  setQrUriMap((prev) => {
1355
1458
  const next = {
1356
1459
  ...prev
@@ -1359,16 +1462,24 @@ var ConnectModalInner = /* @__PURE__ */ __name(({ open, connectors, onConnect, o
1359
1462
  return next;
1360
1463
  });
1361
1464
  wc.resetView();
1465
+ if (renderConnectError) {
1466
+ setErrorState({
1467
+ connectorId,
1468
+ error: normalizedError
1469
+ });
1470
+ } else {
1471
+ setSelectedConnector(null);
1472
+ }
1362
1473
  }, [
1363
1474
  onLog,
1364
- wc.resetView
1475
+ wc.resetView,
1476
+ renderConnectError
1365
1477
  ]);
1366
1478
  const { triggerConnect, cancelConnect } = useWagmiConnect({
1367
1479
  onConnect: handleConnect,
1368
1480
  onError: handleError,
1369
1481
  onQrUri: handleQrUri
1370
1482
  });
1371
- const wagmiConfig = useConfig2();
1372
1483
  const connectorLogoMap = useMemo4(() => {
1373
1484
  const map = {};
1374
1485
  const connectorIds = [
@@ -1394,6 +1505,7 @@ var ConnectModalInner = /* @__PURE__ */ __name(({ open, connectors, onConnect, o
1394
1505
  setSelectedConnector(null);
1395
1506
  setQrUriMap({});
1396
1507
  pendingQrConnectorRef.current = null;
1508
+ setErrorState(null);
1397
1509
  wc.reset();
1398
1510
  cancelConnect();
1399
1511
  }
@@ -1403,9 +1515,10 @@ var ConnectModalInner = /* @__PURE__ */ __name(({ open, connectors, onConnect, o
1403
1515
  wc.reset
1404
1516
  ]);
1405
1517
  const handleSelectConnector = useCallback3((id) => {
1406
- if (id === selectedConnector) return;
1518
+ if (id === selectedConnector && !errorState) return;
1407
1519
  pendingQrConnectorRef.current = id;
1408
1520
  setSelectedConnector(id);
1521
+ setErrorState(null);
1409
1522
  wc.resetView();
1410
1523
  writeRecentConnector(id);
1411
1524
  setRecentConnector(id);
@@ -1416,6 +1529,7 @@ var ConnectModalInner = /* @__PURE__ */ __name(({ open, connectors, onConnect, o
1416
1529
  triggerConnect(id);
1417
1530
  }, [
1418
1531
  selectedConnector,
1532
+ errorState,
1419
1533
  triggerConnect,
1420
1534
  cancelConnect,
1421
1535
  wcProjectId,
@@ -1423,22 +1537,112 @@ var ConnectModalInner = /* @__PURE__ */ __name(({ open, connectors, onConnect, o
1423
1537
  wc.loadWalletList
1424
1538
  ]);
1425
1539
  const handleBack = useCallback3(() => {
1540
+ if (errorState) {
1541
+ setErrorState(null);
1542
+ setSelectedConnector(null);
1543
+ return;
1544
+ }
1426
1545
  if (selectedConnector === "WALLET_CONNECT" && wc.handleBack()) return;
1427
1546
  pendingQrConnectorRef.current = null;
1428
1547
  setSelectedConnector(null);
1429
1548
  cancelConnect();
1430
1549
  }, [
1431
1550
  selectedConnector,
1551
+ errorState,
1432
1552
  wc.handleBack,
1433
1553
  cancelConnect
1434
1554
  ]);
1555
+ const handleSwitchChainDisconnect = useCallback3(() => {
1556
+ disconnectAsync().catch((err) => {
1557
+ const normalized = err instanceof Error ? err : new Error(String(err));
1558
+ onLog == null ? void 0 : onLog(`[connect-kit] disconnect failed: ${normalized.message}`, normalized);
1559
+ });
1560
+ }, [
1561
+ disconnectAsync,
1562
+ onLog
1563
+ ]);
1564
+ const handleOverlayClick = useCallback3(() => {
1565
+ if (chainMismatch) return;
1566
+ onClose();
1567
+ }, [
1568
+ chainMismatch,
1569
+ onClose
1570
+ ]);
1571
+ const handleCloseClick = useCallback3(() => {
1572
+ if (chainMismatch) {
1573
+ handleSwitchChainDisconnect();
1574
+ return;
1575
+ }
1576
+ onClose();
1577
+ }, [
1578
+ chainMismatch,
1579
+ handleSwitchChainDisconnect,
1580
+ onClose
1581
+ ]);
1582
+ const renderedError = useMemo4(() => {
1583
+ if (!errorState || !renderConnectError) return null;
1584
+ const ctx = {
1585
+ connectorId: errorState.connectorId,
1586
+ reason: classifyError(errorState.error),
1587
+ error: errorState.error,
1588
+ retry: /* @__PURE__ */ __name(() => {
1589
+ const id = errorState.connectorId;
1590
+ setErrorState(null);
1591
+ pendingQrConnectorRef.current = id;
1592
+ cancelConnect();
1593
+ triggerConnect(id);
1594
+ }, "retry"),
1595
+ dismiss: /* @__PURE__ */ __name(() => {
1596
+ setErrorState(null);
1597
+ setSelectedConnector(null);
1598
+ }, "dismiss")
1599
+ };
1600
+ return renderConnectError(ctx);
1601
+ }, [
1602
+ errorState,
1603
+ renderConnectError,
1604
+ triggerConnect,
1605
+ cancelConnect
1606
+ ]);
1607
+ if (chainMismatch && requiredChainId != null) {
1608
+ return /* @__PURE__ */ React.createElement("div", {
1609
+ className: "ck-root"
1610
+ }, /* @__PURE__ */ React.createElement("div", {
1611
+ className: "ck-overlay",
1612
+ onClick: handleOverlayClick
1613
+ }), /* @__PURE__ */ React.createElement("div", {
1614
+ className: "ck-modal ck-modal--switch-chain",
1615
+ role: "dialog",
1616
+ "aria-modal": "true",
1617
+ "aria-label": "Switch network"
1618
+ }, /* @__PURE__ */ React.createElement("div", {
1619
+ className: "ck-pane"
1620
+ }, /* @__PURE__ */ React.createElement("div", {
1621
+ className: "ck-pane__header"
1622
+ }, /* @__PURE__ */ React.createElement("h3", {
1623
+ className: "ck-pane__title"
1624
+ }, "Switch network"), /* @__PURE__ */ React.createElement("button", {
1625
+ type: "button",
1626
+ className: "ck-btn-close",
1627
+ onClick: handleCloseClick,
1628
+ "aria-label": "Cancel and disconnect"
1629
+ })), /* @__PURE__ */ React.createElement("div", {
1630
+ className: "ck-pane__body"
1631
+ }, /* @__PURE__ */ React.createElement(SwitchChainView, {
1632
+ currentChainId: account.chainId,
1633
+ requiredChainId,
1634
+ requiredChainName,
1635
+ onDisconnect: handleSwitchChainDisconnect,
1636
+ onLog
1637
+ })))));
1638
+ }
1435
1639
  return /* @__PURE__ */ React.createElement("div", {
1436
1640
  className: "ck-root"
1437
1641
  }, /* @__PURE__ */ React.createElement("div", {
1438
1642
  className: "ck-overlay",
1439
- onClick: onClose
1643
+ onClick: handleOverlayClick
1440
1644
  }), /* @__PURE__ */ React.createElement("div", {
1441
- className: `ck-modal${selectedConnector ? " ck-modal--has-detail" : ""}`,
1645
+ className: `ck-modal${(selectedConnector != null ? selectedConnector : errorState) ? " ck-modal--has-detail" : ""}`,
1442
1646
  role: "dialog",
1443
1647
  "aria-modal": "true",
1444
1648
  "aria-label": "Connect Wallet"
@@ -1447,18 +1651,120 @@ var ConnectModalInner = /* @__PURE__ */ __name(({ open, connectors, onConnect, o
1447
1651
  selected: selectedConnector,
1448
1652
  recentConnector,
1449
1653
  onSelect: handleSelectConnector,
1450
- onClose
1654
+ onClose: handleCloseClick
1451
1655
  }), /* @__PURE__ */ React.createElement(DetailPane, {
1452
1656
  selectedConnector,
1453
1657
  qrUri: selectedConnector ? (_a = qrUriMap[selectedConnector]) != null ? _a : null : null,
1454
1658
  hasMetaMaskExtension,
1455
1659
  connectorLogoMap,
1456
1660
  wc,
1661
+ errorOverride: renderedError,
1457
1662
  onBack: handleBack,
1458
- onClose
1663
+ onClose: handleCloseClick
1459
1664
  })));
1460
1665
  }, "ConnectModalInner");
1461
1666
 
1667
+ // src/ConnectKitProvider.tsx
1668
+ import { createContext, useCallback as useCallback4, useContext, useEffect as useEffect5, useMemo as useMemo5, useRef as useRef4, useState as useState4 } from "react";
1669
+ import { useAccount as useAccount2, useDisconnect as useDisconnect2 } from "wagmi";
1670
+ var ConnectKitContext = /* @__PURE__ */ createContext(null);
1671
+ var resolveConnectorId = /* @__PURE__ */ __name((wagmiConnectorId) => {
1672
+ if (!wagmiConnectorId) return null;
1673
+ if (wagmiConnectorId === "credit-connect") return "CREDIT_CONNECT";
1674
+ if (wagmiConnectorId === "walletConnect") return "WALLET_CONNECT";
1675
+ const lower = wagmiConnectorId.toLowerCase();
1676
+ if (lower.includes("metamask") || wagmiConnectorId === "injected") return "METAMASK";
1677
+ if (lower.includes("walletconnect")) return "WALLET_CONNECT";
1678
+ return null;
1679
+ }, "resolveConnectorId");
1680
+ var ConnectKitProvider = /* @__PURE__ */ __name(({ children, connectors, wcProjectId, requiredChainId, onConnect, onLog, renderConnectError }) => {
1681
+ var _a;
1682
+ const [isOpen, setIsOpen] = useState4(false);
1683
+ const [pendingResult, setPendingResult] = useState4(null);
1684
+ const account = useAccount2();
1685
+ const { disconnectAsync } = useDisconnect2();
1686
+ const onConnectRef = useRef4(onConnect);
1687
+ useEffect5(() => {
1688
+ onConnectRef.current = onConnect;
1689
+ }, [
1690
+ onConnect
1691
+ ]);
1692
+ const chainMismatch = requiredChainId != null && account.status === "connected" && account.chainId != null && account.chainId !== requiredChainId;
1693
+ const effectiveOpen = isOpen || chainMismatch;
1694
+ const open = useCallback4(() => {
1695
+ setIsOpen(true);
1696
+ }, []);
1697
+ const close = useCallback4(() => {
1698
+ setIsOpen(false);
1699
+ }, []);
1700
+ const disconnect = useCallback4(async () => {
1701
+ await disconnectAsync();
1702
+ }, [
1703
+ disconnectAsync
1704
+ ]);
1705
+ const handleModalConnect = useCallback4((result) => {
1706
+ setPendingResult(result);
1707
+ }, []);
1708
+ useEffect5(() => {
1709
+ var _a2;
1710
+ if (!pendingResult) return;
1711
+ if (account.status !== "connected") {
1712
+ setPendingResult(null);
1713
+ return;
1714
+ }
1715
+ if (requiredChainId != null && account.chainId !== requiredChainId) {
1716
+ return;
1717
+ }
1718
+ (_a2 = onConnectRef.current) == null ? void 0 : _a2.call(onConnectRef, pendingResult);
1719
+ setPendingResult(null);
1720
+ setIsOpen(false);
1721
+ }, [
1722
+ pendingResult,
1723
+ account.status,
1724
+ account.chainId,
1725
+ requiredChainId
1726
+ ]);
1727
+ const value = useMemo5(() => {
1728
+ var _a2;
1729
+ return {
1730
+ isOpen: effectiveOpen,
1731
+ open,
1732
+ close,
1733
+ isConnected: account.status === "connected",
1734
+ address: account.address,
1735
+ connectorId: resolveConnectorId((_a2 = account.connector) == null ? void 0 : _a2.id),
1736
+ disconnect
1737
+ };
1738
+ }, [
1739
+ effectiveOpen,
1740
+ open,
1741
+ close,
1742
+ account.status,
1743
+ account.address,
1744
+ (_a = account.connector) == null ? void 0 : _a.id,
1745
+ disconnect
1746
+ ]);
1747
+ return /* @__PURE__ */ React.createElement(ConnectKitContext.Provider, {
1748
+ value
1749
+ }, children, /* @__PURE__ */ React.createElement(ConnectModal, {
1750
+ open: effectiveOpen,
1751
+ connectors,
1752
+ wcProjectId,
1753
+ requiredChainId,
1754
+ renderConnectError,
1755
+ onConnect: handleModalConnect,
1756
+ onClose: close,
1757
+ onLog
1758
+ }));
1759
+ }, "ConnectKitProvider");
1760
+ var useConnectKit = /* @__PURE__ */ __name(() => {
1761
+ const ctx = useContext(ConnectKitContext);
1762
+ if (!ctx) {
1763
+ throw new Error("useConnectKit must be used within a ConnectKitProvider");
1764
+ }
1765
+ return ctx;
1766
+ }, "useConnectKit");
1767
+
1462
1768
  // src/creditConnectConnector.ts
1463
1769
  import { createConnector } from "@wagmi/core";
1464
1770
  import { getAddress } from "viem";
@@ -1850,6 +2156,8 @@ var creditConnectConnector = /* @__PURE__ */ __name((options) => {
1850
2156
  });
1851
2157
  }, "creditConnectConnector");
1852
2158
  export {
2159
+ ConnectKitProvider,
1853
2160
  ConnectModal,
1854
- creditConnectConnector
2161
+ creditConnectConnector,
2162
+ useConnectKit
1855
2163
  };
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gluwa/connect-kit",
3
- "version": "0.1.0-next.1",
3
+ "version": "0.1.0-next.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gluwa/connect-kit",
3
- "version": "0.1.0-next.1",
3
+ "version": "0.1.0-next.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -0,0 +1,169 @@
1
+ import {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ type FC,
10
+ type ReactNode,
11
+ } from 'react';
12
+ import { useAccount, useDisconnect } from 'wagmi';
13
+ import { ConnectModal } from './ConnectModal';
14
+ import type { ConnectErrorContext, ConnectResult, ConnectorId, Connectors } from './types';
15
+
16
+ export interface ConnectKitProviderProps {
17
+ children: ReactNode;
18
+ connectors: Connectors;
19
+ wcProjectId?: string;
20
+ /**
21
+ * 연결돼 있는 동안 강제할 체인 ID. 다른 체인에 연결돼 있으면
22
+ * 자동으로 스위치 모달이 떠서 사용자에게 전환 또는 disconnect를 요구.
23
+ */
24
+ requiredChainId?: number;
25
+ /**
26
+ * 연결이 정상적으로 끝나고(체인 검증까지 통과) "사용 가능한 상태"가 됐을 때 한 번 호출.
27
+ * 재연결(reconnect)에서는 호출되지 않음 — 그쪽은 dApp의 watchAccount 책임.
28
+ */
29
+ onConnect?: (result: ConnectResult) => void;
30
+ onLog?: (message: string, error?: Error) => void;
31
+ /**
32
+ * 연결 실패(거절/미설치/기타) 시 모달 안에 표시할 fallback UI를 dApp이 직접 그리고 싶을 때.
33
+ * 미제공 시 기본 동작은 selector로 복귀.
34
+ */
35
+ renderConnectError?: (ctx: ConnectErrorContext) => ReactNode;
36
+ }
37
+
38
+ export interface ConnectKitContextValue {
39
+ isOpen: boolean;
40
+ open: () => void;
41
+ close: () => void;
42
+ isConnected: boolean;
43
+ address: `0x${string}` | undefined;
44
+ connectorId: ConnectorId | null;
45
+ disconnect: () => Promise<void>;
46
+ }
47
+
48
+ const ConnectKitContext = createContext<ConnectKitContextValue | null>(null);
49
+
50
+ const resolveConnectorId = (wagmiConnectorId: string | undefined): ConnectorId | null => {
51
+ if (!wagmiConnectorId) return null;
52
+ if (wagmiConnectorId === 'credit-connect') return 'CREDIT_CONNECT';
53
+ if (wagmiConnectorId === 'walletConnect') return 'WALLET_CONNECT';
54
+ const lower = wagmiConnectorId.toLowerCase();
55
+ if (lower.includes('metamask') || wagmiConnectorId === 'injected') return 'METAMASK';
56
+ if (lower.includes('walletconnect')) return 'WALLET_CONNECT';
57
+ return null;
58
+ };
59
+
60
+ export const ConnectKitProvider: FC<ConnectKitProviderProps> = ({
61
+ children,
62
+ connectors,
63
+ wcProjectId,
64
+ requiredChainId,
65
+ onConnect,
66
+ onLog,
67
+ renderConnectError,
68
+ }) => {
69
+ const [isOpen, setIsOpen] = useState(false);
70
+ const [pendingResult, setPendingResult] = useState<ConnectResult | null>(null);
71
+
72
+ const account = useAccount();
73
+ const { disconnectAsync } = useDisconnect();
74
+
75
+ // dApp의 onConnect는 ref로 잡아 effect 재실행 노이즈 차단
76
+ const onConnectRef = useRef(onConnect);
77
+ useEffect(() => {
78
+ onConnectRef.current = onConnect;
79
+ }, [onConnect]);
80
+
81
+ // chain mismatch invariant: 연결돼 있지만 요구 체인이 아니면 modal force-open
82
+ const chainMismatch =
83
+ requiredChainId != null &&
84
+ account.status === 'connected' &&
85
+ account.chainId != null &&
86
+ account.chainId !== requiredChainId;
87
+
88
+ const effectiveOpen = isOpen || chainMismatch;
89
+
90
+ const open = useCallback((): void => {
91
+ setIsOpen(true);
92
+ }, []);
93
+
94
+ const close = useCallback((): void => {
95
+ setIsOpen(false);
96
+ }, []);
97
+
98
+ const disconnect = useCallback(async (): Promise<void> => {
99
+ await disconnectAsync();
100
+ }, [disconnectAsync]);
101
+
102
+ // ConnectModal → Provider: 연결 결과를 일단 보류했다가
103
+ // chain 검증까지 통과한 후에만 dApp으로 forward
104
+ const handleModalConnect = useCallback((result: ConnectResult): void => {
105
+ setPendingResult(result);
106
+ }, []);
107
+
108
+ // pendingResult가 있고, 연결 + chain 매칭이 충족되면 dApp으로 발화
109
+ useEffect(() => {
110
+ if (!pendingResult) return;
111
+ if (account.status !== 'connected') {
112
+ // 연결이 끊겼거나 진행 중 — pending 폐기
113
+ setPendingResult(null);
114
+ return;
115
+ }
116
+ if (requiredChainId != null && account.chainId !== requiredChainId) {
117
+ // chain 아직 안 맞음 — switch 후 다시 평가
118
+ return;
119
+ }
120
+ onConnectRef.current?.(pendingResult);
121
+ setPendingResult(null);
122
+ setIsOpen(false);
123
+ }, [pendingResult, account.status, account.chainId, requiredChainId]);
124
+
125
+ const value = useMemo<ConnectKitContextValue>(
126
+ () => ({
127
+ isOpen: effectiveOpen,
128
+ open,
129
+ close,
130
+ isConnected: account.status === 'connected',
131
+ address: account.address,
132
+ connectorId: resolveConnectorId(account.connector?.id),
133
+ disconnect,
134
+ }),
135
+ [
136
+ effectiveOpen,
137
+ open,
138
+ close,
139
+ account.status,
140
+ account.address,
141
+ account.connector?.id,
142
+ disconnect,
143
+ ],
144
+ );
145
+
146
+ return (
147
+ <ConnectKitContext.Provider value={value}>
148
+ {children}
149
+ <ConnectModal
150
+ open={effectiveOpen}
151
+ connectors={connectors}
152
+ wcProjectId={wcProjectId}
153
+ requiredChainId={requiredChainId}
154
+ renderConnectError={renderConnectError}
155
+ onConnect={handleModalConnect}
156
+ onClose={close}
157
+ onLog={onLog}
158
+ />
159
+ </ConnectKitContext.Provider>
160
+ );
161
+ };
162
+
163
+ export const useConnectKit = (): ConnectKitContextValue => {
164
+ const ctx = useContext(ConnectKitContext);
165
+ if (!ctx) {
166
+ throw new Error('useConnectKit must be used within a ConnectKitProvider');
167
+ }
168
+ return ctx;
169
+ };
@@ -140,6 +140,44 @@
140
140
  &:hover {
141
141
  background: rgba(255, 255, 255, 0.22);
142
142
  }
143
+
144
+ &:disabled {
145
+ opacity: 0.6;
146
+ cursor: not-allowed;
147
+ }
148
+ }
149
+
150
+ .ck-btn-secondary {
151
+ display: inline-flex;
152
+ align-items: center;
153
+ justify-content: center;
154
+ padding: 8px 16px;
155
+ border-radius: 8px;
156
+ font-size: 14px;
157
+ font-weight: 500;
158
+ cursor: pointer;
159
+ white-space: nowrap;
160
+ color: rgba(255, 255, 255, 0.75);
161
+ background: transparent;
162
+ border: 1px solid rgba(255, 255, 255, 0.18);
163
+
164
+ &:hover {
165
+ color: #fff;
166
+ border-color: rgba(255, 255, 255, 0.32);
167
+ }
168
+ }
169
+
170
+ .ck-view__actions {
171
+ display: flex;
172
+ flex-direction: column;
173
+ gap: 8px;
174
+ width: 100%;
175
+ margin-top: 16px;
176
+ }
177
+
178
+ .ck-view__caption--error {
179
+ color: #ff8080;
180
+ margin-top: 8px;
143
181
  }
144
182
 
145
183
  // ── Connector list ────────────────────────────────────────
@@ -1,8 +1,10 @@
1
- import { useState, useEffect, useCallback, useRef, useMemo, type FC } from 'react';
1
+ import { useState, useEffect, useCallback, useRef, useMemo, type FC, type ReactNode } from 'react';
2
2
  import './ConnectModal.scss';
3
- import { useConfig } from 'wagmi';
3
+ import { useAccount, useConfig, useDisconnect } from 'wagmi';
4
4
  import {
5
5
  type ConnectModalProps,
6
+ type ConnectErrorContext,
7
+ type ConnectErrorReason,
6
8
  type Connectors,
7
9
  type ConnectorId,
8
10
  type WCWallet,
@@ -16,6 +18,7 @@ import { IdleView } from './views/IdleView';
16
18
  import { CreditWalletView } from './views/CreditWalletView';
17
19
  import { MetaMaskView } from './views/MetaMaskView';
18
20
  import { WalletConnectView } from './views/WalletConnectView';
21
+ import { SwitchChainView } from './views/SwitchChainView';
19
22
 
20
23
  const RECENT_KEY = 'connect-kit:recent';
21
24
  const readRecentConnector = (): ConnectorId | null => {
@@ -32,6 +35,18 @@ const writeRecentConnector = (id: ConnectorId): void => {
32
35
  } catch {}
33
36
  };
34
37
 
38
+ const classifyError = (error: Error): ConnectErrorReason => {
39
+ const code =
40
+ typeof error === 'object' && error !== null && 'code' in error
41
+ ? (error as unknown as { code: unknown }).code
42
+ : undefined;
43
+ if (code === 4001) return 'rejected';
44
+ const message = error.message?.toLowerCase() ?? '';
45
+ if (message.includes('user rejected') || message.includes('user denied')) return 'rejected';
46
+ if (message.includes('unsupported') || message.includes('not installed')) return 'unsupported';
47
+ return 'unknown';
48
+ };
49
+
35
50
  interface SelectorPaneProps {
36
51
  connectors: ConnectorId[];
37
52
  selected: ConnectorId | null;
@@ -149,6 +164,7 @@ interface DetailPaneProps {
149
164
  hasMetaMaskExtension: boolean;
150
165
  connectorLogoMap: Partial<Record<ConnectorId, string>>;
151
166
  wc: WCState;
167
+ errorOverride: ReactNode | null;
152
168
  onBack: () => void;
153
169
  onClose: () => void;
154
170
  }
@@ -159,6 +175,7 @@ const DetailPane: FC<DetailPaneProps> = ({
159
175
  hasMetaMaskExtension,
160
176
  connectorLogoMap,
161
177
  wc,
178
+ errorOverride,
162
179
  onBack,
163
180
  onClose,
164
181
  }) => {
@@ -181,35 +198,39 @@ const DetailPane: FC<DetailPaneProps> = ({
181
198
  </div>
182
199
 
183
200
  <div className="ck-pane__body">
184
- {!selectedConnector && <IdleView />}
201
+ {errorOverride || (
202
+ <>
203
+ {!selectedConnector && <IdleView />}
185
204
 
186
- {(selectedConnector === 'CREDIT_CONNECT' || selectedConnector === 'CREDIT_WALLET') && (
187
- <CreditWalletView connectorId={selectedConnector} qrUri={qrUri} />
188
- )}
205
+ {(selectedConnector === 'CREDIT_CONNECT' || selectedConnector === 'CREDIT_WALLET') && (
206
+ <CreditWalletView connectorId={selectedConnector} qrUri={qrUri} />
207
+ )}
189
208
 
190
- {selectedConnector === 'METAMASK' && (
191
- <MetaMaskView
192
- qrUri={qrUri}
193
- hasExtension={hasMetaMaskExtension}
194
- logoUrl={connectorLogoMap.METAMASK}
195
- />
196
- )}
209
+ {selectedConnector === 'METAMASK' && (
210
+ <MetaMaskView
211
+ qrUri={qrUri}
212
+ hasExtension={hasMetaMaskExtension}
213
+ logoUrl={connectorLogoMap.METAMASK}
214
+ />
215
+ )}
197
216
 
198
- {selectedConnector === 'WALLET_CONNECT' && (
199
- <WalletConnectView
200
- subView={wc.subView}
201
- qrUri={qrUri}
202
- logoUrl={connectorLogoMap.WALLET_CONNECT}
203
- walletList={wc.walletList}
204
- walletListLoading={wc.walletListLoading}
205
- walletListSearch={wc.walletListSearch}
206
- walletListFilterActive={wc.filterActive}
207
- selectedWallet={wc.selectedWallet}
208
- onShowList={wc.onShowList}
209
- onSelectWallet={wc.onSelectWallet}
210
- onSearchChange={wc.onSearchChange}
211
- onFilterToggle={wc.onFilterToggle}
212
- />
217
+ {selectedConnector === 'WALLET_CONNECT' && (
218
+ <WalletConnectView
219
+ subView={wc.subView}
220
+ qrUri={qrUri}
221
+ logoUrl={connectorLogoMap.WALLET_CONNECT}
222
+ walletList={wc.walletList}
223
+ walletListLoading={wc.walletListLoading}
224
+ walletListSearch={wc.walletListSearch}
225
+ walletListFilterActive={wc.filterActive}
226
+ selectedWallet={wc.selectedWallet}
227
+ onShowList={wc.onShowList}
228
+ onSelectWallet={wc.onSelectWallet}
229
+ onSearchChange={wc.onSearchChange}
230
+ onFilterToggle={wc.onFilterToggle}
231
+ />
232
+ )}
233
+ </>
213
234
  )}
214
235
  </div>
215
236
  </div>
@@ -278,15 +299,36 @@ const ConnectModalInner: FC<ConnectModalInnerProps> = ({
278
299
  onClose,
279
300
  onLog,
280
301
  wcProjectId,
302
+ requiredChainId,
303
+ renderConnectError,
281
304
  }) => {
282
305
  const [selectedConnector, setSelectedConnector] = useState<ConnectorId | null>(null);
283
306
  const [qrUriMap, setQrUriMap] = useState<Partial<Record<ConnectorId, string>>>({});
284
307
  const pendingQrConnectorRef = useRef<ConnectorId | null>(null);
285
308
  const [recentConnector, setRecentConnector] = useState<ConnectorId | null>(null);
286
309
  const [hasMetaMaskExtension, setHasMetaMaskExtension] = useState<boolean>(false);
310
+ const [errorState, setErrorState] = useState<{ connectorId: ConnectorId; error: Error } | null>(
311
+ null,
312
+ );
287
313
 
288
314
  const wc = useWCState();
289
315
 
316
+ // wagmi 상태 — chain mismatch invariant 판정용
317
+ const account = useAccount();
318
+ const { disconnectAsync } = useDisconnect();
319
+ const wagmiConfig = useConfig();
320
+
321
+ const chainMismatch =
322
+ requiredChainId != null &&
323
+ account.status === 'connected' &&
324
+ account.chainId != null &&
325
+ account.chainId !== requiredChainId;
326
+
327
+ const requiredChainName = useMemo<string | undefined>(() => {
328
+ if (requiredChainId == null) return undefined;
329
+ return wagmiConfig.chains.find((chain) => chain.id === requiredChainId)?.name;
330
+ }, [wagmiConfig.chains, requiredChainId]);
331
+
290
332
  // QR URI 수신 — 커넥터별 캐시에 저장 (null은 무시하여 캐시 유지)
291
333
  const handleQrUri = useCallback((uri: string | null) => {
292
334
  if (uri) {
@@ -298,7 +340,10 @@ const ConnectModalInner: FC<ConnectModalInnerProps> = ({
298
340
  // 연결 완료
299
341
  const handleConnect = useCallback(
300
342
  (result: Parameters<typeof onConnect>[0]) => {
343
+ setErrorState(null);
301
344
  onConnect(result);
345
+ // chain mismatch가 있으면 모달은 SwitchChainView로 자동 전환됨 (닫지 않음)
346
+ // Provider 측에서 requiredChainId가 매칭될 때까지 force-open 유지
302
347
  onClose();
303
348
  },
304
349
  [onConnect, onClose],
@@ -306,17 +351,25 @@ const ConnectModalInner: FC<ConnectModalInnerProps> = ({
306
351
 
307
352
  // 연결 실패 — 실패한 커넥터의 QR 캐시만 삭제
308
353
  const handleError = useCallback(
309
- (error: Error, connectorId: ConnectorId): void => {
310
- onLog?.(`[connect-kit] connection failed (${connectorId}): ${error.message}`, error);
311
- setSelectedConnector(null);
354
+ (error: unknown, connectorId: ConnectorId): void => {
355
+ const normalizedError = error instanceof Error ? error : new Error(String(error));
356
+ onLog?.(
357
+ `[connect-kit] connection failed (${connectorId}): ${normalizedError.message}`,
358
+ normalizedError,
359
+ );
312
360
  setQrUriMap((prev) => {
313
361
  const next = { ...prev };
314
362
  delete next[connectorId];
315
363
  return next;
316
364
  });
317
365
  wc.resetView();
366
+ if (renderConnectError) {
367
+ setErrorState({ connectorId, error: normalizedError });
368
+ } else {
369
+ setSelectedConnector(null);
370
+ }
318
371
  },
319
- [onLog, wc.resetView],
372
+ [onLog, wc.resetView, renderConnectError],
320
373
  );
321
374
 
322
375
  // 연결 시도
@@ -327,7 +380,6 @@ const ConnectModalInner: FC<ConnectModalInnerProps> = ({
327
380
  });
328
381
 
329
382
  // wagmi connector icon 매핑
330
- const wagmiConfig = useConfig();
331
383
  const connectorLogoMap = useMemo(() => {
332
384
  const map: Partial<Record<ConnectorId, string>> = {};
333
385
  const connectorIds: ConnectorId[] = [
@@ -355,18 +407,19 @@ const ConnectModalInner: FC<ConnectModalInnerProps> = ({
355
407
  setSelectedConnector(null);
356
408
  setQrUriMap({});
357
409
  pendingQrConnectorRef.current = null;
410
+ setErrorState(null);
358
411
  wc.reset();
359
412
  cancelConnect();
360
413
  }
361
414
  }, [open, cancelConnect, wc.reset]);
362
415
 
363
- // 커넥터 선택 — 같은 커넥터 재선택 시 무시, 다른 커넥터 선택 시 이전 연결 취소 후 새 연결
416
+ // 커넥터 선택
364
417
  const handleSelectConnector = useCallback(
365
418
  (id: ConnectorId): void => {
366
- if (id === selectedConnector) return;
367
-
419
+ if (id === selectedConnector && !errorState) return;
368
420
  pendingQrConnectorRef.current = id;
369
421
  setSelectedConnector(id);
422
+ setErrorState(null);
370
423
  wc.resetView();
371
424
  writeRecentConnector(id);
372
425
  setRecentConnector(id);
@@ -380,6 +433,7 @@ const ConnectModalInner: FC<ConnectModalInnerProps> = ({
380
433
  },
381
434
  [
382
435
  selectedConnector,
436
+ errorState,
383
437
  triggerConnect,
384
438
  cancelConnect,
385
439
  wcProjectId,
@@ -390,17 +444,103 @@ const ConnectModalInner: FC<ConnectModalInnerProps> = ({
390
444
 
391
445
  // 뒤로가기 — QR 캐시는 유지, wagmi만 reset
392
446
  const handleBack = useCallback((): void => {
447
+ if (errorState) {
448
+ setErrorState(null);
449
+ setSelectedConnector(null);
450
+ return;
451
+ }
393
452
  if (selectedConnector === 'WALLET_CONNECT' && wc.handleBack()) return;
394
453
  pendingQrConnectorRef.current = null;
395
454
  setSelectedConnector(null);
396
455
  cancelConnect();
397
- }, [selectedConnector, wc.handleBack, cancelConnect]);
456
+ }, [selectedConnector, errorState, wc.handleBack, cancelConnect]);
457
+
458
+ // chain mismatch 상태에서의 disconnect (Switch chain pane의 cancel)
459
+ const handleSwitchChainDisconnect = useCallback((): void => {
460
+ disconnectAsync().catch((err: unknown) => {
461
+ const normalized = err instanceof Error ? err : new Error(String(err));
462
+ onLog?.(`[connect-kit] disconnect failed: ${normalized.message}`, normalized);
463
+ });
464
+ }, [disconnectAsync, onLog]);
465
+
466
+ // overlay 클릭 — chain mismatch 상태에선 무시 (실수 클릭 방어)
467
+ const handleOverlayClick = useCallback((): void => {
468
+ if (chainMismatch) return;
469
+ onClose();
470
+ }, [chainMismatch, onClose]);
471
+
472
+ // close 버튼 — chain mismatch 상태에선 disconnect
473
+ const handleCloseClick = useCallback((): void => {
474
+ if (chainMismatch) {
475
+ handleSwitchChainDisconnect();
476
+ return;
477
+ }
478
+ onClose();
479
+ }, [chainMismatch, handleSwitchChainDisconnect, onClose]);
480
+
481
+ const renderedError: ReactNode = useMemo(() => {
482
+ if (!errorState || !renderConnectError) return null;
483
+ const ctx: ConnectErrorContext = {
484
+ connectorId: errorState.connectorId,
485
+ reason: classifyError(errorState.error),
486
+ error: errorState.error,
487
+ retry: () => {
488
+ const id = errorState.connectorId;
489
+ setErrorState(null);
490
+ pendingQrConnectorRef.current = id;
491
+ cancelConnect();
492
+ triggerConnect(id);
493
+ },
494
+ dismiss: () => {
495
+ setErrorState(null);
496
+ setSelectedConnector(null);
497
+ },
498
+ };
499
+ return renderConnectError(ctx);
500
+ }, [errorState, renderConnectError, triggerConnect, cancelConnect]);
501
+
502
+ // chain mismatch면 단일 pane (selector/detail 숨김)
503
+ // requiredChainId != null 재확인은 TS narrowing용 — chainMismatch true면 이미 보장됨
504
+ if (chainMismatch && requiredChainId != null) {
505
+ return (
506
+ <div className="ck-root">
507
+ <div className="ck-overlay" onClick={handleOverlayClick} />
508
+ <div
509
+ className="ck-modal ck-modal--switch-chain"
510
+ role="dialog"
511
+ aria-modal="true"
512
+ aria-label="Switch network"
513
+ >
514
+ <div className="ck-pane">
515
+ <div className="ck-pane__header">
516
+ <h3 className="ck-pane__title">Switch network</h3>
517
+ <button
518
+ type="button"
519
+ className="ck-btn-close"
520
+ onClick={handleCloseClick}
521
+ aria-label="Cancel and disconnect"
522
+ />
523
+ </div>
524
+ <div className="ck-pane__body">
525
+ <SwitchChainView
526
+ currentChainId={account.chainId}
527
+ requiredChainId={requiredChainId}
528
+ requiredChainName={requiredChainName}
529
+ onDisconnect={handleSwitchChainDisconnect}
530
+ onLog={onLog}
531
+ />
532
+ </div>
533
+ </div>
534
+ </div>
535
+ </div>
536
+ );
537
+ }
398
538
 
399
539
  return (
400
540
  <div className="ck-root">
401
- <div className="ck-overlay" onClick={onClose} />
541
+ <div className="ck-overlay" onClick={handleOverlayClick} />
402
542
  <div
403
- className={`ck-modal${selectedConnector ? ' ck-modal--has-detail' : ''}`}
543
+ className={`ck-modal${(selectedConnector ?? errorState) ? ' ck-modal--has-detail' : ''}`}
404
544
  role="dialog"
405
545
  aria-modal="true"
406
546
  aria-label="Connect Wallet"
@@ -410,7 +550,7 @@ const ConnectModalInner: FC<ConnectModalInnerProps> = ({
410
550
  selected={selectedConnector}
411
551
  recentConnector={recentConnector}
412
552
  onSelect={handleSelectConnector}
413
- onClose={onClose}
553
+ onClose={handleCloseClick}
414
554
  />
415
555
  <DetailPane
416
556
  selectedConnector={selectedConnector}
@@ -418,8 +558,9 @@ const ConnectModalInner: FC<ConnectModalInnerProps> = ({
418
558
  hasMetaMaskExtension={hasMetaMaskExtension}
419
559
  connectorLogoMap={connectorLogoMap}
420
560
  wc={wc}
561
+ errorOverride={renderedError}
421
562
  onBack={handleBack}
422
- onClose={onClose}
563
+ onClose={handleCloseClick}
423
564
  />
424
565
  </div>
425
566
  </div>
package/src/index.ts CHANGED
@@ -1,7 +1,11 @@
1
1
  export { ConnectModal } from './ConnectModal';
2
+ export { ConnectKitProvider, useConnectKit } from './ConnectKitProvider';
3
+ export type { ConnectKitContextValue, ConnectKitProviderProps } from './ConnectKitProvider';
2
4
  export { creditConnectConnector } from './creditConnectConnector';
3
5
  export type { CreditConnectConnectorOptions } from './creditConnectConnector';
4
6
  export type {
7
+ ConnectErrorContext,
8
+ ConnectErrorReason,
5
9
  ConnectModalProps,
6
10
  Connectors,
7
11
  ConnectResult,
package/src/types.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { ReactNode } from 'react';
2
+
1
3
  export type ConnectorId = 'CREDIT_WALLET' | 'CREDIT_CONNECT' | 'METAMASK' | 'WALLET_CONNECT';
2
4
 
3
5
  export type CreditWalletStrategy = 'walletConnect' | 'creditConnect';
@@ -28,6 +30,16 @@ export interface ConnectResult {
28
30
  connectorId: ConnectorId;
29
31
  }
30
32
 
33
+ export type ConnectErrorReason = 'rejected' | 'unsupported' | 'unknown';
34
+
35
+ export interface ConnectErrorContext {
36
+ connectorId: ConnectorId;
37
+ reason: ConnectErrorReason;
38
+ error: Error;
39
+ retry: () => void;
40
+ dismiss: () => void;
41
+ }
42
+
31
43
  export interface ConnectModalProps {
32
44
  open: boolean;
33
45
  connectors: Connectors;
@@ -35,4 +47,6 @@ export interface ConnectModalProps {
35
47
  onClose: () => void;
36
48
  onLog?: (message: string, error?: Error) => void;
37
49
  wcProjectId?: string;
50
+ requiredChainId?: number;
51
+ renderConnectError?: (ctx: ConnectErrorContext) => ReactNode;
38
52
  }
@@ -0,0 +1,67 @@
1
+ import { useState, type FC } from 'react';
2
+ import { useSwitchChain } from 'wagmi';
3
+
4
+ interface SwitchChainViewProps {
5
+ currentChainId: number | undefined;
6
+ requiredChainId: number;
7
+ requiredChainName?: string;
8
+ onDisconnect: () => void;
9
+ onLog?: (message: string, error?: Error) => void;
10
+ }
11
+
12
+ export const SwitchChainView: FC<SwitchChainViewProps> = ({
13
+ currentChainId,
14
+ requiredChainId,
15
+ requiredChainName,
16
+ onDisconnect,
17
+ onLog,
18
+ }) => {
19
+ const { switchChainAsync, isPending } = useSwitchChain();
20
+ const [error, setError] = useState<Error | null>(null);
21
+
22
+ const targetLabel = requiredChainName ?? `chain ${requiredChainId}`;
23
+
24
+ const handleSwitch = (): void => {
25
+ setError(null);
26
+ switchChainAsync({ chainId: requiredChainId }).catch((err: unknown) => {
27
+ const normalized = err instanceof Error ? err : new Error(String(err));
28
+ // wagmi's MetaMask connector handles 4902 (chain not added) via wallet_addEthereumChain
29
+ // automatically when chain metadata is configured. We only surface what reaches us.
30
+ const code =
31
+ typeof err === 'object' && err !== null && 'code' in err
32
+ ? (err as { code: unknown }).code
33
+ : undefined;
34
+ if (code === 4902) {
35
+ onLog?.('[connect-kit] chain not registered in wallet (4902)', normalized);
36
+ } else {
37
+ onLog?.(`[connect-kit] switch chain failed: ${normalized.message}`, normalized);
38
+ }
39
+ setError(normalized);
40
+ });
41
+ };
42
+
43
+ return (
44
+ <div className="ck-view ck-view--switch-chain">
45
+ <h4 className="ck-view__title">Wrong network</h4>
46
+ <p className="ck-view__description">
47
+ {currentChainId != null
48
+ ? `Connected to chain ${currentChainId}. Please switch to ${targetLabel} to continue.`
49
+ : `Please switch to ${targetLabel} to continue.`}
50
+ </p>
51
+ <div className="ck-view__actions">
52
+ <button
53
+ type="button"
54
+ className="ck-btn-primary"
55
+ onClick={handleSwitch}
56
+ disabled={isPending}
57
+ >
58
+ {isPending ? 'Waiting for wallet…' : `Switch to ${targetLabel}`}
59
+ </button>
60
+ <button type="button" className="ck-btn-secondary" onClick={onDisconnect}>
61
+ Disconnect
62
+ </button>
63
+ </div>
64
+ {error && <p className="ck-view__caption ck-view__caption--error">{error.message}</p>}
65
+ </div>
66
+ );
67
+ };