@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,232 @@
1
+ import { cn, Icon, Text } from '@zerodev/react-ui'
2
+ import type { DepositedToken } from '@zerodev/smart-routing-address'
3
+ import { useMemo } from 'react'
4
+ import { TxnItem, type TxnStatus } from '../components/TxnItem'
5
+ import { useSmartRoutingAddressContext } from '../context/SmartRoutingAddressContext'
6
+ import { useDepositStatus } from '../hooks/useDepositStatus'
7
+ import { CHAIN_ICONS, TOKEN_ICONS } from '../iconAssets'
8
+ import type { DepositStage, DepositWithTimestamp } from '../types'
9
+ import { getTxUrl } from '../utils/chains'
10
+ import {
11
+ getSourceTokenSymbol,
12
+ resolveBaseUrl,
13
+ resolveDestChain,
14
+ sourceTokensFromFees,
15
+ } from '../utils/config'
16
+ import { getDepositStage } from '../utils/deposits'
17
+ import { findFeeDataByToken, tokenAddressMatches } from '../utils/fees'
18
+ import {
19
+ formatDisplayAmount,
20
+ formatRelativeTime,
21
+ truncateAddress,
22
+ } from '../utils/format'
23
+
24
+ const STAGE_TO_STATUS: Record<DepositStage, TxnStatus> = {
25
+ pending: 'Detected',
26
+ bridging: 'Routing',
27
+ completed: 'Received',
28
+ failed: 'Failed',
29
+ }
30
+
31
+ type Group = { label: string; sortKey: number; items: DepositWithTimestamp[] }
32
+
33
+ function dayStart(timestamp: number): number {
34
+ const d = new Date(timestamp)
35
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
36
+ }
37
+
38
+ function groupLabel(timestamp: number | undefined): {
39
+ label: string
40
+ sortKey: number
41
+ } {
42
+ if (!timestamp) return { label: 'Recent', sortKey: -1 }
43
+ const today = dayStart(Date.now())
44
+ const day = dayStart(timestamp)
45
+ if (day === today) return { label: 'Today', sortKey: today }
46
+ if (day === today - 86_400_000) return { label: 'Yesterday', sortKey: day }
47
+ return {
48
+ label: new Intl.DateTimeFormat('en-US', {
49
+ month: 'long',
50
+ day: 'numeric',
51
+ year: 'numeric',
52
+ }).format(new Date(timestamp)),
53
+ sortKey: day,
54
+ }
55
+ }
56
+
57
+ function groupByDay(deposits: DepositWithTimestamp[]): Group[] {
58
+ const buckets = new Map<string, Group>()
59
+ for (const deposit of deposits) {
60
+ const timestamp = deposit.createdAt
61
+ ? Date.parse(deposit.createdAt)
62
+ : undefined
63
+ const { label, sortKey } = groupLabel(
64
+ timestamp && !Number.isNaN(timestamp) ? timestamp : undefined,
65
+ )
66
+ const existing = buckets.get(label)
67
+ if (existing) existing.items.push(deposit)
68
+ else buckets.set(label, { label, sortKey, items: [deposit] })
69
+ }
70
+ return [...buckets.values()].sort((a, b) => b.sortKey - a.sortKey)
71
+ }
72
+
73
+ export interface PastDepositsProps {
74
+ /** Fired when a row is tapped — the widget uses this to open the
75
+ * transaction-details view for the selected deposit. When omitted, rows
76
+ * render as static (non-interactive). */
77
+ onSelectDeposit?: (deposit: DepositedToken) => void
78
+ }
79
+
80
+ /**
81
+ * Past deposits view — full history, grouped by day, newest first. Each row
82
+ * is a `TxnItem`; layout matches the deposit's `PendingDeposits` card so the
83
+ * two views share one visual language.
84
+ */
85
+ export function PastDeposits({ onSelectDeposit }: PastDepositsProps) {
86
+ const { config, addressState } = useSmartRoutingAddressContext()
87
+
88
+ const success = addressState.status === 'success' ? addressState : null
89
+ const address = success?.address
90
+ const estimatedFees = success?.estimatedFees ?? []
91
+
92
+ const { deposits, hasLoaded } = useDepositStatus({
93
+ address,
94
+ baseUrl: resolveBaseUrl(config),
95
+ })
96
+
97
+ const destChain = resolveDestChain(config)
98
+ const destChainLogo = CHAIN_ICONS[destChain.id]
99
+
100
+ const groups = useMemo(
101
+ () => groupByDay(deposits as DepositWithTimestamp[]),
102
+ [deposits],
103
+ )
104
+
105
+ return (
106
+ <div className="zd:flex zd:h-full zd:w-full zd:flex-col zd:gap-4 zd:pt-4 zd:pb-6">
107
+ <section
108
+ aria-label="Past deposits"
109
+ className={cn(
110
+ 'zd:relative zd:flex zd:w-full zd:flex-1 zd:min-h-0 zd:flex-col zd:overflow-hidden zd:rounded-2xl zd:p-2',
111
+ 'zd:border-offWhite zd:border-[0.3px] zd:bg-white/20',
112
+ 'zd:shadow-[inset_0_-4px_4px_0_rgba(255,255,255,0.1),inset_0_3px_4px_0_rgba(0,0,0,0.02)]',
113
+ )}
114
+ >
115
+ {!hasLoaded ? (
116
+ <output
117
+ aria-label="Loading past deposits"
118
+ className="zd:flex zd:flex-1 zd:items-center zd:justify-center zd:py-8"
119
+ >
120
+ <Icon
121
+ name="loading"
122
+ className="zd:size-5 zd:animate-spin zd:text-orange"
123
+ aria-hidden
124
+ />
125
+ </output>
126
+ ) : deposits.length === 0 ? (
127
+ <div className="zd:flex zd:flex-1 zd:items-center zd:justify-center zd:py-8">
128
+ <Text className="zd:text-greyScale/50">No deposits yet</Text>
129
+ </div>
130
+ ) : (
131
+ <div className="zd:flex zd:flex-1 zd:flex-col zd:gap-1 zd:overflow-y-auto">
132
+ {groups.map((group) => (
133
+ <section key={group.label} className="zd:flex zd:flex-col">
134
+ <div className="zd:px-2 zd:py-2">
135
+ <Text className="zd:text-body3 zd:text-greyScale">
136
+ {group.label}
137
+ </Text>
138
+ </div>
139
+ <ul className="zd:flex zd:flex-col">
140
+ {group.items.map((deposit) => {
141
+ const { chainId, token, amount, transactionHash } =
142
+ deposit.deposit
143
+ const feeData = findFeeDataByToken(
144
+ estimatedFees,
145
+ chainId,
146
+ token,
147
+ )
148
+ // Match on the on-chain address (via `tokenAddressMatches`)
149
+ // — the server's `feeData.name` is a display symbol (e.g.
150
+ // "ETH"), not the TOKEN_TYPE ("NATIVE"), so a direct
151
+ // tokenType === name compare misses native tokens.
152
+ const source =
153
+ sourceTokensFromFees(estimatedFees).find(
154
+ (t) =>
155
+ t.chain.id === chainId &&
156
+ tokenAddressMatches(t.tokenType, chainId, token),
157
+ ) ?? null
158
+ // Prefer the reconstructed source's symbol; fall back to
159
+ // the server's `feeData.name` so past deposits whose route
160
+ // dropped out of the current fee estimates still get a
161
+ // symbol / icon.
162
+ const sourceSymbol = source
163
+ ? getSourceTokenSymbol(source)
164
+ : (feeData?.name ?? '')
165
+ const sourceTokenLogo = sourceSymbol
166
+ ? TOKEN_ICONS[sourceSymbol.toUpperCase()]
167
+ : undefined
168
+ const sourceChainLogo = CHAIN_ICONS[chainId]
169
+ // Destination token equals source token — widget's default
170
+ // actions forward the deposited asset unchanged.
171
+ const destTokenLogo = sourceTokenLogo
172
+ const amountLabel = feeData
173
+ ? `${formatDisplayAmount(amount, feeData.decimal, 'down')} ${sourceSymbol}`
174
+ : String(amount)
175
+ const timestamp = deposit.createdAt
176
+ ? (formatRelativeTime(deposit.createdAt) ?? '')
177
+ : ''
178
+ // Explorer URL from the chain id alone — independent of
179
+ // whether the deposit's token matched the current fees.
180
+ const href = getTxUrl(chainId, transactionHash)
181
+ const status = STAGE_TO_STATUS[getDepositStage(deposit)]
182
+
183
+ const row = (
184
+ <TxnItem
185
+ amount={amountLabel}
186
+ address={truncateAddress(transactionHash)}
187
+ {...(href && { href })}
188
+ timestamp={timestamp}
189
+ status={status}
190
+ {...(sourceTokenLogo && {
191
+ sourceTokenIconUrl: sourceTokenLogo,
192
+ })}
193
+ {...(sourceChainLogo && {
194
+ sourceChainIconUrl: sourceChainLogo,
195
+ })}
196
+ {...(destTokenLogo && {
197
+ destTokenIconUrl: destTokenLogo,
198
+ })}
199
+ {...(destChainLogo && {
200
+ destChainIconUrl: destChainLogo,
201
+ })}
202
+ />
203
+ )
204
+ return (
205
+ <li key={transactionHash}>
206
+ {onSelectDeposit ? (
207
+ // -mx-1 + px-1 + a wider explicit width extend the
208
+ // hover surface 4px past the row content on each
209
+ // side, so the highlight has visible breathing
210
+ // room without shifting the TxnItem's position.
211
+ <button
212
+ type="button"
213
+ onClick={() => onSelectDeposit(deposit)}
214
+ className="zd:w-full zd:cursor-pointer zd:rounded-xl zd:px-1 zd:text-left zd:hover:bg-white/30"
215
+ >
216
+ {row}
217
+ </button>
218
+ ) : (
219
+ row
220
+ )}
221
+ </li>
222
+ )
223
+ })}
224
+ </ul>
225
+ </section>
226
+ ))}
227
+ </div>
228
+ )}
229
+ </section>
230
+ </div>
231
+ )
232
+ }
@@ -0,0 +1,466 @@
1
+ import {
2
+ ArrowCardPair,
3
+ cn,
4
+ DataRow,
5
+ Icon,
6
+ InfoCard,
7
+ Section,
8
+ Text,
9
+ Tooltip,
10
+ } from '@zerodev/react-ui'
11
+ import { type ReactNode, useState } from 'react'
12
+ import { FeeBreakdownRows, FeeSummary } from '../components/FeeBreakdown'
13
+ import { FEE_INFO } from '../components/FeeBreakdown/feeInfo'
14
+ import { useSmartRoutingAddressContext } from '../context/SmartRoutingAddressContext'
15
+ import { useDepositStatus } from '../hooks/useDepositStatus'
16
+ import { useProviderFees } from '../hooks/useProviderFees'
17
+ import { CHAIN_ICONS, PROVIDER_ICONS, TOKEN_ICONS } from '../iconAssets'
18
+ import type { DepositWithTimestamp } from '../types'
19
+ import { getTxUrl } from '../utils/chains'
20
+ import {
21
+ getSourceTokenSymbol,
22
+ resolveBaseUrl,
23
+ resolveDestChain,
24
+ sourceTokensFromFees,
25
+ } from '../utils/config'
26
+ import { getDepositStage } from '../utils/deposits'
27
+ import { findFeeDataByToken, tokenAddressMatches } from '../utils/fees'
28
+ import { formatDisplayAmount, truncateAddress } from '../utils/format'
29
+ import { buildFeeBreakdown } from '../utils/providerFees'
30
+
31
+ export interface TransactionDetailsProps {
32
+ deposit: DepositWithTimestamp
33
+ }
34
+
35
+ /**
36
+ * Poll the deposits list and return the latest snapshot of the deposit
37
+ * matching `initial.deposit.transactionHash`, falling back to `initial` until
38
+ * the first response lands (or if the deposit falls out of the list). Keeps
39
+ * the details view live while the same interval is already running in
40
+ * `Deposit` / `PastDeposits`.
41
+ */
42
+ function useLiveDeposit(initial: DepositWithTimestamp): DepositWithTimestamp {
43
+ const { config, addressState } = useSmartRoutingAddressContext()
44
+ const address =
45
+ addressState.status === 'success' ? addressState.address : undefined
46
+ const { deposits } = useDepositStatus({
47
+ address,
48
+ baseUrl: resolveBaseUrl(config),
49
+ })
50
+ const targetHash = initial.deposit.transactionHash
51
+ const live = deposits.find((d) => d.deposit.transactionHash === targetHash) as
52
+ | DepositWithTimestamp
53
+ | undefined
54
+ return live ?? initial
55
+ }
56
+
57
+ /** DOM id shared by the fee-toggle button's `aria-controls` and the panel it
58
+ * opens. Kept as a constant so both sides move together if renamed. */
59
+ const FEE_PANEL_ID = 'sra-tx-fee-panel'
60
+
61
+ function formatDate(iso?: string): string | null {
62
+ if (!iso) return null
63
+ const ts = Date.parse(iso)
64
+ if (Number.isNaN(ts)) return null
65
+ return new Intl.DateTimeFormat('en-US', {
66
+ month: 'short',
67
+ day: 'numeric',
68
+ year: 'numeric',
69
+ hour: 'numeric',
70
+ minute: '2-digit',
71
+ }).format(new Date(ts))
72
+ }
73
+
74
+ /**
75
+ * Transaction details view (Figma `17634:105049`) — token hero pair via
76
+ * `ArrowCardPair` + `InfoCard`, a "Transaction details" `Section` with the
77
+ * route metadata, and a "Transaction Progress" `Section` with the step trail.
78
+ */
79
+ export function TransactionDetails({
80
+ deposit: initialDeposit,
81
+ }: TransactionDetailsProps) {
82
+ // Re-poll and pick the latest snapshot for this deposit so the details view
83
+ // — Transaction Progress in particular — updates live rather than staying
84
+ // frozen at click-time state.
85
+ const deposit = useLiveDeposit(initialDeposit)
86
+ const { config, addressState, recipient } = useSmartRoutingAddressContext()
87
+ const estimatedFees =
88
+ addressState.status === 'success' ? addressState.estimatedFees : []
89
+
90
+ const { chainId, token, amount, transactionHash } = deposit.deposit
91
+ const feeData = findFeeDataByToken(estimatedFees, chainId, token)
92
+
93
+ // Match on the on-chain address (via `tokenAddressMatches`) — the server's
94
+ // `feeData.name` is a display symbol (e.g. "ETH"), not the TOKEN_TYPE
95
+ // ("NATIVE"), so a direct name compare would miss native tokens.
96
+ const source =
97
+ sourceTokensFromFees(estimatedFees).find(
98
+ (t) =>
99
+ t.chain.id === chainId &&
100
+ tokenAddressMatches(t.tokenType, chainId, token),
101
+ ) ?? null
102
+ const sourceSymbol = source ? getSourceTokenSymbol(source) : ''
103
+ const sourceChain = source?.chain
104
+ const sourceTokenLogo = sourceSymbol
105
+ ? TOKEN_ICONS[sourceSymbol.toUpperCase()]
106
+ : undefined
107
+ const sourceChainLogo = CHAIN_ICONS[chainId]
108
+
109
+ const destChain = resolveDestChain(config)
110
+ // Widget's default actions forward the deposited token, so destination
111
+ // token = source token — reuse the source logo.
112
+ const destTokenLogo = sourceTokenLogo
113
+ const destChainLogo = CHAIN_ICONS[destChain.id]
114
+
115
+ const stage = getDepositStage(deposit)
116
+ const execution = deposit.execution
117
+ const outAmountRaw = execution?.outputAmount
118
+ const failed = stage === 'failed'
119
+
120
+ // Headline reads as "how much you sent" — round down so we never overstate
121
+ // the deposit. `formatDisplayAmount` (magnitude-aware) also prevents the
122
+ // 18-decimal fractional tail that `formatTokenAmount` produces for ETH.
123
+ const sourceHeadline = feeData
124
+ ? `${formatDisplayAmount(amount, feeData.decimal, 'down')} ${sourceSymbol}`
125
+ : String(amount)
126
+
127
+ // NOTE: `feeData.decimal` is the source token's decimals — the SRA fee
128
+ // estimates don't include destination-token decimals, so this is an
129
+ // approximation that only reads correctly when source and destination
130
+ // decimals match. Fixing this properly needs a dest-decimals lookup and
131
+ // is out of scope here.
132
+ //
133
+ // Destination uses `'nearest'` rounding — the server returns the actual
134
+ // on-chain amount after fees + slippage (e.g. 249.9996), and users read
135
+ // this as "how much I received", so rounding to 250.00 is intuitive.
136
+ const destHeadline =
137
+ outAmountRaw && feeData
138
+ ? `${formatDisplayAmount(outAmountRaw, feeData.decimal, 'nearest')} ${sourceSymbol}`
139
+ : '—'
140
+
141
+ // `getTxUrl` looks up the chain independently of whether `source` was
142
+ // resolved above — a deposit on a known chain still gets a link even if
143
+ // its token wasn't matched via `sourceTokensFromFees`.
144
+ const sourceHref = getTxUrl(chainId, transactionHash)
145
+ const destHref = execution
146
+ ? getTxUrl(destChain.id, execution.transactionHash)
147
+ : undefined
148
+
149
+ // Refetch current Across / Relay quotes for the same route so the "Routing"
150
+ // step can show which provider was used. The historic provider isn't
151
+ // stored server-side; the assumption is the route hasn't changed materially
152
+ // and current quotes still name the same winning provider.
153
+ const providerFees = useProviderFees(source, destChain, feeData, recipient)
154
+ const breakdown =
155
+ feeData && sourceSymbol
156
+ ? buildFeeBreakdown(feeData, sourceSymbol, providerFees.fees)
157
+ : null
158
+ const [feeOpen, setFeeOpen] = useState(false)
159
+
160
+ const date = formatDate(deposit.createdAt)
161
+
162
+ return (
163
+ // No `h-full` — the page takes its natural content height so the
164
+ // Screen's scroll container can activate scrolling when the fee
165
+ // breakdown is expanded and content exceeds the viewport.
166
+ <div className="zd:flex zd:w-full zd:flex-col zd:gap-3 zd:pt-4 zd:pb-6">
167
+ <ArrowCardPair
168
+ topCard={
169
+ <InfoCard
170
+ title={sourceHeadline}
171
+ {...(sourceTokenLogo && { imageSource: sourceTokenLogo })}
172
+ {...(sourceChainLogo && { chainIconUrl: sourceChainLogo })}
173
+ />
174
+ }
175
+ bottomCard={
176
+ <InfoCard
177
+ title={destHeadline}
178
+ {...(destTokenLogo && { imageSource: destTokenLogo })}
179
+ {...(destChainLogo && { chainIconUrl: destChainLogo })}
180
+ />
181
+ }
182
+ />
183
+
184
+ <Section title="Transaction details">
185
+ <DataRow
186
+ label="From network"
187
+ value={
188
+ <NetworkValue
189
+ name={sourceChain?.name ?? '—'}
190
+ {...(sourceChainLogo && { logoUrl: sourceChainLogo })}
191
+ />
192
+ }
193
+ />
194
+ <DataRow
195
+ label="To network"
196
+ value={
197
+ <NetworkValue
198
+ name={destChain.name}
199
+ {...(destChainLogo && { logoUrl: destChainLogo })}
200
+ />
201
+ }
202
+ />
203
+ {date && <DataRow label="Date" value={date} />}
204
+ {breakdown && (
205
+ <DataRow
206
+ label="Total fee"
207
+ value={<FeeSummary breakdown={breakdown} />}
208
+ info
209
+ infoTooltip={FEE_INFO.estimatedFee}
210
+ trailing={
211
+ <button
212
+ type="button"
213
+ onClick={() => setFeeOpen((prev) => !prev)}
214
+ aria-expanded={feeOpen}
215
+ aria-controls={FEE_PANEL_ID}
216
+ aria-label={feeOpen ? 'Hide fee details' : 'Show fee details'}
217
+ className="zd:inline-flex zd:items-center zd:justify-center zd:cursor-pointer"
218
+ >
219
+ <Icon
220
+ name={feeOpen ? 'chevronUp' : 'chevronDown'}
221
+ className="zd:w-3.5 zd:h-3.5 zd:text-greyScale"
222
+ />
223
+ </button>
224
+ }
225
+ />
226
+ )}
227
+ {feeOpen && breakdown && (
228
+ <section id={FEE_PANEL_ID} aria-label="Fee breakdown">
229
+ <FeeBreakdownRows breakdown={breakdown} />
230
+ </section>
231
+ )}
232
+ </Section>
233
+
234
+ <Section title="Transaction Progress">
235
+ <ProgressStep
236
+ label="Deposit detected"
237
+ info={FEE_INFO.detected}
238
+ done
239
+ right={
240
+ <TxLink
241
+ label={truncateAddress(transactionHash)}
242
+ href={sourceHref}
243
+ />
244
+ }
245
+ />
246
+ <ProgressStep
247
+ label="Routing"
248
+ info={FEE_INFO.routing}
249
+ // Routing was at least attempted whenever we've left the initial
250
+ // detected stage — including the failed path (failure implies the
251
+ // route was tried), so mark it done there too rather than showing
252
+ // an inert pending circle above the "Failed" row.
253
+ done={stage === 'bridging' || stage === 'completed' || failed}
254
+ right={
255
+ providerFees.loading ? (
256
+ <ProviderChipSkeleton />
257
+ ) : breakdown?.provider ? (
258
+ <ProviderChip provider={breakdown.provider} />
259
+ ) : (
260
+ <StatusText>—</StatusText>
261
+ )
262
+ }
263
+ />
264
+ {failed ? (
265
+ <ProgressStep
266
+ label="Failed"
267
+ info={FEE_INFO.failed}
268
+ done={false}
269
+ failed
270
+ isLast
271
+ right={<StatusText tone="error">Error</StatusText>}
272
+ />
273
+ ) : (
274
+ <ProgressStep
275
+ label="Completed"
276
+ info={FEE_INFO.completed}
277
+ done={stage === 'completed'}
278
+ isLast
279
+ right={
280
+ execution ? (
281
+ <TxLink
282
+ label={truncateAddress(execution.transactionHash)}
283
+ href={destHref}
284
+ />
285
+ ) : (
286
+ <StatusText>Pending</StatusText>
287
+ )
288
+ }
289
+ />
290
+ )}
291
+ </Section>
292
+ </div>
293
+ )
294
+ }
295
+
296
+ function NetworkValue({ name, logoUrl }: { name: string; logoUrl?: string }) {
297
+ return (
298
+ <span className="zd:inline-flex zd:items-center zd:gap-1.5">
299
+ <Text className="zd:whitespace-nowrap zd:font-medium">{name}</Text>
300
+ {logoUrl && (
301
+ <img src={logoUrl} alt="" className="zd:size-4 zd:rounded-full" />
302
+ )}
303
+ </span>
304
+ )
305
+ }
306
+
307
+ function ProgressStep({
308
+ label,
309
+ info,
310
+ done,
311
+ failed,
312
+ isLast,
313
+ right,
314
+ }: {
315
+ label: string
316
+ info?: string | undefined
317
+ done: boolean
318
+ failed?: boolean
319
+ isLast?: boolean
320
+ right?: ReactNode
321
+ }) {
322
+ return (
323
+ <div className="zd:relative zd:flex zd:w-full zd:items-start zd:gap-3">
324
+ <div className="zd:flex zd:flex-col zd:items-center zd:pt-0.5">
325
+ <StatusMark done={done} failed={!!failed} />
326
+ {!isLast && (
327
+ <div
328
+ className={cn(
329
+ 'zd:mt-1 zd:h-4 zd:w-px',
330
+ done ? 'zd:bg-solarOrange/60' : 'zd:bg-greyScale/20',
331
+ )}
332
+ />
333
+ )}
334
+ </div>
335
+ <div className="zd:flex zd:min-w-0 zd:flex-1 zd:items-center zd:gap-1">
336
+ <Text
337
+ className={cn(
338
+ 'zd:whitespace-nowrap',
339
+ failed
340
+ ? 'zd:text-negative'
341
+ : // Mute the label until the step is done so a "Completed" row
342
+ // with an inert open circle doesn't read at full intensity
343
+ // while the flow is still bridging.
344
+ done
345
+ ? 'zd:text-greyScale'
346
+ : 'zd:text-greyScale/50',
347
+ )}
348
+ >
349
+ {label}
350
+ </Text>
351
+ {info && (
352
+ <Tooltip content={info}>
353
+ <button
354
+ type="button"
355
+ aria-label="More info"
356
+ className="zd:inline-flex zd:items-center zd:justify-center zd:cursor-help zd:outline-none zd:bg-transparent"
357
+ >
358
+ <Icon
359
+ name="info"
360
+ className="zd:w-3 zd:h-3 zd:text-greyScale/50"
361
+ aria-hidden
362
+ />
363
+ </button>
364
+ </Tooltip>
365
+ )}
366
+ </div>
367
+ <div className="zd:flex zd:shrink-0 zd:items-center">{right}</div>
368
+ </div>
369
+ )
370
+ }
371
+
372
+ function StatusMark({ done, failed }: { done: boolean; failed: boolean }) {
373
+ if (failed) {
374
+ return (
375
+ <Icon name="warning" className="zd:size-4 zd:text-negative" aria-hidden />
376
+ )
377
+ }
378
+ if (done) {
379
+ return (
380
+ <span className="zd:inline-flex zd:size-4 zd:items-center zd:justify-center zd:rounded-full zd:bg-solarOrange">
381
+ <Icon name="check" className="zd:size-3 zd:text-white" aria-hidden />
382
+ </span>
383
+ )
384
+ }
385
+ return (
386
+ <span className="zd:inline-block zd:size-4 zd:rounded-full zd:border zd:border-greyScale/30" />
387
+ )
388
+ }
389
+
390
+ function TxLink({ label, href }: { label: string; href?: string | undefined }) {
391
+ if (!href) {
392
+ return (
393
+ <span className="zd:inline-flex zd:items-center zd:gap-1 zd:text-body3 zd:text-greyScale">
394
+ {label}
395
+ </span>
396
+ )
397
+ }
398
+ return (
399
+ <a
400
+ href={href}
401
+ target="_blank"
402
+ rel="noreferrer noopener"
403
+ className="zd:inline-flex zd:items-center zd:gap-1 zd:text-body3 zd:text-greyScale zd:hover:text-solarOrange"
404
+ >
405
+ {label}
406
+ <Icon
407
+ name="export"
408
+ className="zd:size-3 zd:text-solarOrange"
409
+ aria-hidden
410
+ />
411
+ </a>
412
+ )
413
+ }
414
+
415
+ /** Small right-aligned status text used in `ProgressStep` — dedups the
416
+ * "Pending" / "—" / "Error" placeholders that all share the same size class
417
+ * and only differ by tone. */
418
+ function StatusText({
419
+ children,
420
+ tone = 'muted',
421
+ }: {
422
+ children: string
423
+ tone?: 'muted' | 'error'
424
+ }) {
425
+ return (
426
+ <Text
427
+ className={cn(
428
+ 'zd:text-body3',
429
+ tone === 'error' ? 'zd:text-negative' : 'zd:text-greyScale/50',
430
+ )}
431
+ >
432
+ {children}
433
+ </Text>
434
+ )
435
+ }
436
+
437
+ function ProviderChip({ provider }: { provider: string }) {
438
+ const iconUrl = PROVIDER_ICONS[provider]
439
+ return (
440
+ <span
441
+ className="zd:inline-flex zd:items-center zd:gap-1 zd:text-body3 zd:text-greyScale"
442
+ title={`Quoted via ${provider}`}
443
+ >
444
+ {provider}
445
+ {iconUrl && (
446
+ <img
447
+ src={iconUrl}
448
+ alt=""
449
+ aria-hidden
450
+ className="zd:size-3.5 zd:shrink-0 zd:rounded-sm zd:object-cover"
451
+ />
452
+ )}
453
+ </span>
454
+ )
455
+ }
456
+
457
+ /** Skeleton placeholder shown while `useProviderFees` is fetching quotes. */
458
+ function ProviderChipSkeleton() {
459
+ return (
460
+ <output
461
+ aria-busy="true"
462
+ aria-label="Loading provider"
463
+ className="zd:block zd:h-4 zd:w-16 zd:rounded-full zd:bg-greyScale/15 zd:animate-skel-pulse"
464
+ />
465
+ )
466
+ }