@bufinance/web3-signin 0.1.6 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bufinance/web3-signin",
3
- "version": "0.1.6",
3
+ "version": "0.2.1",
4
4
  "description": "Headless cross-app Web3 wallet sign-in for BUFI: EIP-6963 wallet discovery + Supabase signInWithWeb3 (EIP-4361) + Turnstile captcha. Shared by desk-v1 and defi-web-app. Source of truth lives here; both apps consume it.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -24,3 +24,10 @@ export {
24
24
  type WalletBalancePanelTab,
25
25
  type WalletBalancePanelClassNames,
26
26
  } from "./wallet-balance-panel";
27
+ export {
28
+ WalletPanel,
29
+ type WalletPanelProps,
30
+ type WalletPanelCurrency,
31
+ type WalletPanelChain,
32
+ type WalletPanelTab,
33
+ } from "./wallet-panel";
@@ -0,0 +1,600 @@
1
+ "use client";
2
+
3
+ import {
4
+ useEffect,
5
+ useRef,
6
+ useState,
7
+ type CSSProperties,
8
+ type ReactNode,
9
+ } from "react";
10
+
11
+ /**
12
+ * WalletPanel — the desk-v1-AUTHORITATIVE wallet-dropdown body, ported into the
13
+ * shared package so defi-web INHERITS desk's UI (desk is the source of truth;
14
+ * defi-web is not authoritative for the wallet UI).
15
+ *
16
+ * Faithful to desk-v1 `header/wallet-dropdown.tsx` `content`:
17
+ * - Total Balance row → [label] [all-currency-icons dropdown] [pill: label + amount]
18
+ * - dashed separator
19
+ * - Select Chains row → [label] [all-chain-icons dropdown] [pill: address + copy + explorer]
20
+ * - team summary cards → 2-col Operations/Treasury selector (label + token icons + balance)
21
+ *
22
+ * SELF-CONTAINED by design. The package's Tailwind classes are NOT in either
23
+ * app's `content` glob, so desk's arbitrary-hex utilities (`text-[#6954cf]`,
24
+ * `dark:bg-[#1E1633]`, `hover:bg-[#f5f3ff]`) would never be generated. Instead
25
+ * this ships desk's EXACT theme as a scoped `<style>` block (full `:hover` +
26
+ * dark support, zero Tailwind/framer-motion/lucide/Radix deps). The host injects
27
+ * icons (`renderCurrencyIcon`/`renderChainIcon`), the live total + ghost as
28
+ * ReactNode slots, and the theme via `theme="light"|"dark"`.
29
+ */
30
+
31
+ // ── desk theme tokens (verbatim from wallet-dropdown.tsx) ──────────────────
32
+ // ink #6954cf / dark #E2D0FD accent #9E84FF
33
+ // surface bg #fff border #dad4f4 dark bg #1E1633 border #3A2E63
34
+ // interactive hover bg #f5f3ff dark hover bg #2B224B
35
+ // Dark mode follows EITHER an ancestor `.dark` class (Tailwind's class strategy,
36
+ // which both apps use) OR an explicit data-theme="dark" on the root — so the
37
+ // host never has to thread a theme prop; it just works under the app's `.dark`.
38
+ const STYLE_ID = "bufi-wallet-panel-style";
39
+ const PANEL_CSS = `
40
+ .bwp-root{
41
+ --bwp-bg:#fff;--bwp-border:#dad4f4;--bwp-ink:#6954cf;--bwp-ink-60:rgba(105,84,207,.6);
42
+ --bwp-ink-30:rgba(105,84,207,.3);--bwp-hover:#f5f3ff;--bwp-menu-bg:#fff;
43
+ --bwp-menu-shadow:0 8px 24px rgba(60,40,120,.16);--bwp-check:#6954cf;--bwp-more:rgba(105,84,207,.5);
44
+ --bwp-tab-hover:rgba(245,243,255,.5);--bwp-tab-on-border:#6954cf;--bwp-tab-on-bg:#f5f3ff;
45
+ --bwp-tab-label:rgba(105,84,207,.7);--bwp-tab-label-on:#6a55cf;--bwp-spin:rgba(105,84,207,.4);
46
+ --bwp-empty:rgba(105,84,207,.3);
47
+ box-sizing:border-box;width:calc(100vw - 32px);max-width:340px;padding:16px;border-radius:9px;
48
+ border:1px solid var(--bwp-border);background:var(--bwp-bg);color:var(--bwp-ink);
49
+ font-family:inherit;-webkit-font-smoothing:antialiased;}
50
+ .bwp-root.bwp-team{max-width:380px;}
51
+ .bwp-root[data-theme="dark"], .dark .bwp-root{
52
+ --bwp-bg:#1E1633;--bwp-border:#3A2E63;--bwp-ink:#E2D0FD;--bwp-ink-60:rgba(226,208,253,.6);
53
+ --bwp-ink-30:rgba(226,208,253,.35);--bwp-hover:#2B224B;--bwp-menu-bg:#221a36;
54
+ --bwp-menu-shadow:0 8px 24px rgba(0,0,0,.5);--bwp-check:#9E84FF;--bwp-more:rgba(226,208,253,.5);
55
+ --bwp-tab-hover:rgba(30,22,51,.6);--bwp-tab-on-border:#3A2E63;--bwp-tab-on-bg:#1E1633;
56
+ --bwp-tab-label:rgba(226,208,253,.7);--bwp-tab-label-on:#E2D0FD;--bwp-spin:rgba(226,208,253,.4);
57
+ --bwp-empty:rgba(226,208,253,.4);}
58
+ .bwp-root *{box-sizing:border-box;}
59
+
60
+ .bwp-row{display:flex;align-items:center;gap:10px;}
61
+ .bwp-label{flex-shrink:0;width:46px;font-size:11px;line-height:1.15;font-weight:500;color:var(--bwp-ink-60);}
62
+
63
+ .bwp-ctl{display:inline-flex;align-items:center;gap:4px;border:1px solid var(--bwp-border);border-radius:6px;
64
+ background:var(--bwp-bg);transition:background-color .15s;cursor:pointer;}
65
+ .bwp-ctl--int{padding:6px 8px;flex-shrink:0;}
66
+ .bwp-ctl--int:hover{background:var(--bwp-hover);}
67
+ .bwp-ctl--int:disabled{opacity:.5;cursor:default;}
68
+
69
+ .bwp-pill{display:flex;align-items:center;flex:1 1 auto;min-width:0;border:1px solid var(--bwp-border);
70
+ border-radius:6px;background:var(--bwp-bg);}
71
+ .bwp-pill--bal{padding:4px 12px;}
72
+ .bwp-pill--addr{padding:6px 12px;gap:6px;}
73
+
74
+ .bwp-cur-label{font-size:14px;font-weight:500;color:var(--bwp-ink);}
75
+ .bwp-amount{margin-left:auto;font-size:18px;font-weight:700;color:var(--bwp-ink);font-variant-numeric:tabular-nums;}
76
+
77
+ .bwp-addr{font-size:14px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--bwp-ink);
78
+ overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
79
+ .bwp-addr-actions{display:flex;align-items:center;gap:4px;flex-shrink:0;margin-left:auto;}
80
+ .bwp-iconbtn{display:inline-flex;color:var(--bwp-ink-30);background:none;border:0;padding:0;cursor:pointer;
81
+ transition:color .15s;text-decoration:none;}
82
+ .bwp-iconbtn:hover{color:var(--bwp-ink);}
83
+ .bwp-addr-empty{font-size:14px;font-style:italic;color:var(--bwp-empty);}
84
+
85
+ .bwp-chev{color:var(--bwp-ink-60);flex-shrink:0;}
86
+ .bwp-more{font-size:9px;font-weight:500;padding-left:2px;color:var(--bwp-more);}
87
+
88
+ .bwp-stack{display:inline-flex;align-items:center;}
89
+ .bwp-stack > * + *{margin-left:-4px;}
90
+ .bwp-stack--chain > * + *{margin-left:-2px;}
91
+ .bwp-glyph{display:inline-flex;border-radius:9999px;transition:opacity .15s;}
92
+ .bwp-glyph--dim{opacity:.55;}
93
+ .bwp-glyph--on{position:relative;z-index:1;}
94
+
95
+ .bwp-sep{border:0;border-top:1px dashed var(--bwp-border);margin:12px 0;}
96
+
97
+ .bwp-menu-wrap{position:relative;flex-shrink:0;}
98
+ .bwp-menu{position:absolute;top:calc(100% + 4px);z-index:200;border-radius:8px;border:1px solid var(--bwp-border);
99
+ background:var(--bwp-menu-bg);box-shadow:var(--bwp-menu-shadow);padding:4px;overflow:hidden;}
100
+ .bwp-menu-item{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;
101
+ padding:7px 8px;border-radius:6px;background:none;border:0;cursor:pointer;text-align:left;
102
+ font-size:14px;color:var(--bwp-ink);transition:background-color .12s;}
103
+ .bwp-menu-item:hover{background:var(--bwp-hover);}
104
+ .bwp-menu-left{display:flex;align-items:center;gap:8px;min-width:0;}
105
+ .bwp-menu-check{color:var(--bwp-check);flex-shrink:0;}
106
+
107
+ .bwp-tabs{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px;}
108
+ .bwp-tab{display:flex;flex-direction:column;gap:6px;padding:8px 12px;border-radius:8px;
109
+ border:1px solid var(--bwp-border);background:none;text-align:left;cursor:pointer;transition:all .18s;}
110
+ .bwp-tab:hover{background:var(--bwp-tab-hover);}
111
+ .bwp-tab.bwp-tab--on{border-color:var(--bwp-tab-on-border);background:var(--bwp-tab-on-bg);
112
+ box-shadow:0 1px 2px rgba(60,40,120,.08);}
113
+ .bwp-tab-top{display:flex;align-items:center;justify-content:space-between;gap:8px;}
114
+ .bwp-tab-label{font-size:11px;font-weight:600;color:var(--bwp-tab-label);}
115
+ .bwp-tab--on .bwp-tab-label{color:var(--bwp-tab-label-on);}
116
+ .bwp-tab-bal{font-size:14px;font-weight:700;font-variant-numeric:tabular-nums;color:var(--bwp-ink);}
117
+
118
+ .bwp-empty{font-size:14px;font-style:italic;color:var(--bwp-empty);}
119
+
120
+ .bwp-spin{animation:bwp-spin 1s linear infinite;color:var(--bwp-spin);}
121
+ @keyframes bwp-spin{to{transform:rotate(360deg);}}
122
+ @media (prefers-reduced-motion:reduce){.bwp-spin{animation:none;}}
123
+ `;
124
+
125
+ function useInjectedStyle() {
126
+ useEffect(() => {
127
+ if (typeof document === "undefined") return;
128
+ if (document.getElementById(STYLE_ID)) return;
129
+ const el = document.createElement("style");
130
+ el.id = STYLE_ID;
131
+ el.textContent = PANEL_CSS;
132
+ document.head.appendChild(el);
133
+ }, []);
134
+ }
135
+
136
+ // ── inline icons (no lucide dep) ───────────────────────────────────────────
137
+ function ChevronDownIcon({ size = 12, className }: { size?: number; className?: string }) {
138
+ return (
139
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden>
140
+ <path d="m6 9 6 6 6-6" />
141
+ </svg>
142
+ );
143
+ }
144
+ function CheckIcon({ size = 14, className, style }: { size?: number; className?: string; style?: CSSProperties }) {
145
+ return (
146
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" className={className} style={style} aria-hidden>
147
+ <path d="M20 6 9 17l-5-5" />
148
+ </svg>
149
+ );
150
+ }
151
+ function CopyIcon({ size = 14 }: { size?: number }) {
152
+ return (
153
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
154
+ <rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
155
+ <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
156
+ </svg>
157
+ );
158
+ }
159
+ function ExternalLinkIcon({ size = 14 }: { size?: number }) {
160
+ return (
161
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
162
+ <path d="M15 3h6v6" />
163
+ <path d="M10 14 21 3" />
164
+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
165
+ </svg>
166
+ );
167
+ }
168
+ function SpinnerIcon({ size = 20 }: { size?: number }) {
169
+ return (
170
+ <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" className="bwp-spin" aria-hidden>
171
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
172
+ </svg>
173
+ );
174
+ }
175
+
176
+ // ── vanilla dropdown (replaces Radix DropdownMenu) ─────────────────────────
177
+ function Menu({
178
+ triggerContent,
179
+ triggerClassName,
180
+ triggerAriaLabel,
181
+ disabled,
182
+ align = "start",
183
+ minWidth,
184
+ children,
185
+ }: {
186
+ triggerContent: ReactNode;
187
+ triggerClassName: string;
188
+ triggerAriaLabel?: string;
189
+ disabled?: boolean;
190
+ align?: "start" | "end";
191
+ minWidth?: number;
192
+ children: (close: () => void) => ReactNode;
193
+ }) {
194
+ const [open, setOpen] = useState(false);
195
+ const ref = useRef<HTMLDivElement>(null);
196
+
197
+ useEffect(() => {
198
+ if (!open) return;
199
+ const onDown = (e: MouseEvent) => {
200
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
201
+ };
202
+ const onKey = (e: KeyboardEvent) => {
203
+ if (e.key === "Escape") setOpen(false);
204
+ };
205
+ document.addEventListener("mousedown", onDown);
206
+ document.addEventListener("keydown", onKey);
207
+ return () => {
208
+ document.removeEventListener("mousedown", onDown);
209
+ document.removeEventListener("keydown", onKey);
210
+ };
211
+ }, [open]);
212
+
213
+ return (
214
+ <div className="bwp-menu-wrap" ref={ref}>
215
+ <button
216
+ type="button"
217
+ aria-label={triggerAriaLabel}
218
+ aria-haspopup="menu"
219
+ aria-expanded={open}
220
+ disabled={disabled}
221
+ onClick={() => setOpen((o) => !o)}
222
+ className={triggerClassName}
223
+ >
224
+ {triggerContent}
225
+ </button>
226
+ {open ? (
227
+ <div className="bwp-menu" role="menu" style={{ minWidth, [align === "end" ? "right" : "left"]: 0 }}>
228
+ {children(() => setOpen(false))}
229
+ </div>
230
+ ) : null}
231
+ </div>
232
+ );
233
+ }
234
+
235
+ // ── public types ───────────────────────────────────────────────────────────
236
+ export interface WalletPanelCurrency {
237
+ code: string;
238
+ name?: string;
239
+ }
240
+ export interface WalletPanelChain {
241
+ key: string;
242
+ label: string;
243
+ }
244
+ export interface WalletPanelTab {
245
+ key: string;
246
+ label: string;
247
+ /** Already display-formatted balance, e.g. "1,234.56". */
248
+ balance: string;
249
+ /** Token-icon cluster node (e.g. <AssetCircles>). */
250
+ icons?: ReactNode;
251
+ }
252
+
253
+ export interface WalletPanelProps {
254
+ /** Drives the scoped theme (inline styles, no Tailwind). Default "light". */
255
+ theme?: "light" | "dark";
256
+ /** "team" widens the card to 380px (matches desk). Default "personal". */
257
+ variant?: "personal" | "team";
258
+
259
+ // — Team summary tab cards (omit for personal) —
260
+ tabs?: WalletPanelTab[];
261
+ activeTab?: string;
262
+ onTabChange?: (key: string) => void;
263
+ /** Slot under the tab cards — team actions (move funds / allowlist). */
264
+ tabActions?: ReactNode;
265
+
266
+ /** Top slot — ghost/private cards, multisig inbox, prompts. */
267
+ topSlot?: ReactNode;
268
+
269
+ // — Total Balance row —
270
+ currencies: WalletPanelCurrency[];
271
+ /** "all" | currency code. */
272
+ selectedCurrency: string;
273
+ onCurrencySelect?: (code: string) => void;
274
+ /** Host renders a currency glyph. `active` = the selected one (host may emphasize). */
275
+ renderCurrencyIcon: (code: string, opts: { active: boolean }) => ReactNode;
276
+ /** Pill currency label, e.g. "All" | "USDC". */
277
+ currencyLabel: string;
278
+ /** Live total node (e.g. an AnimatedNumber); right-aligned in the pill. */
279
+ totalText: ReactNode;
280
+ isLoading?: boolean;
281
+
282
+ // — Select Chains row —
283
+ chains: WalletPanelChain[];
284
+ /** "all" | chain key. */
285
+ selectedChain: string;
286
+ onChainSelect?: (key: string) => void;
287
+ renderChainIcon: (key: string, opts: { size: number; active: boolean }) => ReactNode;
288
+ isSettingChain?: boolean;
289
+ /** Address pill. Null/empty → "No wallet". */
290
+ truncatedAddress?: string | null;
291
+ onCopy?: () => void;
292
+ copied?: boolean;
293
+ explorerUrl?: string | null;
294
+
295
+ /** No-address override for the whole rows block. */
296
+ hasAddress?: boolean;
297
+ emptyMessage?: ReactNode;
298
+
299
+ /** Bottom slot — additional wallets, analytics link, log out. */
300
+ footer?: ReactNode;
301
+
302
+ /** Extra class on the root surface (e.g. `ghost-popover` for the dark-mode wash). */
303
+ className?: string;
304
+ /** data-ghost / data-private-state passthrough (privacy theming hooks). */
305
+ ghostActive?: boolean;
306
+ }
307
+
308
+ export function WalletPanel({
309
+ theme = "light",
310
+ variant = "personal",
311
+ tabs,
312
+ activeTab,
313
+ onTabChange,
314
+ tabActions,
315
+ topSlot,
316
+ currencies,
317
+ selectedCurrency,
318
+ onCurrencySelect,
319
+ renderCurrencyIcon,
320
+ currencyLabel,
321
+ totalText,
322
+ isLoading,
323
+ chains,
324
+ selectedChain,
325
+ onChainSelect,
326
+ renderChainIcon,
327
+ isSettingChain,
328
+ truncatedAddress,
329
+ onCopy,
330
+ copied,
331
+ explorerUrl,
332
+ hasAddress = true,
333
+ emptyMessage = "No wallet",
334
+ footer,
335
+ className,
336
+ ghostActive,
337
+ }: WalletPanelProps): ReactNode {
338
+ useInjectedStyle();
339
+
340
+ // Selected currency floats first (desk parity).
341
+ const sortedCurrencies =
342
+ selectedCurrency === "all"
343
+ ? currencies
344
+ : [...currencies].sort((a, b) =>
345
+ a.code === selectedCurrency ? -1 : b.code === selectedCurrency ? 1 : 0,
346
+ );
347
+
348
+ const rows = (
349
+ <>
350
+ {/* Total Balance row */}
351
+ <div className="bwp-row">
352
+ <span className="bwp-label">
353
+ Total
354
+ <br />
355
+ Balance
356
+ </span>
357
+
358
+ <Menu
359
+ align="start"
360
+ minWidth={140}
361
+ triggerAriaLabel="Select currency"
362
+ triggerClassName="bwp-ctl bwp-ctl--int"
363
+ triggerContent={
364
+ <>
365
+ <span className="bwp-stack">
366
+ {sortedCurrencies.map((c) => {
367
+ const on = selectedCurrency !== "all" && c.code === selectedCurrency;
368
+ return (
369
+ <span
370
+ key={c.code}
371
+ className={`bwp-glyph ${on ? "bwp-glyph--on" : "bwp-glyph--dim"}`}
372
+ >
373
+ {renderCurrencyIcon(c.code, { active: on })}
374
+ </span>
375
+ );
376
+ })}
377
+ </span>
378
+ <ChevronDownIcon className="bwp-chev" />
379
+ </>
380
+ }
381
+ >
382
+ {(close) => (
383
+ <>
384
+ <button
385
+ type="button"
386
+ role="menuitem"
387
+ className="bwp-menu-item"
388
+ onClick={() => {
389
+ onCurrencySelect?.("all");
390
+ close();
391
+ }}
392
+ >
393
+ <span className="bwp-menu-left">
394
+ <span className="bwp-stack">
395
+ {currencies.slice(0, 2).map((c) => (
396
+ <span key={c.code} className="bwp-glyph">
397
+ {renderCurrencyIcon(c.code, { active: false })}
398
+ </span>
399
+ ))}
400
+ </span>
401
+ <span style={{ fontWeight: 500 }}>All</span>
402
+ </span>
403
+ {selectedCurrency === "all" ? <CheckIcon className="bwp-menu-check" /> : null}
404
+ </button>
405
+ {currencies.map((c) => (
406
+ <button
407
+ key={c.code}
408
+ type="button"
409
+ role="menuitem"
410
+ className="bwp-menu-item"
411
+ onClick={() => {
412
+ onCurrencySelect?.(c.code);
413
+ close();
414
+ }}
415
+ >
416
+ <span className="bwp-menu-left">
417
+ <span className="bwp-glyph">{renderCurrencyIcon(c.code, { active: false })}</span>
418
+ <span>{c.name ?? c.code}</span>
419
+ </span>
420
+ {selectedCurrency === c.code ? <CheckIcon className="bwp-menu-check" /> : null}
421
+ </button>
422
+ ))}
423
+ </>
424
+ )}
425
+ </Menu>
426
+
427
+ <div className="bwp-pill bwp-pill--bal">
428
+ <span className="bwp-cur-label">{currencyLabel}</span>
429
+ {isLoading ? (
430
+ <span style={{ marginLeft: "auto", display: "inline-flex" }}>
431
+ <SpinnerIcon />
432
+ </span>
433
+ ) : (
434
+ <span className="bwp-amount">{totalText}</span>
435
+ )}
436
+ </div>
437
+ </div>
438
+
439
+ <hr className="bwp-sep" />
440
+
441
+ {/* Select Chains row */}
442
+ <div className="bwp-row">
443
+ <span className="bwp-label">
444
+ Select
445
+ <br />
446
+ Chains
447
+ </span>
448
+
449
+ <Menu
450
+ align="start"
451
+ minWidth={200}
452
+ triggerAriaLabel="Select chain"
453
+ disabled={isSettingChain || chains.length === 0}
454
+ triggerClassName="bwp-ctl bwp-ctl--int"
455
+ triggerContent={
456
+ isSettingChain ? (
457
+ <SpinnerIcon />
458
+ ) : (
459
+ <>
460
+ <span className="bwp-stack bwp-stack--chain">
461
+ {chains.slice(0, 4).map((c) => {
462
+ const on = c.key === selectedChain;
463
+ return (
464
+ <span
465
+ key={c.key}
466
+ className={`bwp-glyph ${on ? "bwp-glyph--on" : "bwp-glyph--dim"}`}
467
+ >
468
+ {renderChainIcon(c.key, { size: 18, active: on })}
469
+ </span>
470
+ );
471
+ })}
472
+ {chains.length > 4 ? (
473
+ <span className="bwp-more">+{chains.length - 4}</span>
474
+ ) : null}
475
+ </span>
476
+ <ChevronDownIcon className="bwp-chev" />
477
+ </>
478
+ )
479
+ }
480
+ >
481
+ {(close) => (
482
+ <>
483
+ <button
484
+ type="button"
485
+ role="menuitem"
486
+ className="bwp-menu-item"
487
+ onClick={() => {
488
+ onChainSelect?.("all");
489
+ close();
490
+ }}
491
+ >
492
+ <span className="bwp-menu-left">
493
+ <span className="bwp-stack">
494
+ {chains.slice(0, 3).map((c) => (
495
+ <span key={c.key} className="bwp-glyph">
496
+ {renderChainIcon(c.key, { size: 16, active: false })}
497
+ </span>
498
+ ))}
499
+ </span>
500
+ <span style={{ fontWeight: 500 }}>All</span>
501
+ </span>
502
+ {selectedChain === "all" ? <CheckIcon className="bwp-menu-check" /> : null}
503
+ </button>
504
+ {chains.map((c) => (
505
+ <button
506
+ key={c.key}
507
+ type="button"
508
+ role="menuitem"
509
+ className="bwp-menu-item"
510
+ onClick={() => {
511
+ onChainSelect?.(c.key);
512
+ close();
513
+ }}
514
+ >
515
+ <span className="bwp-menu-left">
516
+ {renderChainIcon(c.key, { size: 20, active: false })}
517
+ <span style={{ fontWeight: 500 }}>{c.label}</span>
518
+ </span>
519
+ {selectedChain === c.key ? <CheckIcon className="bwp-menu-check" /> : null}
520
+ </button>
521
+ ))}
522
+ </>
523
+ )}
524
+ </Menu>
525
+
526
+ <div className="bwp-pill bwp-pill--addr">
527
+ {truncatedAddress ? (
528
+ <>
529
+ <span className="bwp-addr">{truncatedAddress}</span>
530
+ <span className="bwp-addr-actions">
531
+ <button type="button" className="bwp-iconbtn" onClick={onCopy} aria-label="Copy address">
532
+ {copied ? (
533
+ <CheckIcon size={14} style={{ color: "#22c55e" }} />
534
+ ) : (
535
+ <CopyIcon size={14} />
536
+ )}
537
+ </button>
538
+ {explorerUrl ? (
539
+ <a
540
+ href={explorerUrl}
541
+ target="_blank"
542
+ rel="noopener noreferrer"
543
+ className="bwp-iconbtn"
544
+ aria-label="View on explorer"
545
+ >
546
+ <ExternalLinkIcon size={14} />
547
+ </a>
548
+ ) : null}
549
+ </span>
550
+ </>
551
+ ) : (
552
+ <span className="bwp-addr-empty">{emptyMessage}</span>
553
+ )}
554
+ </div>
555
+ </div>
556
+ </>
557
+ );
558
+
559
+ return (
560
+ <div
561
+ className={`bwp-root ${variant === "team" ? "bwp-team" : ""} ${className ?? ""}`.trim()}
562
+ data-theme={theme}
563
+ data-ghost={ghostActive ? "true" : "false"}
564
+ data-private-state={ghostActive ? "true" : "false"}
565
+ >
566
+ {/* Team summary cards — Operations/Treasury selector */}
567
+ {tabs && tabs.length > 0 ? (
568
+ <div className="bwp-tabs">
569
+ {tabs.map((t) => {
570
+ const on = t.key === activeTab;
571
+ return (
572
+ <button
573
+ key={t.key}
574
+ type="button"
575
+ aria-pressed={on}
576
+ onClick={() => onTabChange?.(t.key)}
577
+ className={`bwp-tab ${on ? "bwp-tab--on" : ""}`.trim()}
578
+ >
579
+ <span className="bwp-tab-top">
580
+ <span className="bwp-tab-label">{t.label}</span>
581
+ {t.icons}
582
+ </span>
583
+ <span className="bwp-tab-bal">{t.balance}</span>
584
+ </button>
585
+ );
586
+ })}
587
+ </div>
588
+ ) : null}
589
+
590
+ {tabActions}
591
+ {topSlot}
592
+
593
+ {hasAddress ? rows : <div className="bwp-empty">{emptyMessage}</div>}
594
+
595
+ {footer}
596
+ </div>
597
+ );
598
+ }
599
+
600
+ export default WalletPanel;