@zerodev/smart-routing-address-react-ui 0.0.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.
Files changed (100) hide show
  1. package/README.md +117 -0
  2. package/dist/_types/components/AddressDisplay/index.d.ts +43 -0
  3. package/dist/_types/components/AddressDisplay/index.d.ts.map +1 -0
  4. package/dist/_types/components/ErrorRetryCard/index.d.ts +21 -0
  5. package/dist/_types/components/ErrorRetryCard/index.d.ts.map +1 -0
  6. package/dist/_types/components/FeeBreakdown/feeInfo.d.ts +24 -0
  7. package/dist/_types/components/FeeBreakdown/feeInfo.d.ts.map +1 -0
  8. package/dist/_types/components/FeeBreakdown/index.d.ts +28 -0
  9. package/dist/_types/components/FeeBreakdown/index.d.ts.map +1 -0
  10. package/dist/_types/components/LoadingCard/index.d.ts +6 -0
  11. package/dist/_types/components/LoadingCard/index.d.ts.map +1 -0
  12. package/dist/_types/components/PendingDeposits/index.d.ts +14 -0
  13. package/dist/_types/components/PendingDeposits/index.d.ts.map +1 -0
  14. package/dist/_types/components/QrSheet/QrCode.d.ts +28 -0
  15. package/dist/_types/components/QrSheet/QrCode.d.ts.map +1 -0
  16. package/dist/_types/components/QrSheet/index.d.ts +16 -0
  17. package/dist/_types/components/QrSheet/index.d.ts.map +1 -0
  18. package/dist/_types/components/TxnItem/ExplorerLink.d.ts +16 -0
  19. package/dist/_types/components/TxnItem/ExplorerLink.d.ts.map +1 -0
  20. package/dist/_types/components/TxnItem/index.d.ts +19 -0
  21. package/dist/_types/components/TxnItem/index.d.ts.map +1 -0
  22. package/dist/_types/constants.d.ts +20 -0
  23. package/dist/_types/constants.d.ts.map +1 -0
  24. package/dist/_types/context/SmartRoutingAddressContext.d.ts +32 -0
  25. package/dist/_types/context/SmartRoutingAddressContext.d.ts.map +1 -0
  26. package/dist/_types/context/SmartRoutingAddressProvider.d.ts +13 -0
  27. package/dist/_types/context/SmartRoutingAddressProvider.d.ts.map +1 -0
  28. package/dist/_types/hooks/useCreateSmartRoutingAddress.d.ts +23 -0
  29. package/dist/_types/hooks/useCreateSmartRoutingAddress.d.ts.map +1 -0
  30. package/dist/_types/hooks/useDepositStatus.d.ts +25 -0
  31. package/dist/_types/hooks/useDepositStatus.d.ts.map +1 -0
  32. package/dist/_types/hooks/useNewDeposits.d.ts +8 -0
  33. package/dist/_types/hooks/useNewDeposits.d.ts.map +1 -0
  34. package/dist/_types/hooks/useProviderFees.d.ts +22 -0
  35. package/dist/_types/hooks/useProviderFees.d.ts.map +1 -0
  36. package/dist/_types/hooks/useSmartRoutingAddress.d.ts +20 -0
  37. package/dist/_types/hooks/useSmartRoutingAddress.d.ts.map +1 -0
  38. package/dist/_types/iconAssets.d.ts +7 -0
  39. package/dist/_types/iconAssets.d.ts.map +1 -0
  40. package/dist/_types/index.d.ts +14 -0
  41. package/dist/_types/index.d.ts.map +1 -0
  42. package/dist/_types/pages/Deposit.d.ts +12 -0
  43. package/dist/_types/pages/Deposit.d.ts.map +1 -0
  44. package/dist/_types/pages/PastDeposits.d.ts +14 -0
  45. package/dist/_types/pages/PastDeposits.d.ts.map +1 -0
  46. package/dist/_types/pages/TransactionDetails.d.ts +11 -0
  47. package/dist/_types/pages/TransactionDetails.d.ts.map +1 -0
  48. package/dist/_types/pages/index.d.ts +25 -0
  49. package/dist/_types/pages/index.d.ts.map +1 -0
  50. package/dist/_types/types.d.ts +75 -0
  51. package/dist/_types/types.d.ts.map +1 -0
  52. package/dist/_types/utils/chains.d.ts +8 -0
  53. package/dist/_types/utils/chains.d.ts.map +1 -0
  54. package/dist/_types/utils/config.d.ts +35 -0
  55. package/dist/_types/utils/config.d.ts.map +1 -0
  56. package/dist/_types/utils/deposits.d.ts +29 -0
  57. package/dist/_types/utils/deposits.d.ts.map +1 -0
  58. package/dist/_types/utils/fees.d.ts +14 -0
  59. package/dist/_types/utils/fees.d.ts.map +1 -0
  60. package/dist/_types/utils/format.d.ts +27 -0
  61. package/dist/_types/utils/format.d.ts.map +1 -0
  62. package/dist/_types/utils/providerFees.d.ts +103 -0
  63. package/dist/_types/utils/providerFees.d.ts.map +1 -0
  64. package/dist/index.cjs +2 -0
  65. package/dist/index.cjs.map +1 -0
  66. package/dist/index.mjs +2180 -0
  67. package/dist/index.mjs.map +1 -0
  68. package/dist/styles.css +2 -0
  69. package/package.json +91 -0
  70. package/src/components/AddressDisplay/index.tsx +108 -0
  71. package/src/components/ErrorRetryCard/index.tsx +68 -0
  72. package/src/components/FeeBreakdown/feeInfo.ts +25 -0
  73. package/src/components/FeeBreakdown/index.tsx +218 -0
  74. package/src/components/LoadingCard/index.tsx +27 -0
  75. package/src/components/PendingDeposits/index.tsx +152 -0
  76. package/src/components/QrSheet/QrCode.tsx +163 -0
  77. package/src/components/QrSheet/index.tsx +80 -0
  78. package/src/components/TxnItem/ExplorerLink.tsx +37 -0
  79. package/src/components/TxnItem/index.tsx +178 -0
  80. package/src/constants.ts +102 -0
  81. package/src/context/SmartRoutingAddressContext.ts +48 -0
  82. package/src/context/SmartRoutingAddressProvider.tsx +145 -0
  83. package/src/hooks/useCreateSmartRoutingAddress.ts +28 -0
  84. package/src/hooks/useDepositStatus.ts +110 -0
  85. package/src/hooks/useNewDeposits.ts +25 -0
  86. package/src/hooks/useProviderFees.ts +235 -0
  87. package/src/hooks/useSmartRoutingAddress.ts +25 -0
  88. package/src/iconAssets.ts +130 -0
  89. package/src/index.ts +27 -0
  90. package/src/pages/Deposit.tsx +605 -0
  91. package/src/pages/PastDeposits.tsx +232 -0
  92. package/src/pages/TransactionDetails.tsx +466 -0
  93. package/src/pages/index.tsx +165 -0
  94. package/src/types.ts +84 -0
  95. package/src/utils/chains.ts +26 -0
  96. package/src/utils/config.ts +142 -0
  97. package/src/utils/deposits.ts +106 -0
  98. package/src/utils/fees.ts +51 -0
  99. package/src/utils/format.ts +108 -0
  100. package/src/utils/providerFees.ts +301 -0
@@ -0,0 +1,605 @@
1
+ import {
2
+ ArrowCardPair,
3
+ cn,
4
+ DataRow,
5
+ Icon,
6
+ Pill,
7
+ Select,
8
+ SelectContent,
9
+ SelectIcon,
10
+ SelectItem,
11
+ SelectTrigger,
12
+ Text,
13
+ TokenListItem,
14
+ Tooltip,
15
+ Wrapper,
16
+ } from '@zerodev/react-ui'
17
+ import type { DepositedToken, TOKEN_TYPE } from '@zerodev/smart-routing-address'
18
+ import { useCallback, useEffect, useMemo, useState } from 'react'
19
+ import { AddressDisplay } from '../components/AddressDisplay'
20
+ import { ErrorRetryCard } from '../components/ErrorRetryCard'
21
+ import {
22
+ FeeBreakdownRows,
23
+ FeeSummary,
24
+ LiveValue,
25
+ } from '../components/FeeBreakdown'
26
+ import { FEE_INFO } from '../components/FeeBreakdown/feeInfo'
27
+ import { LoadingCard } from '../components/LoadingCard'
28
+ import { PendingDeposits } from '../components/PendingDeposits'
29
+ import { DEFAULT_FILL_TIME_SECONDS } from '../constants'
30
+ import { useSmartRoutingAddressContext } from '../context/SmartRoutingAddressContext'
31
+ import { useDepositStatus } from '../hooks/useDepositStatus'
32
+ import { useNewDeposits } from '../hooks/useNewDeposits'
33
+ import { useProviderFees } from '../hooks/useProviderFees'
34
+ import { CHAIN_ICONS, PROVIDER_ICONS, TOKEN_ICONS } from '../iconAssets'
35
+ import type { SourceToken } from '../types'
36
+ import {
37
+ getSourceTokenSymbol,
38
+ resolveBaseUrl,
39
+ resolveDestChain,
40
+ sourceTokensFromFees,
41
+ } from '../utils/config'
42
+ import { findFeeData, resolveTokenAddress } from '../utils/fees'
43
+ import {
44
+ formatDisplayAmount,
45
+ formatDuration,
46
+ formatSlippage,
47
+ } from '../utils/format'
48
+ import { buildFeeBreakdown } from '../utils/providerFees'
49
+
50
+ export interface DepositProps {
51
+ onQrClick?: () => void
52
+ /** Navigate to the "Past deposits" view. When omitted, the row is hidden. */
53
+ onViewPastDeposits?: () => void
54
+ /** Fired when a pending-deposit row is tapped. Wires through to
55
+ * `PendingDeposits` so an in-flight deposit opens the transaction-details
56
+ * view, matching the past-deposits behaviour. */
57
+ onSelectDeposit?: (deposit: DepositedToken) => void
58
+ }
59
+
60
+ const SUBTITLE =
61
+ "Send any supported token from any network. We'll swap & bridge it directly to your account, ready to use."
62
+
63
+ // The two picker panels span the full pill row (Figma "Send" layout), not
64
+ // just the trigger cell each Select lives in. Trigger width = half the row
65
+ // minus half the 4px gap, so full-row width = trigger * 2 + 4px.
66
+ const FULL_ROW_PANEL_STYLE = {
67
+ width: 'calc(var(--radix-select-trigger-width) * 2 + 4px)',
68
+ }
69
+
70
+ export function Deposit({
71
+ onQrClick,
72
+ onViewPastDeposits,
73
+ onSelectDeposit,
74
+ }: DepositProps) {
75
+ const { config, addressState, recipient, retry, setActiveRoute } =
76
+ useSmartRoutingAddressContext()
77
+ const [feeOpen, setFeeOpen] = useState(false)
78
+ const [retrying, setRetrying] = useState(false)
79
+
80
+ const success = addressState.status === 'success' ? addressState : null
81
+ const address = success?.address
82
+ const estimatedFees = success?.estimatedFees ?? []
83
+
84
+ // allowPartialRoutes lets the server drop source tokens it can't route, so
85
+ // the routable set is exactly the tokens the fee estimates came back with.
86
+ const srcTokens = useMemo(
87
+ () => sourceTokensFromFees(estimatedFees),
88
+ [estimatedFees],
89
+ )
90
+
91
+ // Track the user's picker selection. `null` means "no explicit choice yet" —
92
+ // an effect below seeds it to the first routable option once srcTokens land.
93
+ const [selectedTokenType, setSelectedTokenType] = useState<TOKEN_TYPE | null>(
94
+ null,
95
+ )
96
+ const [selectedChainId, setSelectedChainId] = useState<number | null>(null)
97
+
98
+ // Seed picker state from the first routable option as soon as srcTokens
99
+ // arrive. Also re-seeds if a stale selection becomes unroutable.
100
+ useEffect(() => {
101
+ if (srcTokens.length === 0) return
102
+ const currentValid = srcTokens.some(
103
+ (t) =>
104
+ t.tokenType === selectedTokenType && t.chain.id === selectedChainId,
105
+ )
106
+ if (currentValid) return
107
+ // Prefer to keep the token, swap the chain to one that's available for it.
108
+ const forSameToken = srcTokens.find(
109
+ (t) => t.tokenType === selectedTokenType,
110
+ )
111
+ const fallback = forSameToken ?? srcTokens[0]
112
+ if (!fallback) return
113
+ setSelectedTokenType(fallback.tokenType)
114
+ setSelectedChainId(fallback.chain.id)
115
+ }, [srcTokens, selectedTokenType, selectedChainId])
116
+
117
+ const source: SourceToken | null = useMemo(() => {
118
+ if (!selectedTokenType || selectedChainId === null) {
119
+ return srcTokens[0] ?? null
120
+ }
121
+ return (
122
+ srcTokens.find(
123
+ (t) =>
124
+ t.tokenType === selectedTokenType && t.chain.id === selectedChainId,
125
+ ) ??
126
+ srcTokens[0] ??
127
+ null
128
+ )
129
+ }, [srcTokens, selectedTokenType, selectedChainId])
130
+
131
+ const {
132
+ deposits,
133
+ hasLoaded,
134
+ error: depositsError,
135
+ refetch: refetchDeposits,
136
+ } = useDepositStatus({
137
+ address,
138
+ baseUrl: resolveBaseUrl(config),
139
+ })
140
+ const newDeposits = useNewDeposits(deposits, hasLoaded)
141
+ const pastDepositsCount = deposits.length - newDeposits.length
142
+
143
+ const destChain = resolveDestChain(config)
144
+ // Widget's default actions forward the deposited token, so the destination
145
+ // token equals the source token — one symbol and logo serve both cards.
146
+ // Only the chain differs, hence the source/dest split below.
147
+ const tokenSymbol = source ? getSourceTokenSymbol(source) : undefined
148
+ const tokenLogo = tokenSymbol
149
+ ? TOKEN_ICONS[tokenSymbol.toUpperCase()]
150
+ : undefined
151
+ const sourceChainLogo = source ? CHAIN_ICONS[source.chain.id] : undefined
152
+ const destChainLogo = CHAIN_ICONS[destChain.id]
153
+ const feeData = source
154
+ ? findFeeData(estimatedFees, source.chain.id, source.tokenType)
155
+ : null
156
+ const fillTime = formatDuration(DEFAULT_FILL_TIME_SECONDS)
157
+
158
+ // Live bridge quotes from Across / Relay, keyed off the selected route.
159
+ // Enriches the SRA fee estimate with the itemised legs it doesn't expose.
160
+ const providerFees = useProviderFees(source, destChain, feeData, recipient)
161
+ const breakdown =
162
+ feeData && tokenSymbol
163
+ ? buildFeeBreakdown(feeData, tokenSymbol, providerFees.fees)
164
+ : null
165
+
166
+ // Publish the current picker selection so hosts (e.g. a demo "send" panel)
167
+ // can mirror the widget's route. Cleared when the picker is empty so
168
+ // downstream mocks show their fallback instead of stale state.
169
+ useEffect(() => {
170
+ if (!source || !feeData || !tokenSymbol) {
171
+ setActiveRoute(null)
172
+ return
173
+ }
174
+ const token = resolveTokenAddress(source.tokenType, source.chain.id)
175
+ if (!token) {
176
+ setActiveRoute(null)
177
+ return
178
+ }
179
+ setActiveRoute({
180
+ sourceChainId: source.chain.id,
181
+ sourceChainName: source.chain.name,
182
+ token,
183
+ symbol: tokenSymbol,
184
+ decimals: feeData.decimal,
185
+ // `feeData.fee` is a `Hex` string from the SDK — normalise to a
186
+ // decimal atomic-units string here so `activeRoute.feeAmount` has
187
+ // one consistent representation across every write path (see
188
+ // `ActiveRoute.feeAmount` — the demo's fallback uses decimals too).
189
+ // Hosts using `BigInt()` work either way, but `Number()` /
190
+ // `parseInt(x, 10)` would silently misread the hex form.
191
+ feeAmount: BigInt(feeData.fee).toString(),
192
+ })
193
+ }, [source, feeData, tokenSymbol, setActiveRoute])
194
+
195
+ // Deduped list of routable token types — the token picker's rows. Chain
196
+ // count per token drives the subtitle. Kept as SourceToken (not a bespoke
197
+ // dropdown-item shape) so the render below can call the same helpers used
198
+ // for the trigger.
199
+ const uniqueTokens = useMemo(() => {
200
+ const seen = new Set<TOKEN_TYPE>()
201
+ const out: SourceToken[] = []
202
+ for (const token of srcTokens) {
203
+ if (seen.has(token.tokenType)) continue
204
+ seen.add(token.tokenType)
205
+ out.push(token)
206
+ }
207
+ return out
208
+ }, [srcTokens])
209
+
210
+ // Chains that carry the currently-selected token — the chain picker's rows.
211
+ const availableChains = useMemo(() => {
212
+ if (!selectedTokenType) return []
213
+ const seen = new Set<number>()
214
+ const out: SourceToken[] = []
215
+ for (const token of srcTokens) {
216
+ if (token.tokenType !== selectedTokenType) continue
217
+ if (seen.has(token.chain.id)) continue
218
+ seen.add(token.chain.id)
219
+ out.push(token)
220
+ }
221
+ return out
222
+ }, [srcTokens, selectedTokenType])
223
+
224
+ // Only formatted when the consumer explicitly sets a slippage — otherwise
225
+ // the server picks its own default and there is no meaningful value to show.
226
+ const slippage =
227
+ typeof config.slippage === 'number' ? formatSlippage(config.slippage) : null
228
+
229
+ const minDepositAmount =
230
+ feeData && tokenSymbol
231
+ ? `${formatDisplayAmount(feeData.minDeposit, feeData.decimal, 'up')} ${tokenSymbol}`
232
+ : null
233
+
234
+ // Flash key re-triggers the LiveValue animation on the estimated-fee row
235
+ // when the underlying quote changes; combining every meaningful component
236
+ // catches all updates in one dependency.
237
+ const feeFlashKey = breakdown
238
+ ? `${breakdown.totalText ?? ''}|${breakdown.ratePct ?? ''}|${breakdown.flatUsd ?? ''}`
239
+ : ''
240
+
241
+ const pickerDisabled = uniqueTokens.length === 0
242
+ const sourceChainName = source?.chain.name
243
+
244
+ // Error surfaces the retry card handles. Only one is shown at a time,
245
+ // matched top-down in severity order: (1) address creation itself failed,
246
+ // (2) creation succeeded but no routes came back, (3) deposit polling is
247
+ // erroring after we already have an address. `hasLoaded` gates the polling
248
+ // case so first-load latency isn't mistaken for an error.
249
+ const addressError = addressState.status === 'error'
250
+ const noRoutesError = !!success && srcTokens.length === 0
251
+ const pollingError = !!address && !!depositsError && hasLoaded
252
+ const errorMessage = addressError
253
+ ? 'Failed to create deposit address...'
254
+ : noRoutesError
255
+ ? 'No routes found, try one more time...'
256
+ : pollingError
257
+ ? 'Failed to load deposits, try again...'
258
+ : null
259
+
260
+ const handleRetry = useCallback(async () => {
261
+ if (retrying) return
262
+ setRetrying(true)
263
+ try {
264
+ if (pollingError && !addressError && !noRoutesError) {
265
+ refetchDeposits()
266
+ } else {
267
+ await retry()
268
+ }
269
+ } finally {
270
+ setRetrying(false)
271
+ }
272
+ }, [
273
+ retrying,
274
+ pollingError,
275
+ addressError,
276
+ noRoutesError,
277
+ refetchDeposits,
278
+ retry,
279
+ ])
280
+
281
+ return (
282
+ <div className="zd:flex zd:h-full zd:w-full zd:flex-col zd:items-center zd:gap-4 zd:pt-4 zd:pb-6">
283
+ <Text className="zd:w-full zd:text-center">{SUBTITLE}</Text>
284
+
285
+ <div className="zd:relative zd:flex zd:w-full zd:flex-1 zd:flex-col zd:gap-2">
286
+ <ArrowCardPair
287
+ topCard={
288
+ <Wrapper
289
+ variant="ghost"
290
+ className="zd:relative zd:flex zd:w-full zd:flex-col zd:rounded-2xl zd:p-1"
291
+ >
292
+ <CardTitle>Send</CardTitle>
293
+ <PillRow
294
+ left={
295
+ <Select
296
+ value={selectedTokenType ?? ''}
297
+ onValueChange={(id) =>
298
+ setSelectedTokenType(id as TOKEN_TYPE)
299
+ }
300
+ disabled={pickerDisabled}
301
+ >
302
+ <SelectTrigger asChild>
303
+ <Pill
304
+ label={tokenSymbol ?? ''}
305
+ {...(tokenLogo && { logoUri: tokenLogo })}
306
+ disabled={pickerDisabled}
307
+ loading={!tokenSymbol}
308
+ trailingIcon={!pickerDisabled && <SelectIcon />}
309
+ />
310
+ </SelectTrigger>
311
+ <SelectContent align="start" style={FULL_ROW_PANEL_STYLE}>
312
+ {uniqueTokens.map((token, i) => {
313
+ const symbol = getSourceTokenSymbol(token)
314
+ const chainCount = srcTokens.filter(
315
+ (t) => t.tokenType === token.tokenType,
316
+ ).length
317
+ const logo = TOKEN_ICONS[symbol.toUpperCase()]
318
+ return (
319
+ <SelectItem
320
+ key={token.tokenType}
321
+ value={token.tokenType}
322
+ textValue={symbol}
323
+ className="zd:p-0"
324
+ >
325
+ <TokenListItem
326
+ symbol={symbol}
327
+ subtitle={`${chainCount} network${chainCount === 1 ? '' : 's'}`}
328
+ {...(logo && { imageSource: logo })}
329
+ />
330
+ {i === 0 && (
331
+ <span className="zd:absolute zd:top-1/2 zd:right-3 zd:-translate-y-1/2 zd:inline-flex zd:items-center zd:rounded-full zd:bg-positive/15 zd:px-2 zd:py-1 zd:text-body3 zd:text-positive zd:pointer-events-none">
332
+ Recommended
333
+ </span>
334
+ )}
335
+ </SelectItem>
336
+ )
337
+ })}
338
+ </SelectContent>
339
+ </Select>
340
+ }
341
+ right={
342
+ <Select
343
+ value={
344
+ selectedChainId !== null ? String(selectedChainId) : ''
345
+ }
346
+ onValueChange={(id) => setSelectedChainId(Number(id))}
347
+ disabled={pickerDisabled}
348
+ >
349
+ <SelectTrigger asChild>
350
+ <Pill
351
+ label={sourceChainName ?? ''}
352
+ {...(sourceChainLogo && { logoUri: sourceChainLogo })}
353
+ disabled={pickerDisabled}
354
+ loading={!sourceChainName}
355
+ trailingIcon={!pickerDisabled && <SelectIcon />}
356
+ />
357
+ </SelectTrigger>
358
+ <SelectContent align="end" style={FULL_ROW_PANEL_STYLE}>
359
+ {availableChains.map((token) => {
360
+ const logo = CHAIN_ICONS[token.chain.id]
361
+ return (
362
+ <SelectItem
363
+ key={token.chain.id}
364
+ value={String(token.chain.id)}
365
+ textValue={token.chain.name}
366
+ className="zd:p-0"
367
+ >
368
+ <TokenListItem
369
+ symbol={token.chain.name}
370
+ iconVariant="network"
371
+ {...(logo && { imageSource: logo })}
372
+ />
373
+ </SelectItem>
374
+ )
375
+ })}
376
+ </SelectContent>
377
+ </Select>
378
+ }
379
+ />
380
+ <div className="zd:flex zd:w-full zd:flex-col zd:items-start zd:gap-2 zd:px-2 zd:py-4">
381
+ {slippage && (
382
+ <DataRow
383
+ label="Max slippage"
384
+ value={slippage}
385
+ info
386
+ infoTooltip={FEE_INFO.maxSlippage}
387
+ trailing={
388
+ breakdown?.provider &&
389
+ PROVIDER_ICONS[breakdown.provider] ? (
390
+ <LiveValue
391
+ loading={providerFees.loading}
392
+ flashKey={breakdown.provider}
393
+ >
394
+ <Tooltip content={`Quoted via ${breakdown.provider}`}>
395
+ <button
396
+ type="button"
397
+ aria-label={`Quoted via ${breakdown.provider}`}
398
+ className="zd:inline-flex zd:items-center zd:justify-center zd:cursor-help zd:outline-none zd:bg-transparent"
399
+ >
400
+ <img
401
+ src={PROVIDER_ICONS[breakdown.provider]}
402
+ alt=""
403
+ aria-hidden
404
+ className="zd:size-4 zd:shrink-0 zd:rounded-[4px] zd:object-cover"
405
+ />
406
+ </button>
407
+ </Tooltip>
408
+ </LiveValue>
409
+ ) : null
410
+ }
411
+ />
412
+ )}
413
+ <DataRow
414
+ label="Estimated fee"
415
+ value={
416
+ breakdown ? (
417
+ <LiveValue
418
+ loading={providerFees.loading}
419
+ flashKey={feeFlashKey}
420
+ >
421
+ <FeeSummary breakdown={breakdown} />
422
+ </LiveValue>
423
+ ) : (
424
+ // Match Min deposit's skeleton so both loading
425
+ // affordances share one visual language.
426
+ <div className="zd:h-3.5 zd:w-20 zd:rounded-md zd:bg-greyScale/15 zd:animate-skel-pulse" />
427
+ )
428
+ }
429
+ info
430
+ infoTooltip={FEE_INFO.estimatedFee}
431
+ trailing={
432
+ breakdown ? (
433
+ <button
434
+ type="button"
435
+ onClick={() => setFeeOpen((prev) => !prev)}
436
+ aria-expanded={feeOpen}
437
+ aria-label={
438
+ feeOpen ? 'Hide fee details' : 'Show fee details'
439
+ }
440
+ className="zd:inline-flex zd:items-center zd:justify-center zd:cursor-pointer"
441
+ >
442
+ <Icon
443
+ name={feeOpen ? 'chevronUp' : 'chevronDown'}
444
+ className="zd:w-3.5 zd:h-3.5 zd:text-greyScale"
445
+ />
446
+ </button>
447
+ ) : null
448
+ }
449
+ />
450
+ {feeOpen && breakdown && (
451
+ <FeeBreakdownRows breakdown={breakdown} />
452
+ )}
453
+ </div>
454
+ </Wrapper>
455
+ }
456
+ bottomCard={
457
+ <Wrapper
458
+ variant="ghost"
459
+ className="zd:relative zd:flex zd:w-full zd:flex-col zd:gap-2 zd:rounded-2xl zd:p-1"
460
+ >
461
+ <CardTitle>Arrives as</CardTitle>
462
+ <PillRow
463
+ left={
464
+ <Pill
465
+ label={tokenSymbol ?? ''}
466
+ {...(tokenLogo && { logoUri: tokenLogo })}
467
+ disabled
468
+ loading={!tokenSymbol}
469
+ />
470
+ }
471
+ right={
472
+ <Pill
473
+ label={destChain.name}
474
+ {...(destChainLogo && { logoUri: destChainLogo })}
475
+ disabled
476
+ />
477
+ }
478
+ />
479
+ <div className="zd:flex zd:w-full zd:flex-col zd:items-start zd:px-2">
480
+ <DataRow
481
+ label="Ready in"
482
+ value={fillTime}
483
+ info
484
+ infoTooltip={FEE_INFO.readyIn}
485
+ />
486
+ </div>
487
+ <AddressDisplay
488
+ status={addressState.status}
489
+ address={address}
490
+ onQrClick={onQrClick}
491
+ />
492
+ {errorMessage && (
493
+ <ErrorRetryCard
494
+ message={errorMessage}
495
+ onRetry={handleRetry}
496
+ busy={retrying}
497
+ />
498
+ )}
499
+ <DataRow
500
+ label="Min deposit"
501
+ value={
502
+ minDepositAmount ?? (
503
+ // Match PillSkeleton's greyish pulse so all loading
504
+ // affordances in this card share one visual language.
505
+ // `<output>` gets an implicit `role="status"` — biome's
506
+ // `useSemanticElements` prefers it over `<div role>`.
507
+ // `aria-busy` + `aria-label` so screen readers hear
508
+ // "loading" instead of landing on an empty region.
509
+ <output
510
+ aria-busy="true"
511
+ aria-label="Loading minimum deposit"
512
+ className="zd:block zd:h-3.5 zd:w-20 zd:rounded-md zd:bg-greyScale/15 zd:animate-skel-pulse"
513
+ />
514
+ )
515
+ }
516
+ info
517
+ infoTooltip={FEE_INFO.minDeposit}
518
+ variant="warning"
519
+ />
520
+ </Wrapper>
521
+ }
522
+ />
523
+
524
+ {newDeposits.length > 0 ? (
525
+ <PendingDeposits
526
+ deposits={newDeposits}
527
+ estimatedFees={estimatedFees}
528
+ config={config}
529
+ {...(onSelectDeposit && { onSelectDeposit })}
530
+ />
531
+ ) : (
532
+ <LoadingCard
533
+ text={
534
+ source
535
+ ? `Watching for your deposit on ${source.chain.name}…`
536
+ : 'Watching for your deposit…'
537
+ }
538
+ />
539
+ )}
540
+
541
+ {pastDepositsCount > 0 &&
542
+ (onViewPastDeposits ? (
543
+ <button
544
+ type="button"
545
+ onClick={onViewPastDeposits}
546
+ className="zd:flex zd:w-full zd:items-center zd:gap-2 zd:px-4 zd:py-4 zd:cursor-pointer"
547
+ >
548
+ <Icon
549
+ name="clock"
550
+ className="zd:size-4 zd:text-greyScale/50"
551
+ aria-hidden
552
+ />
553
+ <Text className="zd:flex-1 zd:text-left zd:text-body1">
554
+ Past deposits ({pastDepositsCount})
555
+ </Text>
556
+ <Icon
557
+ name="chevronRight"
558
+ className="zd:size-4 zd:text-greyScale/50"
559
+ aria-hidden
560
+ />
561
+ </button>
562
+ ) : (
563
+ <div className="zd:flex zd:w-full zd:items-center zd:gap-2 zd:px-4 zd:py-4">
564
+ <Icon
565
+ name="clock"
566
+ className="zd:size-4 zd:text-greyScale/50"
567
+ aria-hidden
568
+ />
569
+ <Text className="zd:flex-1 zd:text-left zd:text-body1">
570
+ Past deposits ({pastDepositsCount})
571
+ </Text>
572
+ <Icon
573
+ name="chevronRight"
574
+ className="zd:size-4 zd:text-greyScale/50"
575
+ aria-hidden
576
+ />
577
+ </div>
578
+ ))}
579
+ </div>
580
+ </div>
581
+ )
582
+ }
583
+
584
+ function CardTitle({ children }: { children: string }) {
585
+ return (
586
+ <div className={cn('zd:flex zd:items-center zd:px-2 zd:py-3')}>
587
+ <Text className="zd:whitespace-nowrap zd:text-h3">{children}</Text>
588
+ </div>
589
+ )
590
+ }
591
+
592
+ function PillRow({
593
+ left,
594
+ right,
595
+ }: {
596
+ left: React.ReactNode
597
+ right: React.ReactNode
598
+ }) {
599
+ return (
600
+ <div className="zd:flex zd:w-full zd:items-start zd:gap-1">
601
+ <div className="zd:min-w-0 zd:flex-1">{left}</div>
602
+ <div className="zd:min-w-0 zd:flex-1">{right}</div>
603
+ </div>
604
+ )
605
+ }