@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.
- package/README.md +117 -0
- package/dist/_types/components/AddressDisplay/index.d.ts +43 -0
- package/dist/_types/components/AddressDisplay/index.d.ts.map +1 -0
- package/dist/_types/components/ErrorRetryCard/index.d.ts +21 -0
- package/dist/_types/components/ErrorRetryCard/index.d.ts.map +1 -0
- package/dist/_types/components/FeeBreakdown/feeInfo.d.ts +24 -0
- package/dist/_types/components/FeeBreakdown/feeInfo.d.ts.map +1 -0
- package/dist/_types/components/FeeBreakdown/index.d.ts +28 -0
- package/dist/_types/components/FeeBreakdown/index.d.ts.map +1 -0
- package/dist/_types/components/LoadingCard/index.d.ts +6 -0
- package/dist/_types/components/LoadingCard/index.d.ts.map +1 -0
- package/dist/_types/components/PendingDeposits/index.d.ts +14 -0
- package/dist/_types/components/PendingDeposits/index.d.ts.map +1 -0
- package/dist/_types/components/QrSheet/QrCode.d.ts +28 -0
- package/dist/_types/components/QrSheet/QrCode.d.ts.map +1 -0
- package/dist/_types/components/QrSheet/index.d.ts +16 -0
- package/dist/_types/components/QrSheet/index.d.ts.map +1 -0
- package/dist/_types/components/TxnItem/ExplorerLink.d.ts +16 -0
- package/dist/_types/components/TxnItem/ExplorerLink.d.ts.map +1 -0
- package/dist/_types/components/TxnItem/index.d.ts +19 -0
- package/dist/_types/components/TxnItem/index.d.ts.map +1 -0
- package/dist/_types/constants.d.ts +20 -0
- package/dist/_types/constants.d.ts.map +1 -0
- package/dist/_types/context/SmartRoutingAddressContext.d.ts +32 -0
- package/dist/_types/context/SmartRoutingAddressContext.d.ts.map +1 -0
- package/dist/_types/context/SmartRoutingAddressProvider.d.ts +13 -0
- package/dist/_types/context/SmartRoutingAddressProvider.d.ts.map +1 -0
- package/dist/_types/hooks/useCreateSmartRoutingAddress.d.ts +23 -0
- package/dist/_types/hooks/useCreateSmartRoutingAddress.d.ts.map +1 -0
- package/dist/_types/hooks/useDepositStatus.d.ts +25 -0
- package/dist/_types/hooks/useDepositStatus.d.ts.map +1 -0
- package/dist/_types/hooks/useNewDeposits.d.ts +8 -0
- package/dist/_types/hooks/useNewDeposits.d.ts.map +1 -0
- package/dist/_types/hooks/useProviderFees.d.ts +22 -0
- package/dist/_types/hooks/useProviderFees.d.ts.map +1 -0
- package/dist/_types/hooks/useSmartRoutingAddress.d.ts +20 -0
- package/dist/_types/hooks/useSmartRoutingAddress.d.ts.map +1 -0
- package/dist/_types/iconAssets.d.ts +7 -0
- package/dist/_types/iconAssets.d.ts.map +1 -0
- package/dist/_types/index.d.ts +14 -0
- package/dist/_types/index.d.ts.map +1 -0
- package/dist/_types/pages/Deposit.d.ts +12 -0
- package/dist/_types/pages/Deposit.d.ts.map +1 -0
- package/dist/_types/pages/PastDeposits.d.ts +14 -0
- package/dist/_types/pages/PastDeposits.d.ts.map +1 -0
- package/dist/_types/pages/TransactionDetails.d.ts +11 -0
- package/dist/_types/pages/TransactionDetails.d.ts.map +1 -0
- package/dist/_types/pages/index.d.ts +25 -0
- package/dist/_types/pages/index.d.ts.map +1 -0
- package/dist/_types/types.d.ts +75 -0
- package/dist/_types/types.d.ts.map +1 -0
- package/dist/_types/utils/chains.d.ts +8 -0
- package/dist/_types/utils/chains.d.ts.map +1 -0
- package/dist/_types/utils/config.d.ts +35 -0
- package/dist/_types/utils/config.d.ts.map +1 -0
- package/dist/_types/utils/deposits.d.ts +29 -0
- package/dist/_types/utils/deposits.d.ts.map +1 -0
- package/dist/_types/utils/fees.d.ts +14 -0
- package/dist/_types/utils/fees.d.ts.map +1 -0
- package/dist/_types/utils/format.d.ts +27 -0
- package/dist/_types/utils/format.d.ts.map +1 -0
- package/dist/_types/utils/providerFees.d.ts +103 -0
- package/dist/_types/utils/providerFees.d.ts.map +1 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.mjs +2180 -0
- package/dist/index.mjs.map +1 -0
- package/dist/styles.css +2 -0
- package/package.json +91 -0
- package/src/components/AddressDisplay/index.tsx +108 -0
- package/src/components/ErrorRetryCard/index.tsx +68 -0
- package/src/components/FeeBreakdown/feeInfo.ts +25 -0
- package/src/components/FeeBreakdown/index.tsx +218 -0
- package/src/components/LoadingCard/index.tsx +27 -0
- package/src/components/PendingDeposits/index.tsx +152 -0
- package/src/components/QrSheet/QrCode.tsx +163 -0
- package/src/components/QrSheet/index.tsx +80 -0
- package/src/components/TxnItem/ExplorerLink.tsx +37 -0
- package/src/components/TxnItem/index.tsx +178 -0
- package/src/constants.ts +102 -0
- package/src/context/SmartRoutingAddressContext.ts +48 -0
- package/src/context/SmartRoutingAddressProvider.tsx +145 -0
- package/src/hooks/useCreateSmartRoutingAddress.ts +28 -0
- package/src/hooks/useDepositStatus.ts +110 -0
- package/src/hooks/useNewDeposits.ts +25 -0
- package/src/hooks/useProviderFees.ts +235 -0
- package/src/hooks/useSmartRoutingAddress.ts +25 -0
- package/src/iconAssets.ts +130 -0
- package/src/index.ts +27 -0
- package/src/pages/Deposit.tsx +605 -0
- package/src/pages/PastDeposits.tsx +232 -0
- package/src/pages/TransactionDetails.tsx +466 -0
- package/src/pages/index.tsx +165 -0
- package/src/types.ts +84 -0
- package/src/utils/chains.ts +26 -0
- package/src/utils/config.ts +142 -0
- package/src/utils/deposits.ts +106 -0
- package/src/utils/fees.ts +51 -0
- package/src/utils/format.ts +108 -0
- 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
|
+
}
|