Notifications Refresh (#135)
* Remove X button from tablet/mobile Notification menu * Toast styling * missed in merge * .env.example * Update abacus@1.0.9 * Add notif strings, and abaucs notif state * Update Notification hooks, add Notification Preference controls * Consolidate Notification into single component * withLabel -> withTrigger prop * nits * OrderStatusChanged -> AbacusGenerated * Notification OnClick * Move Notification -> Components * withAnimation -> $withAnimation * Notification styling and click handle * load state, onClick, localStorage * add error count * Rez localStorage Notifications * Update TransferStatus UI * Use Notification component for other abacusNotifs * fix uselocalnotif query * Default to Notification from NotificationMenu --------- Co-authored-by: Bill He <bill@dydx.exchange>
This commit is contained in:
+1
-1
@@ -111,7 +111,7 @@
|
||||
"styled-components": "^5.3.11",
|
||||
"use-latest": "^1.2.1",
|
||||
"viem": "^1.12.2",
|
||||
"wagmi": "^1.3.7"
|
||||
"wagmi": "^1.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.22.5",
|
||||
|
||||
Generated
+68
-69
@@ -239,8 +239,8 @@ dependencies:
|
||||
specifier: ^1.12.2
|
||||
version: 1.12.2(typescript@5.1.3)
|
||||
wagmi:
|
||||
specifier: ^1.3.7
|
||||
version: 1.3.7(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2)
|
||||
specifier: ^1.4.5
|
||||
version: 1.4.5(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2)
|
||||
|
||||
devDependencies:
|
||||
'@babel/core':
|
||||
@@ -5975,26 +5975,12 @@ packages:
|
||||
pretty-format: 27.5.1
|
||||
dev: true
|
||||
|
||||
/@wagmi/chains@1.4.0(typescript@5.1.3):
|
||||
resolution: {integrity: sha512-9HwJrhcZ1TxyrCbE10y7s1eSiSiyfGam7AHIOLYExaOX+vpOZ+MNTt4orFEDbEpz1fxwJDPPI38lanAUix1OSA==}
|
||||
/@wagmi/connectors@3.1.3(@types/react@18.2.14)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2):
|
||||
resolution: {integrity: sha512-UgwsQKQDFObJVJMf9pDfFoXTv710o4zrTHyhIWKBTMMkLpCMsMxN5+ZaDhBYt/BgoRinfRYQo8uwuwLhxE6Log==}
|
||||
peerDependencies:
|
||||
typescript: '>=5.0.4'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
dependencies:
|
||||
typescript: 5.1.3
|
||||
dev: false
|
||||
|
||||
/@wagmi/connectors@2.6.5(@wagmi/chains@1.4.0)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2):
|
||||
resolution: {integrity: sha512-klF31togMDd0qQqEcLl5cCGxjMbL0RRXQ8I1vxmEa3KdGzw6Z3ICVzX7/bDfnNEZcOW7BKyAnZDq7rCt5jTOiw==}
|
||||
peerDependencies:
|
||||
'@wagmi/chains': '>=1.3.0'
|
||||
typescript: '>=5.0.4'
|
||||
viem: '>=0.3.35'
|
||||
peerDependenciesMeta:
|
||||
'@wagmi/chains':
|
||||
optional: true
|
||||
typescript:
|
||||
optional: true
|
||||
dependencies:
|
||||
@@ -6002,17 +5988,17 @@ packages:
|
||||
'@ledgerhq/connect-kit-loader': 1.1.2
|
||||
'@safe-global/safe-apps-provider': 0.17.1(typescript@5.1.3)
|
||||
'@safe-global/safe-apps-sdk': 8.1.0(typescript@5.1.3)
|
||||
'@wagmi/chains': 1.4.0(typescript@5.1.3)
|
||||
'@walletconnect/ethereum-provider': 2.8.6(@walletconnect/modal@2.5.9)
|
||||
'@walletconnect/ethereum-provider': 2.10.2(@walletconnect/modal@2.6.2)
|
||||
'@walletconnect/legacy-provider': 2.0.0
|
||||
'@walletconnect/modal': 2.5.9(react@18.2.0)
|
||||
'@walletconnect/utils': 2.8.6
|
||||
'@walletconnect/modal': 2.6.2(@types/react@18.2.14)(react@18.2.0)
|
||||
'@walletconnect/utils': 2.10.2
|
||||
abitype: 0.8.7(typescript@5.1.3)
|
||||
eventemitter3: 4.0.7
|
||||
typescript: 5.1.3
|
||||
viem: 1.12.2(typescript@5.1.3)
|
||||
transitivePeerDependencies:
|
||||
- '@react-native-async-storage/async-storage'
|
||||
- '@types/react'
|
||||
- bufferutil
|
||||
- encoding
|
||||
- lokijs
|
||||
@@ -6022,8 +6008,8 @@ packages:
|
||||
- zod
|
||||
dev: false
|
||||
|
||||
/@wagmi/core@1.3.6(@types/react@18.2.14)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2):
|
||||
resolution: {integrity: sha512-TXv9ZlRR5aySfERFuWMuo+lKXC/CoqtxVJZVHPqhK1jY+nldMx3AvrWzzF4CccRaMYcVdvPFepvmxzq2A2VvWg==}
|
||||
/@wagmi/core@1.4.5(@types/react@18.2.14)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2):
|
||||
resolution: {integrity: sha512-N9luRb1Uk4tBN9kaYcQSWKE9AsRt/rvZaFt5IZech4JPzNN2sQlfhKd9GEjOXYRDqEPHdDvos7qyBKiDNTz4GA==}
|
||||
peerDependencies:
|
||||
typescript: '>=5.0.4'
|
||||
viem: '>=0.3.35'
|
||||
@@ -6031,8 +6017,7 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@wagmi/chains': 1.4.0(typescript@5.1.3)
|
||||
'@wagmi/connectors': 2.6.5(@wagmi/chains@1.4.0)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2)
|
||||
'@wagmi/connectors': 3.1.3(@types/react@18.2.14)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2)
|
||||
abitype: 0.8.7(typescript@5.1.3)
|
||||
eventemitter3: 4.0.7
|
||||
typescript: 5.1.3
|
||||
@@ -6077,8 +6062,8 @@ packages:
|
||||
- utf-8-validate
|
||||
dev: false
|
||||
|
||||
/@walletconnect/core@2.8.6:
|
||||
resolution: {integrity: sha512-rnSqm1KJLcww/v6+UH8JeibQkJ3EKgyUDPfEK0stSEkrIUIcXaFlq3Et8S+vgV8bPhI0MVUhAhFL5OJZ3t2ryg==}
|
||||
/@walletconnect/core@2.10.2:
|
||||
resolution: {integrity: sha512-JQz/xp3SLEpTeRQctdck2ugSBVEpMxoSE+lFi2voJkZop1hv6P+uqr6E4PzjFluAjeAnKlT1xvra0aFWjPWVcw==}
|
||||
dependencies:
|
||||
'@walletconnect/heartbeat': 1.2.1
|
||||
'@walletconnect/jsonrpc-provider': 1.0.13
|
||||
@@ -6091,8 +6076,8 @@ packages:
|
||||
'@walletconnect/relay-auth': 1.0.4
|
||||
'@walletconnect/safe-json': 1.0.2
|
||||
'@walletconnect/time': 1.0.2
|
||||
'@walletconnect/types': 2.8.6
|
||||
'@walletconnect/utils': 2.8.6
|
||||
'@walletconnect/types': 2.10.2
|
||||
'@walletconnect/utils': 2.10.2
|
||||
events: 3.3.0
|
||||
lodash.isequal: 4.5.0
|
||||
uint8arrays: 3.1.1
|
||||
@@ -6128,8 +6113,8 @@ packages:
|
||||
tslib: 1.14.1
|
||||
dev: false
|
||||
|
||||
/@walletconnect/ethereum-provider@2.8.6(@walletconnect/modal@2.5.9):
|
||||
resolution: {integrity: sha512-wUvJEsXTLmMihrOhQxAs1k9hrWEOT03QBn54P9r9GpJbJ1zEfIjQaXFfi8uup6gldhH+vN38PsbOiLyv/6d3qQ==}
|
||||
/@walletconnect/ethereum-provider@2.10.2(@walletconnect/modal@2.6.2):
|
||||
resolution: {integrity: sha512-QMYFZ6+rVq2CJLdIPdKK0j1Qm66UA27oQU5V2SrL8EVwl7wFfm0Bq7fnL+qAWeDpn612dNeNErpk/ROa1zWlWg==}
|
||||
peerDependencies:
|
||||
'@walletconnect/modal': '>=2'
|
||||
peerDependenciesMeta:
|
||||
@@ -6140,11 +6125,11 @@ packages:
|
||||
'@walletconnect/jsonrpc-provider': 1.0.13
|
||||
'@walletconnect/jsonrpc-types': 1.0.3
|
||||
'@walletconnect/jsonrpc-utils': 1.0.8
|
||||
'@walletconnect/modal': 2.5.9(react@18.2.0)
|
||||
'@walletconnect/sign-client': 2.8.6
|
||||
'@walletconnect/types': 2.8.6
|
||||
'@walletconnect/universal-provider': 2.8.6
|
||||
'@walletconnect/utils': 2.8.6
|
||||
'@walletconnect/modal': 2.6.2(@types/react@18.2.14)(react@18.2.0)
|
||||
'@walletconnect/sign-client': 2.10.2
|
||||
'@walletconnect/types': 2.10.2
|
||||
'@walletconnect/universal-provider': 2.10.2
|
||||
'@walletconnect/utils': 2.10.2
|
||||
events: 3.3.0
|
||||
transitivePeerDependencies:
|
||||
- '@react-native-async-storage/async-storage'
|
||||
@@ -6295,32 +6280,34 @@ packages:
|
||||
tslib: 1.14.1
|
||||
dev: false
|
||||
|
||||
/@walletconnect/modal-core@2.5.9(react@18.2.0):
|
||||
resolution: {integrity: sha512-isIebwF9hOknGouhS/Ob4YJ9Sa/tqNYG2v6Ua9EkCqIoLimepkG5eC53tslUWW29SLSfQ9qqBNG2+iE7yQXqgw==}
|
||||
/@walletconnect/modal-core@2.6.2(@types/react@18.2.14)(react@18.2.0):
|
||||
resolution: {integrity: sha512-cv8ibvdOJQv2B+nyxP9IIFdxvQznMz8OOr/oR/AaUZym4hjXNL/l1a2UlSQBXrVjo3xxbouMxLb3kBsHoYP2CA==}
|
||||
dependencies:
|
||||
buffer: 6.0.3
|
||||
valtio: 1.10.6(react@18.2.0)
|
||||
valtio: 1.11.2(@types/react@18.2.14)(react@18.2.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- react
|
||||
dev: false
|
||||
|
||||
/@walletconnect/modal-ui@2.5.9(react@18.2.0):
|
||||
resolution: {integrity: sha512-nfBaAT9Ls7RZTBBgAq+Nt/3AoUcinIJ9bcq5UHXTV3lOPu/qCKmUC/0HY3GvUK8ykabUAsjr0OAGmcqkB91qug==}
|
||||
/@walletconnect/modal-ui@2.6.2(@types/react@18.2.14)(react@18.2.0):
|
||||
resolution: {integrity: sha512-rbdstM1HPGvr7jprQkyPggX7rP4XiCG85ZA+zWBEX0dVQg8PpAgRUqpeub4xQKDgY7pY/xLRXSiCVdWGqvG2HA==}
|
||||
dependencies:
|
||||
'@walletconnect/modal-core': 2.5.9(react@18.2.0)
|
||||
lit: 2.7.5
|
||||
'@walletconnect/modal-core': 2.6.2(@types/react@18.2.14)(react@18.2.0)
|
||||
lit: 2.8.0
|
||||
motion: 10.16.2
|
||||
qrcode: 1.5.3
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- react
|
||||
dev: false
|
||||
|
||||
/@walletconnect/modal@2.5.9(react@18.2.0):
|
||||
resolution: {integrity: sha512-Zs2RvPwbBNRdBhb50FuJCxi3FJltt1KSpI7odjU/x9GTpTOcSOkmR66PBCy2JvNA0+ztnS1Xs0LVEr3lu7/Jzw==}
|
||||
/@walletconnect/modal@2.6.2(@types/react@18.2.14)(react@18.2.0):
|
||||
resolution: {integrity: sha512-eFopgKi8AjKf/0U4SemvcYw9zlLpx9njVN8sf6DAkowC2Md0gPU/UNEbH1Wwj407pEKnEds98pKWib1NN1ACoA==}
|
||||
dependencies:
|
||||
'@walletconnect/modal-core': 2.5.9(react@18.2.0)
|
||||
'@walletconnect/modal-ui': 2.5.9(react@18.2.0)
|
||||
'@walletconnect/modal-core': 2.6.2(@types/react@18.2.14)(react@18.2.0)
|
||||
'@walletconnect/modal-ui': 2.6.2(@types/react@18.2.14)(react@18.2.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- react
|
||||
dev: false
|
||||
|
||||
@@ -6376,17 +6363,17 @@ packages:
|
||||
- utf-8-validate
|
||||
dev: false
|
||||
|
||||
/@walletconnect/sign-client@2.8.6:
|
||||
resolution: {integrity: sha512-rOFTKTHP7oJfXgYHX7+SdB8VbcsEE3ZFG/bMdmZboWaBim1mrY3vUyDdKrNr0VgI3AwBiEQezQDfKxBX0pMSQQ==}
|
||||
/@walletconnect/sign-client@2.10.2:
|
||||
resolution: {integrity: sha512-vviSLV3f92I0bReX+OLr1HmbH0uIzYEQQFd1MzIfDk9PkfFT/LLAHhUnDaIAMkIdippqDcJia+5QEtT4JihL3Q==}
|
||||
dependencies:
|
||||
'@walletconnect/core': 2.8.6
|
||||
'@walletconnect/core': 2.10.2
|
||||
'@walletconnect/events': 1.0.1
|
||||
'@walletconnect/heartbeat': 1.2.1
|
||||
'@walletconnect/jsonrpc-utils': 1.0.8
|
||||
'@walletconnect/logger': 2.0.1
|
||||
'@walletconnect/time': 1.0.2
|
||||
'@walletconnect/types': 2.8.6
|
||||
'@walletconnect/utils': 2.8.6
|
||||
'@walletconnect/types': 2.10.2
|
||||
'@walletconnect/utils': 2.10.2
|
||||
events: 3.3.0
|
||||
transitivePeerDependencies:
|
||||
- '@react-native-async-storage/async-storage'
|
||||
@@ -6415,8 +6402,8 @@ packages:
|
||||
- lokijs
|
||||
dev: false
|
||||
|
||||
/@walletconnect/types@2.8.6:
|
||||
resolution: {integrity: sha512-Z/PFa3W1XdxeTcCtdR6lUsFgZfU/69wWJBPyclPwn7cu1+eriuCr6XZXQpJjib3flU+HnwHiXeUuqZaheehPxw==}
|
||||
/@walletconnect/types@2.10.2:
|
||||
resolution: {integrity: sha512-luNV+07Wdla4STi9AejseCQY31tzWKQ5a7C3zZZaRK/di+rFaAAb7YW04OP4klE7tw/mJRGPTlekZElmHxO8kQ==}
|
||||
dependencies:
|
||||
'@walletconnect/events': 1.0.1
|
||||
'@walletconnect/heartbeat': 1.2.1
|
||||
@@ -6429,17 +6416,17 @@ packages:
|
||||
- lokijs
|
||||
dev: false
|
||||
|
||||
/@walletconnect/universal-provider@2.8.6:
|
||||
resolution: {integrity: sha512-ln1RVv8+oHu9enOJ/oVkjiarneB+4vJCk16znOklIN2JtDHwB8iObDHlQH3UE6ynNTw1iRvaGuPR4g+YdIfB6w==}
|
||||
/@walletconnect/universal-provider@2.10.2:
|
||||
resolution: {integrity: sha512-wFgI0LbQ3D56sgaUMsgOHCM5m8WLxiC71BGuCKQfApgsbNMVKugYVy2zWHyUyi8sqTQHI+uSaVpDev4UHq9LEw==}
|
||||
dependencies:
|
||||
'@walletconnect/jsonrpc-http-connection': 1.0.7
|
||||
'@walletconnect/jsonrpc-provider': 1.0.13
|
||||
'@walletconnect/jsonrpc-types': 1.0.3
|
||||
'@walletconnect/jsonrpc-utils': 1.0.8
|
||||
'@walletconnect/logger': 2.0.1
|
||||
'@walletconnect/sign-client': 2.8.6
|
||||
'@walletconnect/types': 2.8.6
|
||||
'@walletconnect/utils': 2.8.6
|
||||
'@walletconnect/sign-client': 2.10.2
|
||||
'@walletconnect/types': 2.10.2
|
||||
'@walletconnect/utils': 2.10.2
|
||||
events: 3.3.0
|
||||
transitivePeerDependencies:
|
||||
- '@react-native-async-storage/async-storage'
|
||||
@@ -6471,8 +6458,8 @@ packages:
|
||||
- lokijs
|
||||
dev: false
|
||||
|
||||
/@walletconnect/utils@2.8.6:
|
||||
resolution: {integrity: sha512-wcy6e5+COYo7tfNnW8YqidnATdJDIW6vDiWWE7A1F78Sl/VflkaevB9cIgyn8eLdxC1SxXgGoeC2oLP90nnHJg==}
|
||||
/@walletconnect/utils@2.10.2:
|
||||
resolution: {integrity: sha512-syxXRpc2yhSknMu3IfiBGobxOY7fLfLTJuw+ppKaeO6WUdZpIit3wfuGOcc0Ms3ZPFCrGfyGOoZsCvgdXtptRg==}
|
||||
dependencies:
|
||||
'@stablelib/chacha20poly1305': 1.0.1
|
||||
'@stablelib/hkdf': 1.0.1
|
||||
@@ -6482,7 +6469,7 @@ packages:
|
||||
'@walletconnect/relay-api': 1.0.9
|
||||
'@walletconnect/safe-json': 1.0.2
|
||||
'@walletconnect/time': 1.0.2
|
||||
'@walletconnect/types': 2.8.6
|
||||
'@walletconnect/types': 2.10.2
|
||||
'@walletconnect/window-getters': 1.0.1
|
||||
'@walletconnect/window-metadata': 1.0.1
|
||||
detect-browser: 5.3.0
|
||||
@@ -10411,6 +10398,14 @@ packages:
|
||||
lit-html: 2.8.0
|
||||
dev: false
|
||||
|
||||
/lit@2.8.0:
|
||||
resolution: {integrity: sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==}
|
||||
dependencies:
|
||||
'@lit/reactive-element': 1.6.3
|
||||
lit-element: 3.3.3
|
||||
lit-html: 2.8.0
|
||||
dev: false
|
||||
|
||||
/loader-runner@4.3.0:
|
||||
resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==}
|
||||
engines: {node: '>=6.11.5'}
|
||||
@@ -13544,15 +13539,19 @@ packages:
|
||||
use-sync-external-store: 1.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/valtio@1.10.6(react@18.2.0):
|
||||
resolution: {integrity: sha512-SxN1bHUmdhW6V8qsQTpCgJEwp7uHbntuH0S9cdLQtiohuevwBksbpXjwj5uDMA7bLwg1WKyq9sEpZrx3TIMrkA==}
|
||||
/valtio@1.11.2(@types/react@18.2.14)(react@18.2.0):
|
||||
resolution: {integrity: sha512-1XfIxnUXzyswPAPXo1P3Pdx2mq/pIqZICkWN60Hby0d9Iqb+MEIpqgYVlbflvHdrp2YR/q3jyKWRPJJ100yxaw==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=16.8'
|
||||
react: '>=16.8'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
dependencies:
|
||||
'@types/react': 18.2.14
|
||||
proxy-compare: 2.5.1
|
||||
react: 18.2.0
|
||||
use-sync-external-store: 1.2.0(react@18.2.0)
|
||||
@@ -13775,8 +13774,8 @@ packages:
|
||||
- supports-color
|
||||
dev: true
|
||||
|
||||
/wagmi@1.3.7(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2):
|
||||
resolution: {integrity: sha512-n4Qwby0QB8/JSGjc0gGn4MWZ0BT229agEg6saPGS6VswmPjJLdkLq9qxKn1JptXxPREmb/ZvwHxU++3Z+gQJVA==}
|
||||
/wagmi@1.4.5(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2):
|
||||
resolution: {integrity: sha512-Ph62E6cO5n2Z8Z5LTyZrkaNprxTsbC4w0qZJT4OJdXrEELziI8z/b4FO6amVFXdu2rDp/wpvF56e4mhKC8/Kdw==}
|
||||
peerDependencies:
|
||||
react: '>=17.0.0'
|
||||
typescript: '>=5.0.4'
|
||||
@@ -13788,7 +13787,7 @@ packages:
|
||||
'@tanstack/query-sync-storage-persister': 4.32.6
|
||||
'@tanstack/react-query': 4.32.6(react-dom@18.2.0)(react@18.2.0)
|
||||
'@tanstack/react-query-persist-client': 4.32.6(@tanstack/react-query@4.32.6)
|
||||
'@wagmi/core': 1.3.6(@types/react@18.2.14)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2)
|
||||
'@wagmi/core': 1.4.5(@types/react@18.2.14)(react@18.2.0)(typescript@5.1.3)(viem@1.12.2)
|
||||
abitype: 0.8.7(typescript@5.1.3)
|
||||
react: 18.2.0
|
||||
typescript: 5.1.3
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import type { Story } from '@ladle/react';
|
||||
|
||||
import { Collapsible } from '@/components/Collapsible';
|
||||
import { Collapsible, type CollapsibleProps } from '@/components/Collapsible';
|
||||
|
||||
import { StoryWrapper } from '.ladle/components';
|
||||
import { IconName } from './Icon';
|
||||
|
||||
export const CollapsibleStory: Story<Parameters<typeof Collapsible>> = (args) => (
|
||||
export const CollapsibleStory: Story<CollapsibleProps> = (args) => (
|
||||
<StoryWrapper>
|
||||
<Collapsible
|
||||
label="Collapsible List of Items"
|
||||
{...args}
|
||||
>
|
||||
<Collapsible {...args}>
|
||||
<ul>
|
||||
<li>Collapsible Item 1</li>
|
||||
<li>Collapsible Item 2</li>
|
||||
@@ -23,6 +20,8 @@ export const CollapsibleStory: Story<Parameters<typeof Collapsible>> = (args) =>
|
||||
|
||||
CollapsibleStory.args = {
|
||||
disabled: false,
|
||||
withTrigger: true,
|
||||
label: 'Collapsible List of Items',
|
||||
};
|
||||
|
||||
CollapsibleStory.argTypes = {
|
||||
|
||||
@@ -14,6 +14,7 @@ type ElementProps = {
|
||||
label: React.ReactNode;
|
||||
triggerIcon?: IconName;
|
||||
children: React.ReactNode;
|
||||
withTrigger?: boolean;
|
||||
};
|
||||
|
||||
type StyleProps = {
|
||||
@@ -23,6 +24,8 @@ type StyleProps = {
|
||||
fullWidth?: boolean;
|
||||
};
|
||||
|
||||
export type CollapsibleProps = ElementProps & StyleProps;
|
||||
|
||||
export const Collapsible = ({
|
||||
defaultOpen = false,
|
||||
disabled,
|
||||
@@ -35,26 +38,29 @@ export const Collapsible = ({
|
||||
triggerIconSide = 'left',
|
||||
fullWidth,
|
||||
className,
|
||||
}: ElementProps & StyleProps) => (
|
||||
withTrigger = true,
|
||||
}: CollapsibleProps) => (
|
||||
<Styled.Root defaultOpen={defaultOpen} open={open} onOpenChange={onOpenChange}>
|
||||
<Styled.Trigger className={className} disabled={disabled}>
|
||||
{triggerIconSide === 'right' && (
|
||||
<>
|
||||
{label}
|
||||
{fullWidth && <HorizontalSeparatorFiller />}
|
||||
</>
|
||||
)}
|
||||
{withTrigger && (
|
||||
<Styled.Trigger className={className} disabled={disabled}>
|
||||
{triggerIconSide === 'right' && (
|
||||
<>
|
||||
{label}
|
||||
{fullWidth && <HorizontalSeparatorFiller />}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Styled.TriggerIcon>
|
||||
<Icon iconName={triggerIcon} />
|
||||
</Styled.TriggerIcon>
|
||||
{triggerIconSide === 'left' && (
|
||||
<>
|
||||
{fullWidth && <HorizontalSeparatorFiller />}
|
||||
{label}
|
||||
</>
|
||||
)}
|
||||
</Styled.Trigger>
|
||||
<Styled.TriggerIcon>
|
||||
<Icon iconName={triggerIcon} />
|
||||
</Styled.TriggerIcon>
|
||||
{triggerIconSide === 'left' && (
|
||||
<>
|
||||
{fullWidth && <HorizontalSeparatorFiller />}
|
||||
{label}
|
||||
</>
|
||||
)}
|
||||
</Styled.Trigger>
|
||||
)}
|
||||
<Styled.Content $transitionDuration={transitionDuration}>{children}</Styled.Content>
|
||||
</Styled.Root>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,12 @@ type PickComboxMenuProps<
|
||||
MenuGroupValue extends string | number
|
||||
> = Pick<
|
||||
ComboboxMenuProps<MenuItemValue, MenuGroupValue>,
|
||||
'inputPlaceholder' | 'onItemSelected' | 'slotEmpty' | 'withSearch' | 'withStickyLayout'
|
||||
| 'inputPlaceholder'
|
||||
| 'onItemSelected'
|
||||
| 'slotEmpty'
|
||||
| 'withItemBorders'
|
||||
| 'withSearch'
|
||||
| 'withStickyLayout'
|
||||
>;
|
||||
|
||||
type PickDialogProps = Pick<
|
||||
@@ -53,6 +58,7 @@ export const ComboboxDialogMenu = <
|
||||
onItemSelected,
|
||||
inputPlaceholder,
|
||||
slotEmpty,
|
||||
withItemBorders,
|
||||
withSearch,
|
||||
withStickyLayout = true,
|
||||
children,
|
||||
@@ -83,6 +89,7 @@ export const ComboboxDialogMenu = <
|
||||
title={title}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
slotEmpty={slotEmpty}
|
||||
withItemBorders={withItemBorders}
|
||||
withSearch={withSearch}
|
||||
withStickyLayout={withStickyLayout}
|
||||
/>
|
||||
@@ -95,6 +102,8 @@ const Styled: Record<string, AnyStyledComponent> = {};
|
||||
Styled.Dialog = styled(Dialog)`
|
||||
/* Params */
|
||||
--comboboxDialogMenu-backgroundColor: var(--color-layer-2);
|
||||
--comboboxDialogMenu-item-gap: 0.5rem;
|
||||
--comboboxDialogMenu-item-padding: 0.5rem 1rem;
|
||||
|
||||
/* Overrides */
|
||||
& {
|
||||
@@ -118,5 +127,6 @@ Styled.Dialog = styled(Dialog)`
|
||||
|
||||
Styled.ComboboxMenu = styled(ComboboxMenu)`
|
||||
--comboboxMenu-backgroundColor: var(--comboboxDialogMenu-backgroundColor);
|
||||
--comboboxMenu-item-gap: var(--comboxDialogMenu-item-gap, 0.5rem);
|
||||
--comboboxMenu-item-gap: var(--comboboxDialogMenu-item-gap);
|
||||
--comboboxMenu-item-padding: var(--comboboxDialogMenu-item-padding);
|
||||
`;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Fragment, type ReactNode, useState } from 'react';
|
||||
import styled, { type AnyStyledComponent, css } from 'styled-components';
|
||||
import { Command } from 'cmdk';
|
||||
|
||||
import { type MenuConfig } from '@/constants/menus';
|
||||
import { MenuItem, type MenuConfig } from '@/constants/menus';
|
||||
import { popoverMixins } from '@/styles/popoverMixins';
|
||||
import { layoutMixins } from '@/styles/layoutMixins';
|
||||
|
||||
@@ -20,6 +20,7 @@ type ElementProps<MenuItemValue extends string | number, MenuGroupValue extends
|
||||
|
||||
type StyleProps = {
|
||||
className?: string;
|
||||
withItemBorders?: boolean;
|
||||
withStickyLayout?: boolean;
|
||||
};
|
||||
|
||||
@@ -38,6 +39,7 @@ export const ComboboxMenu = <MenuItemValue extends string, MenuGroupValue extend
|
||||
withSearch = true,
|
||||
|
||||
className,
|
||||
withItemBorders,
|
||||
withStickyLayout,
|
||||
}: ComboboxMenuProps<MenuItemValue, MenuGroupValue>) => {
|
||||
const [highlightedCommand, setHighlightedCommand] = useState<MenuItemValue>();
|
||||
@@ -80,6 +82,7 @@ export const ComboboxMenu = <MenuItemValue extends string, MenuGroupValue extend
|
||||
<Styled.Group
|
||||
key={group.group}
|
||||
heading={group.groupLabel}
|
||||
$withItemBorders={withItemBorders}
|
||||
$withStickyLayout={withStickyLayout}
|
||||
>
|
||||
{group.items.map((item) => (
|
||||
@@ -99,22 +102,31 @@ export const ComboboxMenu = <MenuItemValue extends string, MenuGroupValue extend
|
||||
}
|
||||
}}
|
||||
disabled={item.disabled}
|
||||
$withItemBorders={withItemBorders}
|
||||
>
|
||||
{item.slotBefore}
|
||||
<Styled.ItemLabel>
|
||||
<span>
|
||||
{`${item.label}${item.subitems?.length ? '…' : ''}`}
|
||||
{item.tag && (
|
||||
<>
|
||||
{' '}
|
||||
<Tag>{item.tag}</Tag>
|
||||
</>
|
||||
{
|
||||
<>
|
||||
{item.slotBefore}
|
||||
{item.slotCustomContent ?? (
|
||||
<Styled.ItemLabel>
|
||||
<span>
|
||||
{typeof item.label === 'string'
|
||||
? `${item.label}${item.subitems?.length ? '…' : ''}`
|
||||
: item.label}
|
||||
{item.tag && (
|
||||
<>
|
||||
{' '}
|
||||
<Tag>{item.tag}</Tag>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{item.description && <span>{item.description}</span>}
|
||||
</Styled.ItemLabel>
|
||||
)}
|
||||
</span>
|
||||
{item.description && <span>{item.description}</span>}
|
||||
</Styled.ItemLabel>
|
||||
{item.slotAfter}
|
||||
{item.subitems && '→'}
|
||||
{item.slotAfter}
|
||||
{item.subitems && '→'}
|
||||
</>
|
||||
}
|
||||
</Styled.Item>
|
||||
|
||||
{searchValue &&
|
||||
@@ -137,6 +149,7 @@ export const ComboboxMenu = <MenuItemValue extends string, MenuGroupValue extend
|
||||
onItemSelected?.();
|
||||
}}
|
||||
disabled={subitem.disabled}
|
||||
$withItemBorders={withItemBorders}
|
||||
>
|
||||
{subitem.slotBefore}
|
||||
<Styled.ItemLabel>
|
||||
@@ -169,14 +182,17 @@ const Styled: Record<string, AnyStyledComponent> = {};
|
||||
|
||||
Styled.Command = styled(Command)<{ $withStickyLayout?: boolean }>`
|
||||
--comboboxMenu-backgroundColor: var(--color-layer-2);
|
||||
|
||||
--comboboxMenu-input-backgroundColor: var(--color-layer-3);
|
||||
--comboboxMenu-input-height: 2.5rem;
|
||||
|
||||
--comboboxMenu-item-checked-backgroundColor: ;
|
||||
--comboboxMenu-item-checked-textColor: ;
|
||||
--comboboxMenu-item-highlighted-backgroundColor: var(--color-layer-3);
|
||||
--comboboxMenu-item-highlighted-textColor: var(--color-text-1);
|
||||
--comboboxMenu-item-backgroundColor: ;
|
||||
--comboboxMenu-item-gap: 0.5rem;
|
||||
--comboboxMenu-item-padding: 0.5em 1em;
|
||||
|
||||
display: grid;
|
||||
align-content: start;
|
||||
@@ -225,7 +241,7 @@ Styled.Input = styled(Command.Input)`
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
|
||||
Styled.Group = styled(Command.Group)<{ $withStickyLayout?: boolean }>`
|
||||
Styled.Group = styled(Command.Group)<{ $withItemBorders?: boolean; $withStickyLayout?: boolean }>`
|
||||
color: var(--color-text-0);
|
||||
|
||||
> [cmdk-group-heading] {
|
||||
@@ -248,6 +264,14 @@ Styled.Group = styled(Command.Group)<{ $withStickyLayout?: boolean }>`
|
||||
${layoutMixins.stickyArea3}
|
||||
}
|
||||
`}
|
||||
|
||||
${({ $withItemBorders }) =>
|
||||
$withItemBorders &&
|
||||
css`
|
||||
> [cmdk-group-items] {
|
||||
padding: var(--border-width) 0;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
Styled.List = styled(Command.List)<{ $withStickyLayout?: boolean }>`
|
||||
@@ -274,13 +298,14 @@ Styled.List = styled(Command.List)<{ $withStickyLayout?: boolean }>`
|
||||
`}
|
||||
`;
|
||||
|
||||
Styled.Item = styled(Command.Item)`
|
||||
Styled.Item = styled(Command.Item)<{ $withItemBorders?: boolean }>`
|
||||
${layoutMixins.scrollSnapItem}
|
||||
${popoverMixins.item}
|
||||
--item-checked-backgroundColor: var(--comboboxMenu-item-checked-backgroundColor);
|
||||
--item-checked-textColor: var(--comboboxMenu-item-checked-textColor);
|
||||
--item-highlighted-textColor: var(--comboboxMenu-item-highlighted-textColor);
|
||||
--item-gap: var(--comboboxMenu-item-gap);
|
||||
--item-padding: var(--comboboxMenu-item-padding);
|
||||
|
||||
background-color: var(--comboboxMenu-backgroundColor, inherit);
|
||||
|
||||
@@ -291,6 +316,12 @@ Styled.Item = styled(Command.Item)`
|
||||
opacity: 0.75;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
${({ $withItemBorders }) =>
|
||||
$withItemBorders &&
|
||||
css`
|
||||
${layoutMixins.withOuterBorder}
|
||||
`}
|
||||
`;
|
||||
|
||||
Styled.ItemLabel = styled.div`
|
||||
@@ -309,6 +340,8 @@ Styled.ItemLabel = styled.div`
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
min-width: 0;
|
||||
`;
|
||||
|
||||
Styled.Empty = styled(Command.Empty)`
|
||||
|
||||
+38
-28
@@ -38,6 +38,7 @@ type ElementProps = {
|
||||
slotTrigger?: React.ReactNode;
|
||||
slotHeaderInner?: React.ReactNode;
|
||||
slotFooter?: React.ReactNode;
|
||||
withClose?: boolean;
|
||||
};
|
||||
|
||||
type StyleProps = {
|
||||
@@ -46,6 +47,7 @@ type StyleProps = {
|
||||
hasHeaderBorder?: boolean;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
withAnimation?: boolean;
|
||||
};
|
||||
|
||||
export type DialogProps = ElementProps & StyleProps;
|
||||
@@ -79,9 +81,11 @@ export const Dialog = ({
|
||||
slotTrigger,
|
||||
slotHeaderInner,
|
||||
slotFooter,
|
||||
withClose = true,
|
||||
placement = DialogPlacement.Default,
|
||||
portalContainer,
|
||||
hasHeaderBorder = false,
|
||||
withAnimation = false,
|
||||
children,
|
||||
className,
|
||||
}: DialogProps) => {
|
||||
@@ -105,6 +109,7 @@ export const Dialog = ({
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
$withAnimation={withAnimation}
|
||||
>
|
||||
<Styled.Header $withBorder={hasHeaderBorder}>
|
||||
<Styled.HeaderTopRow>
|
||||
@@ -114,7 +119,7 @@ export const Dialog = ({
|
||||
|
||||
{title && <Styled.Title>{title}</Styled.Title>}
|
||||
|
||||
{!preventClose && (
|
||||
{!preventClose && withClose && (
|
||||
<Styled.Close ref={closeButtonRef}>
|
||||
<Icon iconName={IconName.Close} />
|
||||
</Styled.Close>
|
||||
@@ -168,7 +173,7 @@ Styled.Overlay = styled(Overlay)`
|
||||
}
|
||||
`;
|
||||
|
||||
Styled.Container = styled(Content)<{ placement: DialogPlacement }>`
|
||||
Styled.Container = styled(Content)<{ placement: DialogPlacement; $withAnimation?: boolean }>`
|
||||
/* Params */
|
||||
--dialog-inset: 1rem;
|
||||
--dialog-width: 30rem;
|
||||
@@ -224,7 +229,7 @@ Styled.Container = styled(Content)<{ placement: DialogPlacement }>`
|
||||
|
||||
outline: none;
|
||||
|
||||
${({ placement }) =>
|
||||
${({ placement, $withAnimation }) =>
|
||||
({
|
||||
[DialogPlacement.Default]: css`
|
||||
inset: var(--dialog-inset);
|
||||
@@ -260,9 +265,11 @@ Styled.Container = styled(Content)<{ placement: DialogPlacement }>`
|
||||
padding-bottom: var(--dialog-radius); */
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
&[data-state='open'] {
|
||||
animation: ${keyframes`
|
||||
${$withAnimation &&
|
||||
css`
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
&[data-state='open'] {
|
||||
animation: ${keyframes`
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
@@ -270,18 +277,19 @@ Styled.Container = styled(Content)<{ placement: DialogPlacement }>`
|
||||
max-height: 0;
|
||||
}
|
||||
`} 0.15s var(--ease-out-expo);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-state='closed'] {
|
||||
animation: ${keyframes`
|
||||
&[data-state='closed'] {
|
||||
animation: ${keyframes`
|
||||
to {
|
||||
opacity: 0;
|
||||
scale: 0.9;
|
||||
max-height: 0;
|
||||
}
|
||||
`} 0.15s;
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
`,
|
||||
[DialogPlacement.Sidebar]: css`
|
||||
--dialog-width: var(--sidebar-width);
|
||||
@@ -291,50 +299,52 @@ Styled.Container = styled(Content)<{ placement: DialogPlacement }>`
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
&[data-state='open'] {
|
||||
animation: ${keyframes`
|
||||
${$withAnimation &&
|
||||
css`
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
&[data-state='open'] {
|
||||
animation: ${keyframes`
|
||||
from {
|
||||
translate: 100% 0;
|
||||
opacity: 0;
|
||||
}
|
||||
`} 0.15s var(--ease-out-expo);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-state='closed'] {
|
||||
animation: ${keyframes`
|
||||
&[data-state='closed'] {
|
||||
animation: ${keyframes`
|
||||
to {
|
||||
translate: 100% 0;
|
||||
opacity: 0;
|
||||
}
|
||||
`} 0.15s var(--ease-out-expo);
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
`,
|
||||
[DialogPlacement.Inline]: css`
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
&[data-state='open'] {
|
||||
animation: ${keyframes`
|
||||
${$withAnimation &&
|
||||
css`
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
&[data-state='open'] {
|
||||
animation: ${keyframes`
|
||||
from {
|
||||
scale: 0.99;
|
||||
opacity: 0;
|
||||
/* filter: blur(2px); */
|
||||
/* backdrop-filter: none; */
|
||||
}
|
||||
`} 0.15s var(--ease-out-expo);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-state='closed'] {
|
||||
animation: ${keyframes`
|
||||
&[data-state='closed'] {
|
||||
animation: ${keyframes`
|
||||
to {
|
||||
scale: 0.99;
|
||||
opacity: 0;
|
||||
/* filter: blur(2px); */
|
||||
/* backdrop-filter: none; */
|
||||
}
|
||||
`} 0.15s var(--ease-out-expo);
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
`,
|
||||
[DialogPlacement.FullScreen]: css`
|
||||
--dialog-width: 100vw;
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import React, { type MouseEvent } from 'react';
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
import { ButtonShape, ButtonSize } from '@/constants/buttons';
|
||||
|
||||
import {
|
||||
NotificationStatus,
|
||||
type Notification as NotificationDataType,
|
||||
} from '@/constants/notifications';
|
||||
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
|
||||
import { popoverMixins } from '@/styles/popoverMixins';
|
||||
import { layoutMixins } from '@/styles/layoutMixins';
|
||||
|
||||
import { IconButton } from '@/components/IconButton';
|
||||
import { IconName } from '@/components/Icon';
|
||||
import { Output, OutputType } from '@/components/Output';
|
||||
|
||||
type ElementProps = {
|
||||
notification: NotificationDataType;
|
||||
onClick?: () => void;
|
||||
slotIcon?: React.ReactNode;
|
||||
slotTitle?: React.ReactNode;
|
||||
slotTitleLeft?: React.ReactNode;
|
||||
slotTitleRight?: React.ReactNode;
|
||||
slotDescription?: React.ReactNode;
|
||||
slotCustomContent?: React.ReactNode;
|
||||
slotAction?: React.ReactNode;
|
||||
withClose?: boolean;
|
||||
};
|
||||
|
||||
type StyleProps = {
|
||||
className?: string;
|
||||
isToast?: boolean;
|
||||
};
|
||||
|
||||
export type NotificationProps = ElementProps & StyleProps;
|
||||
|
||||
export const Notification = ({
|
||||
className,
|
||||
isToast,
|
||||
notification,
|
||||
onClick,
|
||||
slotIcon,
|
||||
slotTitle,
|
||||
slotTitleLeft,
|
||||
slotTitleRight,
|
||||
slotDescription,
|
||||
slotCustomContent,
|
||||
slotAction,
|
||||
withClose = !isToast,
|
||||
}: NotificationProps) => {
|
||||
const { markCleared, markSeen } = useNotifications();
|
||||
|
||||
return (
|
||||
<$Container className={className} isToast={isToast} onClick={onClick}>
|
||||
<$Header>
|
||||
{slotTitleLeft ?? (slotIcon && <$Icon>{slotIcon}</$Icon>)}
|
||||
<$Title>{slotTitle}</$Title>
|
||||
{slotTitleRight}
|
||||
{withClose && !isToast && (
|
||||
<$ActionItems>
|
||||
<$Output
|
||||
type={OutputType.RelativeTime}
|
||||
value={notification.timestamps[NotificationStatus.Triggered]}
|
||||
/>
|
||||
|
||||
{notification.status < NotificationStatus.Seen ? <$UnreadIndicator /> : null}
|
||||
|
||||
{notification.status < NotificationStatus.Cleared ? (
|
||||
<$IconButton
|
||||
iconName={IconName.Close}
|
||||
shape={ButtonShape.Square}
|
||||
size={ButtonSize.XSmall}
|
||||
onClick={(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (notification.status < NotificationStatus.Seen) {
|
||||
markSeen(notification);
|
||||
} else if (notification.status < NotificationStatus.Cleared) {
|
||||
markCleared(notification);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</$ActionItems>
|
||||
)}
|
||||
</$Header>
|
||||
<$Description>{slotCustomContent ?? slotDescription}</$Description>
|
||||
{slotAction && <$Action>{slotAction}</$Action>}
|
||||
</$Container>
|
||||
);
|
||||
};
|
||||
|
||||
const $Container = styled.div<{ isToast?: boolean }>`
|
||||
// Params
|
||||
--toast-icon-size: 1.75em;
|
||||
|
||||
// Rules
|
||||
${popoverMixins.popover}
|
||||
overflow: visible;
|
||||
padding: 1rem 1.25rem;
|
||||
|
||||
${({ isToast }) =>
|
||||
isToast
|
||||
? css`
|
||||
padding: 1rem;
|
||||
box-shadow: 0 0 0 var(--border-width) var(--color-border),
|
||||
// border
|
||||
0 0 0.5rem 0.1rem var(--color-layer-2); // shadow
|
||||
`
|
||||
: css`
|
||||
flex: 1;
|
||||
background-color: transparent;
|
||||
border-radius: 0;
|
||||
backdrop-filter: none;
|
||||
`}
|
||||
`;
|
||||
|
||||
const $Header = styled.header`
|
||||
${layoutMixins.row}
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const $Icon = styled.div`
|
||||
${layoutMixins.row}
|
||||
float: left;
|
||||
|
||||
margin-right: 0.5rem;
|
||||
|
||||
line-height: 1;
|
||||
|
||||
> svg,
|
||||
img {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
const $Title = styled.div`
|
||||
flex: 1;
|
||||
|
||||
font: var(--font-base-medium);
|
||||
color: var(--color-text-2);
|
||||
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const $Description = styled.div`
|
||||
margin-top: 0.5rem;
|
||||
color: var(--color-text-0);
|
||||
font: var(--font-small-book);
|
||||
`;
|
||||
|
||||
const $Action = styled.div`
|
||||
margin-top: 0.5rem;
|
||||
font: var(--font-small-book);
|
||||
`;
|
||||
|
||||
const $ActionItems = styled.div`
|
||||
min-width: fit-content;
|
||||
padding-left: 0.5rem;
|
||||
height: 100%;
|
||||
${layoutMixins.row};
|
||||
justify-content: end;
|
||||
gap: 0.5rem;
|
||||
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
|
||||
background-color: var(--color-layer-2);
|
||||
font: var(--font-mini-book);
|
||||
|
||||
display: none;
|
||||
z-index: 1;
|
||||
|
||||
${$Container}:hover & {
|
||||
display: flex;
|
||||
}
|
||||
`;
|
||||
|
||||
const $UnreadIndicator = styled.div`
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-accent);
|
||||
border: 1px solid var(--color-layer-2);
|
||||
`;
|
||||
|
||||
const $Output = styled(Output)`
|
||||
color: var(--color-text-0);
|
||||
`;
|
||||
|
||||
const $IconButton = styled(IconButton)`
|
||||
--button-border: none;
|
||||
--button-textColor: var(--color-text-0);
|
||||
--button-hover-textColor: var(--color-text-1);
|
||||
`;
|
||||
+72
-78
@@ -1,24 +1,18 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Root, Title, Description, Action, Close } from '@radix-ui/react-toast';
|
||||
import { type MouseEvent, useEffect, useRef, useState } from 'react';
|
||||
import styled, { keyframes } from 'styled-components';
|
||||
import { Root, Action, Close } from '@radix-ui/react-toast';
|
||||
|
||||
import { ButtonShape, ButtonSize } from '@/constants/buttons';
|
||||
import { popoverMixins } from '@/styles/popoverMixins';
|
||||
|
||||
import { Notification, type NotificationProps } from '@/components/Notification';
|
||||
|
||||
import { IconButton } from './IconButton';
|
||||
import { CloseIcon } from '@/icons';
|
||||
|
||||
import styled, { keyframes } from 'styled-components';
|
||||
import { popoverMixins } from '@/styles/popoverMixins';
|
||||
import { layoutMixins } from '@/styles/layoutMixins';
|
||||
import { IconName } from './Icon';
|
||||
|
||||
type ElementProps = {
|
||||
isOpen?: boolean;
|
||||
setIsOpen?: (isOpen: boolean, isClosedFromTimeout?: boolean) => void;
|
||||
slotIcon?: React.ReactNode;
|
||||
slotTitle?: React.ReactNode;
|
||||
slotDescription?: React.ReactNode;
|
||||
slotCustomContent?: React.ReactNode;
|
||||
slotAction?: React.ReactNode;
|
||||
actionDescription?: string;
|
||||
actionAltText?: string;
|
||||
sensitivity?: 'foreground' | 'background';
|
||||
@@ -26,13 +20,22 @@ type ElementProps = {
|
||||
lastUpdated?: number;
|
||||
};
|
||||
|
||||
type StyleProps = {};
|
||||
type StyleProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type ToastProps = NotificationProps & ElementProps & StyleProps;
|
||||
|
||||
export const Toast = ({
|
||||
className,
|
||||
isOpen = true,
|
||||
notification,
|
||||
onClick,
|
||||
setIsOpen,
|
||||
slotIcon,
|
||||
slotTitle,
|
||||
slotTitleLeft,
|
||||
slotTitleRight,
|
||||
slotDescription,
|
||||
slotCustomContent,
|
||||
slotAction,
|
||||
@@ -41,7 +44,7 @@ export const Toast = ({
|
||||
sensitivity = 'background',
|
||||
duration = Infinity,
|
||||
lastUpdated,
|
||||
}: ElementProps & StyleProps) => {
|
||||
}: ToastProps) => {
|
||||
// Timeout
|
||||
const timeout = useRef<number>();
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
@@ -58,37 +61,45 @@ export const Toast = ({
|
||||
|
||||
return (
|
||||
<$Root
|
||||
className={className}
|
||||
type={sensitivity}
|
||||
duration={Infinity}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
onPause={() => setIsPaused(true)}
|
||||
onResume={() => setIsPaused(false)}
|
||||
onClick={() => {
|
||||
setIsOpen?.(false, false);
|
||||
onClick?.();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<$Container>
|
||||
<$Header>
|
||||
{slotIcon && <$Icon>{slotIcon}</$Icon>}
|
||||
<Close asChild>
|
||||
<$CloseButton
|
||||
iconName={IconName.Close}
|
||||
shape={ButtonShape.Square}
|
||||
size={ButtonSize.XSmall}
|
||||
onClick={(e: MouseEvent) => e.stopPropagation()}
|
||||
/>
|
||||
</Close>
|
||||
|
||||
<Close asChild>
|
||||
<$CloseButton
|
||||
iconComponent={CloseIcon}
|
||||
shape={ButtonShape.Square}
|
||||
size={ButtonSize.XSmall}
|
||||
/>
|
||||
</Close>
|
||||
|
||||
<$Title>{slotTitle}</$Title>
|
||||
</$Header>
|
||||
{!slotCustomContent && <$Description>{slotDescription}</$Description>}
|
||||
{slotCustomContent}
|
||||
{actionDescription && (
|
||||
<$Action asChild altText={actionAltText}>
|
||||
{slotAction}
|
||||
</$Action>
|
||||
)}
|
||||
</$Container>
|
||||
</div>
|
||||
{slotCustomContent ?? (
|
||||
<$Notification
|
||||
isToast
|
||||
notification={notification}
|
||||
slotIcon={slotIcon}
|
||||
slotTitle={slotTitle}
|
||||
slotTitleLeft={slotTitleLeft}
|
||||
slotTitleRight={slotTitleRight}
|
||||
slotDescription={slotDescription}
|
||||
slotAction={
|
||||
actionDescription && (
|
||||
<$Action asChild altText={actionAltText}>
|
||||
{slotAction}
|
||||
</$Action>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</$Root>
|
||||
);
|
||||
};
|
||||
@@ -114,7 +125,7 @@ const $Root = styled(Root)`
|
||||
translate: var(--x) var(--y);
|
||||
will-change: translate, margin-top;
|
||||
|
||||
margin-bottom: var(--toasts-gap);
|
||||
/* margin-bottom: var(--toasts-gap); */
|
||||
|
||||
&[data-swipe-direction='right'] {
|
||||
&[data-state='open'] {
|
||||
@@ -163,6 +174,10 @@ const $Root = styled(Root)`
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
&:focus:not([data-swipe='end']) & {
|
||||
outline: var(--color-accent) 1px solid;
|
||||
}
|
||||
|
||||
&[data-swipe='move'] {
|
||||
transition-property: opacity;
|
||||
opacity: 0.98;
|
||||
@@ -171,6 +186,10 @@ const $Root = styled(Root)`
|
||||
* {
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
& > * {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
&[data-state='closed'],
|
||||
@@ -198,69 +217,44 @@ const $Root = styled(Root)`
|
||||
}
|
||||
`;
|
||||
|
||||
const $Container = styled.div`
|
||||
const $Notification = styled(Notification)`
|
||||
// Params
|
||||
--toast-icon-size: 1.75em;
|
||||
|
||||
// Rules
|
||||
${popoverMixins.popover}
|
||||
overflow: visible;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 0 0 var(--border-width) var(--color-border),
|
||||
// border
|
||||
0 0 0.5rem 0.1rem var(--color-layer-2); // shadow
|
||||
|
||||
${$Root}:focus:not([data-swipe='end']) & {
|
||||
outline: var(--color-accent) 1px solid;
|
||||
}
|
||||
|
||||
> * {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
${$Root}[data-swipe='move'] & > * {
|
||||
opacity: 0.5;
|
||||
}
|
||||
`;
|
||||
|
||||
const $Header = styled.header`
|
||||
display: block;
|
||||
`;
|
||||
|
||||
const $Icon = styled.div`
|
||||
${layoutMixins.row}
|
||||
|
||||
float: left;
|
||||
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
|
||||
margin-right: 0.4em;
|
||||
|
||||
line-height: 1;
|
||||
`;
|
||||
|
||||
const $CloseButton = styled(IconButton)`
|
||||
--button-textColor: var(--color-text-0);
|
||||
--button-border: none;
|
||||
--button-icon-size: 0.85em;
|
||||
display: none;
|
||||
|
||||
float: right;
|
||||
/* float: right; */
|
||||
margin: -0.42rem -0.42rem -0.42rem 0.42rem;
|
||||
`;
|
||||
|
||||
const $Title = styled(Title)`
|
||||
flex: 1;
|
||||
// Absolute
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
font: var(--font-base-medium);
|
||||
color: var(--color-text-2);
|
||||
border: solid var(--border-width) var(--color-border);
|
||||
border-radius: 50%;
|
||||
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const $Description = styled(Description)`
|
||||
margin-top: 0.5rem;
|
||||
color: var(--color-text-0);
|
||||
font: var(--font-small-book);
|
||||
${$Root}:hover & {
|
||||
display: block;
|
||||
z-index: 2;
|
||||
}
|
||||
`;
|
||||
|
||||
const $Action = styled(Action)`
|
||||
|
||||
@@ -34,6 +34,7 @@ const $ToastArea = styled.aside`
|
||||
> * {
|
||||
display: contents;
|
||||
pointer-events: none;
|
||||
position: relative;
|
||||
|
||||
> ol {
|
||||
/* display: block; */
|
||||
@@ -43,6 +44,7 @@ const $ToastArea = styled.aside`
|
||||
// Desired display order: latest to earliest (¯\_(ツ)_/¯)
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
position: relative;
|
||||
|
||||
pointer-events: none;
|
||||
|
||||
|
||||
+34
-1
@@ -124,6 +124,8 @@ export type SubaccountPosition = Abacus.exchange.dydx.abacus.output.SubaccountPo
|
||||
export type SubaccountOrder = Abacus.exchange.dydx.abacus.output.SubaccountOrder;
|
||||
export type OrderStatus = Abacus.exchange.dydx.abacus.output.input.OrderStatus;
|
||||
export const AbacusOrderStatus = Abacus.exchange.dydx.abacus.output.input.OrderStatus;
|
||||
const abacusOrderStatuses = [...AbacusOrderStatus.values()] as const;
|
||||
export type AbacusOrderStatuses = (typeof abacusOrderStatuses)[number];
|
||||
export type SubaccountFills = Abacus.exchange.dydx.abacus.output.SubaccountFill[];
|
||||
export type SubaccountFill = Abacus.exchange.dydx.abacus.output.SubaccountFill;
|
||||
export type SubaccountFundingPayment = Abacus.exchange.dydx.abacus.output.SubaccountFundingPayment;
|
||||
@@ -209,11 +211,13 @@ type ReadonlyKeysOf<T> = {
|
||||
[K in keyof T]: IfEquals<{ [_ in K]: T[K] }, { readonly [_ in K]: T[K] }, K, never>;
|
||||
}[keyof T];
|
||||
|
||||
type KotlinIrEnumValues<T> = Exclude<ReadonlyKeysOf<T>, 'Companion' | '$serializer'>;
|
||||
export type KotlinIrEnumValues<T> = Exclude<ReadonlyKeysOf<T>, 'Companion' | '$serializer'>;
|
||||
|
||||
export const ORDER_SIDES: Record<KotlinIrEnumValues<typeof AbacusOrderSide>, OrderSide> = {
|
||||
[AbacusOrderSide.buy.name]: OrderSide.BUY,
|
||||
[AbacusOrderSide.sell.name]: OrderSide.SELL,
|
||||
[AbacusOrderSide.buy.rawValue]: OrderSide.BUY,
|
||||
[AbacusOrderSide.sell.rawValue]: OrderSide.SELL,
|
||||
};
|
||||
|
||||
export const POSITION_SIDES: Record<KotlinIrEnumValues<typeof AbacusPositionSide>, PositionSide> = {
|
||||
@@ -234,12 +238,25 @@ export const HISTORICAL_PNL_PERIODS: Record<
|
||||
|
||||
export const ORDER_STATUS_STRINGS: Record<KotlinIrEnumValues<typeof AbacusOrderStatus>, string> = {
|
||||
[AbacusOrderStatus.open.name]: STRING_KEYS.OPEN_STATUS,
|
||||
[AbacusOrderStatus.open.rawValue]: STRING_KEYS.OPEN_STATUS,
|
||||
|
||||
[AbacusOrderStatus.partiallyFilled.name]: STRING_KEYS.PARTIALLY_FILLED,
|
||||
[AbacusOrderStatus.partiallyFilled.rawValue]: STRING_KEYS.PARTIALLY_FILLED,
|
||||
|
||||
[AbacusOrderStatus.filled.name]: STRING_KEYS.ORDER_FILLED,
|
||||
[AbacusOrderStatus.filled.rawValue]: STRING_KEYS.ORDER_FILLED,
|
||||
|
||||
[AbacusOrderStatus.cancelled.name]: STRING_KEYS.CANCELED,
|
||||
[AbacusOrderStatus.cancelled.rawValue]: STRING_KEYS.CANCELED,
|
||||
|
||||
[AbacusOrderStatus.canceling.name]: STRING_KEYS.CANCELING,
|
||||
[AbacusOrderStatus.canceling.rawValue]: STRING_KEYS.CANCELING,
|
||||
|
||||
[AbacusOrderStatus.pending.name]: STRING_KEYS.PENDING,
|
||||
[AbacusOrderStatus.pending.rawValue]: STRING_KEYS.PENDING,
|
||||
|
||||
[AbacusOrderStatus.untriggered.name]: STRING_KEYS.UNTRIGGERED,
|
||||
[AbacusOrderStatus.untriggered.rawValue]: STRING_KEYS.UNTRIGGERED,
|
||||
};
|
||||
|
||||
export const TRADE_TYPES: Record<
|
||||
@@ -247,15 +264,31 @@ export const TRADE_TYPES: Record<
|
||||
Nullable<TradeTypes>
|
||||
> = {
|
||||
[AbacusOrderType.limit.name]: TradeTypes.LIMIT,
|
||||
[AbacusOrderType.limit.rawValue]: TradeTypes.LIMIT,
|
||||
|
||||
[AbacusOrderType.market.name]: TradeTypes.MARKET,
|
||||
[AbacusOrderType.market.rawValue]: TradeTypes.MARKET,
|
||||
|
||||
[AbacusOrderType.stopLimit.name]: TradeTypes.STOP_LIMIT,
|
||||
[AbacusOrderType.stopLimit.rawValue]: TradeTypes.STOP_LIMIT,
|
||||
|
||||
[AbacusOrderType.stopMarket.name]: TradeTypes.STOP_MARKET,
|
||||
[AbacusOrderType.stopMarket.rawValue]: TradeTypes.STOP_MARKET,
|
||||
|
||||
[AbacusOrderType.takeProfitLimit.name]: TradeTypes.TAKE_PROFIT,
|
||||
[AbacusOrderType.takeProfitLimit.rawValue]: TradeTypes.TAKE_PROFIT,
|
||||
|
||||
[AbacusOrderType.takeProfitMarket.name]: TradeTypes.TAKE_PROFIT_MARKET,
|
||||
[AbacusOrderType.takeProfitMarket.rawValue]: TradeTypes.TAKE_PROFIT_MARKET,
|
||||
|
||||
[AbacusOrderType.liquidated.name]: null,
|
||||
[AbacusOrderType.liquidation.name]: null,
|
||||
|
||||
[AbacusOrderType.liquidation.name]: null,
|
||||
[AbacusOrderType.liquidation.rawValue]: null,
|
||||
|
||||
[AbacusOrderType.trailingStop.name]: null,
|
||||
[AbacusOrderType.trailingStop.rawValue]: null,
|
||||
};
|
||||
|
||||
// Custom types involving Abacus
|
||||
|
||||
@@ -10,6 +10,7 @@ export enum DialogTypes {
|
||||
MobileSignIn = 'MobileSignIn',
|
||||
Onboarding = 'Onboarding',
|
||||
OrderDetails = 'OrderDetails',
|
||||
Preferences = 'Preferences',
|
||||
RateLimit = 'RateLimit',
|
||||
Receive = 'Receive',
|
||||
RestrictedGeo = 'RestrictedGeo',
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum LocalStorageKey {
|
||||
PushNotificationsEnabled = 'dydx.PushNotificationsEnabled',
|
||||
PushNotificationsLastUpdated = 'dydx.PushNotificationsLastUpdated',
|
||||
TransferNotifications = 'dydx.TransferNotifications',
|
||||
NotificationPreferences = 'dydx.NotificationPreferences',
|
||||
|
||||
// UI State
|
||||
LastViewedMarket = 'dydx.LastViewedMarket',
|
||||
@@ -25,5 +26,8 @@ export enum LocalStorageKey {
|
||||
|
||||
export const LOCAL_STORAGE_VERSIONS = {
|
||||
[LocalStorageKey.EvmDerivedAddresses]: 'v2',
|
||||
[LocalStorageKey.NotificationPreferences]: 'v1',
|
||||
[LocalStorageKey.TransferNotifications]: 'v1',
|
||||
[LocalStorageKey.Notifications]: 'v1',
|
||||
// TODO: version all localStorage keys
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
LOCALE_DATA,
|
||||
TOOLTIPS,
|
||||
WARNINGS_STRING_KEYS,
|
||||
NOTIFICATIONS_STRING_KEYS,
|
||||
NOTIFICATIONS,
|
||||
} from '@dydxprotocol/v4-localization';
|
||||
|
||||
import { type LinksConfigs } from '@/hooks/useURLConfigs';
|
||||
@@ -27,6 +29,7 @@ export enum SupportedLocales {
|
||||
|
||||
export const EN_LOCALE_DATA = {
|
||||
...LOCALE_DATA[SupportedLocales.EN],
|
||||
...NOTIFICATIONS[SupportedLocales.EN],
|
||||
TOOLTIPS: TOOLTIPS[SupportedLocales.EN],
|
||||
};
|
||||
|
||||
@@ -34,8 +37,13 @@ export const STRING_KEYS = {
|
||||
...APP_STRING_KEYS,
|
||||
...ERRORS_STRING_KEYS,
|
||||
...WARNINGS_STRING_KEYS,
|
||||
...NOTIFICATIONS_STRING_KEYS,
|
||||
};
|
||||
|
||||
export const STRING_KEY_VALUES = Object.fromEntries(
|
||||
Object.values(STRING_KEYS).map((key) => [key, key])
|
||||
);
|
||||
|
||||
export type StringKey = keyof typeof STRING_KEYS;
|
||||
|
||||
export type LocaleData = typeof EN_LOCALE_DATA;
|
||||
|
||||
@@ -26,6 +26,7 @@ export type MenuItem<MenuItemValue, MenuItemTypes = string> = {
|
||||
tag?: React.ReactNode;
|
||||
slotAfter?: React.ReactNode;
|
||||
description?: string;
|
||||
slotCustomContent?: React.ReactNode;
|
||||
|
||||
href?: string;
|
||||
onSelect?: (key: MenuItemValue) => void;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { StatusResponse } from '@0xsquid/sdk';
|
||||
|
||||
/** implemented in useNotificationTypes */
|
||||
export enum NotificationType {
|
||||
OrderStatusChanged = 'OrderStatusChanged',
|
||||
AbacusGenerated = 'AbacusGenerated',
|
||||
SquidTransfer = 'SquidTransfer',
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export enum NotificationStatus {
|
||||
/** Toast or NotificationsMenu item interacted with or dismissed. "Seen" in NotificationsMenu. */
|
||||
Seen,
|
||||
|
||||
/** Notification marked for deletion. "Archived" in NotificationsMenu. */
|
||||
/** Notification marked for deletion. Hidden in NotificationsMenu. */
|
||||
Cleared,
|
||||
}
|
||||
|
||||
@@ -80,14 +80,20 @@ export type Notifications = Record<NotificationId, Notification<any>>;
|
||||
/** Notification display data derived from app state at runtime. */
|
||||
export type NotificationDisplayData = {
|
||||
icon?: React.ReactNode;
|
||||
title: string; // Title for Toast, Notification, and Push Notification
|
||||
body?: string; // Description body for Toast, Notification, and Push Notification
|
||||
|
||||
title?: string;
|
||||
slotTitleLeft?: React.ReactNode;
|
||||
slotTitleRight?: React.ReactNode;
|
||||
|
||||
description?: React.ReactNode;
|
||||
|
||||
customContent?: React.ReactNode;
|
||||
|
||||
customMenuContent?: React.ReactNode;
|
||||
// Overrides title/body for Notification in NotificationMenu
|
||||
renderCustomBody?: ({
|
||||
isToast,
|
||||
notification,
|
||||
}: {
|
||||
isToast?: boolean;
|
||||
notification: Notification;
|
||||
}) => React.ReactNode; // Custom Notification
|
||||
|
||||
actionDescription?: string;
|
||||
|
||||
@@ -122,5 +128,15 @@ export type TransferNotifcation = {
|
||||
fromChainId?: string;
|
||||
toAmount?: number;
|
||||
triggeredAt?: number;
|
||||
errorCount?: number;
|
||||
status?: StatusResponse;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description Struct to store whether a NotificationType should be triggered
|
||||
*/
|
||||
export type NotificationPreferences = {
|
||||
[key in NotificationType]: boolean;
|
||||
} & { version: string };
|
||||
|
||||
export const DEFAULT_TOAST_AUTO_CLOSE_MS = 5000;
|
||||
|
||||
@@ -2,11 +2,11 @@ import { createContext, useContext, useCallback, useEffect, useMemo } from 'reac
|
||||
import { useQuery } from 'react-query';
|
||||
import type { StatusResponse } from '@0xsquid/sdk';
|
||||
|
||||
import { LocalStorageKey } from '@/constants/localStorage';
|
||||
import { LOCAL_STORAGE_VERSIONS, LocalStorageKey } from '@/constants/localStorage';
|
||||
import { type TransferNotifcation } from '@/constants/notifications';
|
||||
|
||||
import { useAccounts } from '@/hooks/useAccounts';
|
||||
import { STATUS_ERROR_GRACE_PERIOD, useSquid } from '@/hooks/useSquid';
|
||||
|
||||
import { useLocalStorage } from './useLocalStorage';
|
||||
|
||||
const LocalNotificationsContext = createContext<
|
||||
@@ -22,16 +22,32 @@ export const LocalNotificationsProvider = ({ ...props }) => (
|
||||
export const useLocalNotifications = () => useContext(LocalNotificationsContext)!;
|
||||
|
||||
const TRANSFER_STATUS_FETCH_INTERVAL = 10_000;
|
||||
const ERROR_COUNT_THRESHOLD = 3;
|
||||
|
||||
const useLocalNotificationsContext = () => {
|
||||
// transfer notifications
|
||||
const [allTransferNotifications, setAllTransferNotifications] = useLocalStorage<{
|
||||
[key: `dydx${string}`]: TransferNotifcation[];
|
||||
version: string;
|
||||
}>({
|
||||
key: LocalStorageKey.TransferNotifications,
|
||||
defaultValue: {},
|
||||
defaultValue: {
|
||||
version: LOCAL_STORAGE_VERSIONS[LocalStorageKey.TransferNotifications],
|
||||
},
|
||||
});
|
||||
|
||||
// Ensure version matches, otherwise wipe
|
||||
useEffect(() => {
|
||||
if (
|
||||
allTransferNotifications?.version !==
|
||||
LOCAL_STORAGE_VERSIONS[LocalStorageKey.TransferNotifications]
|
||||
) {
|
||||
setAllTransferNotifications({
|
||||
version: LOCAL_STORAGE_VERSIONS[LocalStorageKey.TransferNotifications],
|
||||
});
|
||||
}
|
||||
}, [allTransferNotifications]);
|
||||
|
||||
const { dydxAddress } = useAccounts();
|
||||
|
||||
const transferNotifications = dydxAddress ? allTransferNotifications[dydxAddress] || [] : [];
|
||||
@@ -54,56 +70,61 @@ const useLocalNotificationsContext = () => {
|
||||
|
||||
const squid = useSquid();
|
||||
|
||||
const { data: transferStatuses } = useQuery({
|
||||
queryKey: ['getTransactionStatus', transferNotifications],
|
||||
useQuery({
|
||||
queryKey: 'getTransactionStatus',
|
||||
queryFn: async () => {
|
||||
const statuses: { [key: string]: StatusResponse } = {};
|
||||
for (const {
|
||||
txHash,
|
||||
toChainId,
|
||||
fromChainId,
|
||||
triggeredAt,
|
||||
status: currentStatus,
|
||||
} of transferNotifications) {
|
||||
try {
|
||||
// skip if error is returned or if the transaction is not ongoing
|
||||
if (
|
||||
// @ts-ignore status.errors is not in the type definition but can be returned
|
||||
currentStatus?.errors ||
|
||||
currentStatus?.error ||
|
||||
(currentStatus?.squidTransactionStatus &&
|
||||
currentStatus?.squidTransactionStatus !== 'ongoing')
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const processTransferNotifications = async (transferNotifications: TransferNotifcation[]) => {
|
||||
const newTransferNotifications = await Promise.all(
|
||||
transferNotifications.map(async (transferNotification) => {
|
||||
const {
|
||||
txHash,
|
||||
toChainId,
|
||||
fromChainId,
|
||||
triggeredAt,
|
||||
errorCount,
|
||||
status: currentStatus,
|
||||
} = transferNotification;
|
||||
|
||||
const status = await squid?.getStatus({ transactionId: txHash, toChainId, fromChainId });
|
||||
if (status) statuses[txHash] = status;
|
||||
} catch (error) {
|
||||
// ignore errors for the first 120s since the status might not be available yet
|
||||
if (!triggeredAt || Date.now() - triggeredAt > STATUS_ERROR_GRACE_PERIOD) {
|
||||
statuses[txHash] = error;
|
||||
}
|
||||
}
|
||||
}
|
||||
return statuses;
|
||||
if (
|
||||
// @ts-ignore status.errors is not in the type definition but can be returned
|
||||
!currentStatus?.errors &&
|
||||
!currentStatus?.error &&
|
||||
(!currentStatus?.squidTransactionStatus ||
|
||||
currentStatus?.squidTransactionStatus === 'ongoing')
|
||||
) {
|
||||
try {
|
||||
const status = await squid?.getStatus({
|
||||
transactionId: txHash,
|
||||
toChainId,
|
||||
fromChainId,
|
||||
});
|
||||
|
||||
if (status) {
|
||||
transferNotification.status = status;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!triggeredAt || Date.now() - triggeredAt > STATUS_ERROR_GRACE_PERIOD) {
|
||||
if (errorCount && errorCount > ERROR_COUNT_THRESHOLD) {
|
||||
transferNotification.status = error;
|
||||
} else {
|
||||
transferNotification.errorCount = errorCount ? errorCount + 1 : 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return transferNotification;
|
||||
})
|
||||
);
|
||||
|
||||
return newTransferNotifications;
|
||||
};
|
||||
const newTransferNotifications = await processTransferNotifications(transferNotifications);
|
||||
setTransferNotifications(newTransferNotifications);
|
||||
},
|
||||
refetchInterval: TRANSFER_STATUS_FETCH_INTERVAL,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!transferStatuses) return;
|
||||
const newTransferNotifications = transferNotifications.map((notification) => {
|
||||
const status = transferStatuses[notification.txHash];
|
||||
if (!status) return notification;
|
||||
return {
|
||||
...notification,
|
||||
status,
|
||||
};
|
||||
});
|
||||
setTransferNotifications(newTransferNotifications);
|
||||
}, [transferStatuses]);
|
||||
|
||||
return {
|
||||
transferNotifications,
|
||||
addTransferNotification,
|
||||
|
||||
+162
-156
@@ -1,125 +1,147 @@
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import styled, { type AnyStyledComponent } from 'styled-components';
|
||||
import { useSelector, shallowEqual, useDispatch } from 'react-redux';
|
||||
import { groupBy } from 'lodash';
|
||||
import { type ReactNode, useEffect } from 'react';
|
||||
import styled from 'styled-components';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
import { isEqual, groupBy } from 'lodash';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { AlertType } from '@/constants/alerts';
|
||||
import { AbacusOrderStatus, ORDER_SIDES } from '@/constants/abacus';
|
||||
import { DialogTypes } from '@/constants/dialogs';
|
||||
import { STRING_KEYS, StringKey } from '@/constants/localization';
|
||||
import { ENVIRONMENT_CONFIG_MAP } from '@/constants/networks';
|
||||
import { type NotificationTypeConfig, NotificationType } from '@/constants/notifications';
|
||||
import { ORDER_SIDE_STRINGS } from '@/constants/trade';
|
||||
import { AppRoute } from '@/constants/routes';
|
||||
import { DydxChainAsset } from '@/constants/wallets';
|
||||
|
||||
import { useSelectedNetwork } from '@/hooks';
|
||||
import {
|
||||
STRING_KEYS,
|
||||
STRING_KEY_VALUES,
|
||||
type StringGetterFunction,
|
||||
type StringKey,
|
||||
} from '@/constants/localization';
|
||||
|
||||
import {
|
||||
type NotificationTypeConfig,
|
||||
NotificationType,
|
||||
DEFAULT_TOAST_AUTO_CLOSE_MS,
|
||||
} from '@/constants/notifications';
|
||||
|
||||
import { useSelectedNetwork, useStringGetter } from '@/hooks';
|
||||
import { useLocalNotifications } from '@/hooks/useLocalNotifications';
|
||||
|
||||
import { Icon, IconName } from '@/components/Icon';
|
||||
import { Output, OutputType } from '@/components/Output';
|
||||
import { TransferStatusToast } from '@/views/TransferStatus';
|
||||
import { TradeNotification } from '@/views/notifications/TradeNotification';
|
||||
import { TransferStatusNotification } from '@/views/notifications/TransferStatusNotification';
|
||||
|
||||
import { getSubaccountFills, getSubaccountOrders } from '@/state/accountSelectors';
|
||||
import { openDialog } from '@/state/dialogs';
|
||||
import { getAbacusNotifications } from '@/state/notificationsSelectors';
|
||||
import { getMarketIds } from '@/state/perpetualsSelectors';
|
||||
|
||||
import { OrderStatusIcon } from '@/views/OrderStatusIcon';
|
||||
import { formatSeconds } from '@/lib/timeUtils';
|
||||
|
||||
import { useStringGetter } from './useStringGetter';
|
||||
import { TransferStatusSteps } from '@/views/TransferStatusSteps';
|
||||
const parseStringParamsForNotification = ({
|
||||
stringGetter,
|
||||
value,
|
||||
}: {
|
||||
stringGetter: StringGetterFunction;
|
||||
value: unknown;
|
||||
}): ReactNode => {
|
||||
if (STRING_KEY_VALUES[value as StringKey]) {
|
||||
return stringGetter({ key: value as StringKey });
|
||||
}
|
||||
|
||||
export const notificationTypes = [
|
||||
return value as ReactNode;
|
||||
};
|
||||
|
||||
export const notificationTypes: NotificationTypeConfig[] = [
|
||||
{
|
||||
type: NotificationType.OrderStatusChanged,
|
||||
|
||||
useTrigger: ({ trigger, lastUpdated }) => {
|
||||
type: NotificationType.AbacusGenerated,
|
||||
useTrigger: ({ trigger }) => {
|
||||
const stringGetter = useStringGetter();
|
||||
|
||||
const orders = useSelector(getSubaccountOrders, shallowEqual) || [];
|
||||
const ordersByOrderId = Object.fromEntries(orders.map((order) => [order.id, order]));
|
||||
|
||||
const fills = useSelector(getSubaccountFills, shallowEqual) || [];
|
||||
const fillsByOrderId = groupBy(fills, (fill) => fill.orderId);
|
||||
|
||||
const orderIds = useMemo(
|
||||
() => [...Object.keys(ordersByOrderId), ...Object.keys(fillsByOrderId)],
|
||||
[orders, fills]
|
||||
);
|
||||
const abacusNotifications = useSelector(getAbacusNotifications, isEqual);
|
||||
|
||||
useEffect(() => {
|
||||
for (const orderId of orderIds) {
|
||||
const fills = fillsByOrderId[orderId];
|
||||
for (const abacusNotif of abacusNotifications) {
|
||||
const [abacusNotificationType = '', id = ''] = abacusNotif.id.split(':');
|
||||
const parsedData = abacusNotif.data ? JSON.parse(abacusNotif.data) : {};
|
||||
|
||||
const order =
|
||||
ordersByOrderId[orderId] ??
|
||||
(fills?.length
|
||||
? {
|
||||
...fills[fills.length - 1],
|
||||
id: orderId,
|
||||
createdAtMilliseconds: Math.max(
|
||||
...fills.map((fill) => fill.createdAtMilliseconds)
|
||||
const params = Object.fromEntries(
|
||||
Object.entries(parsedData).map(([key, value]) => {
|
||||
return [key, parseStringParamsForNotification({ stringGetter, value })];
|
||||
})
|
||||
);
|
||||
|
||||
switch (abacusNotificationType) {
|
||||
case 'order': {
|
||||
trigger(
|
||||
abacusNotif.id,
|
||||
{
|
||||
icon: abacusNotif.image && <$Icon src={abacusNotif.image} alt="" />,
|
||||
title: stringGetter({ key: abacusNotif.title }),
|
||||
body: abacusNotif.text ? stringGetter({ key: abacusNotif.text, params }) : '',
|
||||
toastDuration: DEFAULT_TOAST_AUTO_CLOSE_MS,
|
||||
toastSensitivity: 'foreground',
|
||||
renderCustomBody: ({ isToast, notification }) => (
|
||||
<TradeNotification
|
||||
isToast={isToast}
|
||||
data={parsedData}
|
||||
notification={notification}
|
||||
/>
|
||||
),
|
||||
status: AbacusOrderStatus.filled,
|
||||
}
|
||||
: undefined);
|
||||
|
||||
if (order)
|
||||
trigger(
|
||||
order.id,
|
||||
{
|
||||
icon: (
|
||||
<OrderStatusIcon status={order.status} totalFilled={order.totalFilled ?? 0} />
|
||||
),
|
||||
title: `${stringGetter({
|
||||
key: order.resources.typeStringKey as StringKey,
|
||||
})} ${
|
||||
order.status === AbacusOrderStatus.open && (order?.totalFilled ?? 0) > 0
|
||||
? stringGetter({ key: STRING_KEYS.PARTIALLY_FILLED })
|
||||
: stringGetter({ key: order.resources.statusStringKey as StringKey })
|
||||
}`,
|
||||
description: `${stringGetter({
|
||||
key: ORDER_SIDE_STRINGS[ORDER_SIDES[order.side.name]],
|
||||
})} ${order.size} ${order.marketId} @ $${order.price}`,
|
||||
actionDescription: 'View Order',
|
||||
actionAltText: 'View this order in the Orders tab or the Notifications menu.',
|
||||
toastSensitivity:
|
||||
order.status === AbacusOrderStatus.pending ? 'foreground' : 'background',
|
||||
toastDuration: 5000,
|
||||
},
|
||||
[order.status.name, order.size],
|
||||
!order.createdAtMilliseconds || order.createdAtMilliseconds > lastUpdated
|
||||
);
|
||||
},
|
||||
[abacusNotif.updateTimeInMilliseconds, abacusNotif.data],
|
||||
true
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
trigger(
|
||||
abacusNotif.id,
|
||||
{
|
||||
icon: abacusNotif.image && <$Icon src={abacusNotif.image} alt="" />,
|
||||
title: stringGetter({ key: abacusNotif.title }),
|
||||
body: abacusNotif.text ? stringGetter({ key: abacusNotif.text, params }) : '',
|
||||
toastDuration: DEFAULT_TOAST_AUTO_CLOSE_MS,
|
||||
toastSensitivity: 'foreground',
|
||||
},
|
||||
[abacusNotif.updateTimeInMilliseconds, abacusNotif.data]
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [orderIds]);
|
||||
}, [abacusNotifications, stringGetter]);
|
||||
},
|
||||
useNotificationAction: () => {
|
||||
const dispatch = useDispatch();
|
||||
const orders = useSelector(getSubaccountOrders, shallowEqual) || [];
|
||||
const ordersById = groupBy(orders, 'id');
|
||||
const fills = useSelector(getSubaccountFills, shallowEqual) || [];
|
||||
const fillsById = groupBy(fills, 'id');
|
||||
const marketIds = useSelector(getMarketIds, shallowEqual);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// useNotificationAction: () => {
|
||||
// const dispatch = useDispatch();
|
||||
// const orders = useSelector(getSubaccountOrders, shallowEqual) || [];
|
||||
// const ordersByOrderId = Object.fromEntries(orders.map((order) => [order.id, order]));
|
||||
return (notificationId: string) => {
|
||||
const [abacusNotificationType = '', id = ''] = notificationId.split(':');
|
||||
|
||||
// const fills = useSelector(getSubaccountFills, shallowEqual) || [];
|
||||
// const fillsByOrderId = groupBy(fills, (fill) => fill.orderId);
|
||||
|
||||
// return (id) => {
|
||||
// if (ordersByOrderId[id]) {
|
||||
// dispatch(
|
||||
// openDialog({
|
||||
// type: DialogTypes.OrderDetails,
|
||||
// dialogProps: { orderId: id },
|
||||
// })
|
||||
// );
|
||||
// } else if (fillsByOrderId[id]) {
|
||||
// const fillId = fillsByOrderId[id][0].id;
|
||||
|
||||
// dispatch(
|
||||
// openDialog({
|
||||
// type: DialogTypes.FillDetails,
|
||||
// dialogProps: { fillId },
|
||||
// })
|
||||
// );
|
||||
// }
|
||||
// };
|
||||
// },
|
||||
} as NotificationTypeConfig<string, [string, number]>,
|
||||
if (ordersById[id]) {
|
||||
dispatch(
|
||||
openDialog({
|
||||
type: DialogTypes.OrderDetails,
|
||||
dialogProps: { orderId: id },
|
||||
})
|
||||
);
|
||||
} else if (fillsById[id]) {
|
||||
dispatch(
|
||||
openDialog({
|
||||
type: DialogTypes.FillDetails,
|
||||
dialogProps: { fillId: id },
|
||||
})
|
||||
);
|
||||
} else if (marketIds.includes(id)) {
|
||||
navigate(`${AppRoute.Trade}/${id}`, {
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
type: NotificationType.SquidTransfer,
|
||||
useTrigger: ({ trigger }) => {
|
||||
@@ -127,81 +149,65 @@ export const notificationTypes = [
|
||||
const { transferNotifications } = useLocalNotifications();
|
||||
const { selectedNetwork } = useSelectedNetwork();
|
||||
|
||||
const getTitleStringKey = useCallback((type: 'deposit' | 'withdrawal', finished: boolean) => {
|
||||
if (type === 'deposit' && !finished) return STRING_KEYS.DEPOSIT_IN_PROGRESS;
|
||||
if (type === 'deposit' && finished) return STRING_KEYS.DEPOSIT;
|
||||
if (type === 'withdrawal' && !finished) return STRING_KEYS.WITHDRAW_IN_PROGRESS;
|
||||
return STRING_KEYS.WITHDRAW;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
for (const transfer of transferNotifications) {
|
||||
const { fromChainId, status, txHash, toAmount } = transfer;
|
||||
const finished = Boolean(status) && status?.squidTransactionStatus !== 'ongoing';
|
||||
const type = fromChainId === ENVIRONMENT_CONFIG_MAP[selectedNetwork].dydxChainId ? 'withdrawal' : 'deposit';
|
||||
// @ts-ignore status.errors is not in the type definition but can be returned
|
||||
const error = status?.errors?.length ? status?.errors[0] : status?.error;
|
||||
const isFinished = Boolean(status) && status?.squidTransactionStatus !== 'ongoing';
|
||||
const icon = <Icon iconName={isFinished ? IconName.Transfer : IconName.Clock} />;
|
||||
|
||||
// TODO: confirm with design what the description should be
|
||||
const description = (
|
||||
<div>
|
||||
<Styled.TransferText>
|
||||
{type === 'deposit' ? 'Deposit of ' : 'Withdraw of '}
|
||||
<Output type={OutputType.Fiat} value={toAmount} />
|
||||
</Styled.TransferText>
|
||||
const type =
|
||||
fromChainId === ENVIRONMENT_CONFIG_MAP[selectedNetwork].dydxChainId
|
||||
? 'withdrawal'
|
||||
: 'deposit';
|
||||
|
||||
{error && (
|
||||
<Styled.ErrorMessage type={AlertType.Error}>
|
||||
{stringGetter({
|
||||
key: STRING_KEYS.SOMETHING_WENT_WRONG_WITH_MESSAGE,
|
||||
params: {
|
||||
ERROR_MESSAGE:
|
||||
error.message || stringGetter({ key: STRING_KEYS.UNKNOWN_ERROR }),
|
||||
},
|
||||
})}
|
||||
</Styled.ErrorMessage>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
const title = stringGetter({
|
||||
key: {
|
||||
deposit: isFinished ? STRING_KEYS.DEPOSIT : STRING_KEYS.DEPOSIT_IN_PROGRESS,
|
||||
withdrawal: isFinished ? STRING_KEYS.WITHDRAW : STRING_KEYS.WITHDRAW_IN_PROGRESS,
|
||||
}[type],
|
||||
});
|
||||
|
||||
const toChainEta = status?.toChain?.chainData?.estimatedRouteDuration || 0;
|
||||
const estimatedDuration = formatSeconds(Math.max(toChainEta, 0));
|
||||
const body = stringGetter({
|
||||
key: STRING_KEYS.DEPOSIT_STATUS,
|
||||
params: {
|
||||
AMOUNT_USD: `${toAmount} ${DydxChainAsset.USDC.toUpperCase()}`,
|
||||
ESTIMATED_DURATION: estimatedDuration,
|
||||
},
|
||||
});
|
||||
|
||||
trigger(
|
||||
txHash,
|
||||
{
|
||||
icon: <Icon iconName={finished ? IconName.Transfer : IconName.Clock} />,
|
||||
title: stringGetter({ key: getTitleStringKey(type, finished) }),
|
||||
description: description,
|
||||
customContent: (
|
||||
<TransferStatusToast
|
||||
icon,
|
||||
title,
|
||||
body,
|
||||
renderCustomBody: ({ isToast, notification }) => (
|
||||
<TransferStatusNotification
|
||||
isToast={isToast}
|
||||
slotIcon={icon}
|
||||
slotTitle={title}
|
||||
transfer={transfer}
|
||||
type={type}
|
||||
toAmount={transfer.toAmount}
|
||||
triggeredAt={transfer.triggeredAt}
|
||||
status={transfer.status}
|
||||
notification={notification}
|
||||
/>
|
||||
),
|
||||
customMenuContent: !finished && (
|
||||
<div>
|
||||
{description}
|
||||
<TransferStatusSteps status={transfer.status} type={type} />
|
||||
</div>
|
||||
),
|
||||
toastSensitivity: 'foreground',
|
||||
},
|
||||
[]
|
||||
[isFinished]
|
||||
);
|
||||
}
|
||||
}, [transferNotifications]);
|
||||
}, [transferNotifications, stringGetter]);
|
||||
},
|
||||
useNotificationAction: () => {
|
||||
return () => {};
|
||||
},
|
||||
},
|
||||
] satisfies NotificationTypeConfig[];
|
||||
];
|
||||
|
||||
const Styled: Record<string, AnyStyledComponent> = {};
|
||||
|
||||
Styled.TransferText = styled.span`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5ch;
|
||||
`;
|
||||
|
||||
Styled.ErrorMessage = styled.div`
|
||||
max-width: 13rem;
|
||||
const $Icon = styled.img`
|
||||
height: 1.5rem;
|
||||
width: 1.5rem;
|
||||
`;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { LocalStorageKey } from '@/constants/localStorage';
|
||||
import { LOCAL_STORAGE_VERSIONS, LocalStorageKey } from '@/constants/localStorage';
|
||||
import {
|
||||
type Notification,
|
||||
type NotificationDisplayData,
|
||||
type NotificationPreferences,
|
||||
type Notifications,
|
||||
NotificationStatus,
|
||||
NotificationType,
|
||||
} from '@/constants/notifications';
|
||||
|
||||
import { useLocalStorage } from './useLocalStorage';
|
||||
@@ -13,8 +15,10 @@ import { notificationTypes } from './useNotificationTypes';
|
||||
|
||||
import { renderSvgToDataUrl } from '../lib/renderSvgToDataUrl';
|
||||
|
||||
const NotificationsContext = createContext<ReturnType<typeof useNotificationsContext> | undefined>(
|
||||
undefined
|
||||
type NotificationsContextType = ReturnType<typeof useNotificationsContext>;
|
||||
|
||||
const NotificationsContext = createContext<NotificationsContextType>(
|
||||
{} as NotificationsContextType
|
||||
);
|
||||
|
||||
NotificationsContext.displayName = 'Notifications';
|
||||
@@ -27,6 +31,7 @@ export const useNotifications = () => useContext(NotificationsContext)!;
|
||||
|
||||
const useNotificationsContext = () => {
|
||||
// Local storage
|
||||
// const [notifications, setNotifications] = useState<Notifications>({});
|
||||
const [notifications, setNotifications] = useLocalStorage<Notifications>({
|
||||
key: LocalStorageKey.Notifications,
|
||||
defaultValue: {},
|
||||
@@ -37,12 +42,25 @@ const useNotificationsContext = () => {
|
||||
defaultValue: Date.now(),
|
||||
});
|
||||
|
||||
const [notificationPreferences, setNotificationPreferences] =
|
||||
useLocalStorage<NotificationPreferences>({
|
||||
key: LocalStorageKey.NotificationPreferences,
|
||||
defaultValue: {
|
||||
[NotificationType.AbacusGenerated]: true,
|
||||
[NotificationType.SquidTransfer]: true,
|
||||
version: LOCAL_STORAGE_VERSIONS[LocalStorageKey.NotificationPreferences],
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setNotificationsLastUpdated(Date.now());
|
||||
}, [notifications]);
|
||||
|
||||
const getKey = <T extends string | number>(notification: Pick<Notification<T>, 'type' | 'id'>) =>
|
||||
`${notification.type}/${notification.id}`;
|
||||
const getKey = useCallback(
|
||||
<T extends string | number>(notification: Pick<Notification<T>, 'type' | 'id'>) =>
|
||||
`${notification.type}/${notification.id}`,
|
||||
[]
|
||||
);
|
||||
|
||||
// Display data
|
||||
const [notificationsDisplayData, setNotificationsDisplayData] = useState(
|
||||
@@ -54,29 +72,46 @@ const useNotificationsContext = () => {
|
||||
[notificationsDisplayData]
|
||||
);
|
||||
|
||||
// Check for version changes
|
||||
useEffect(() => {
|
||||
if (
|
||||
notificationPreferences.version !==
|
||||
LOCAL_STORAGE_VERSIONS[LocalStorageKey.NotificationPreferences]
|
||||
) {
|
||||
setNotificationPreferences({
|
||||
[NotificationType.AbacusGenerated]: true,
|
||||
[NotificationType.SquidTransfer]: true,
|
||||
version: LOCAL_STORAGE_VERSIONS[LocalStorageKey.NotificationPreferences],
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Status changes
|
||||
const updateStatus = useCallback(
|
||||
(notification: Notification, status: NotificationStatus) => {
|
||||
notification.status = status;
|
||||
notification.timestamps[notification.status] = Date.now();
|
||||
setNotifications({ ...notifications });
|
||||
setNotifications({ ...notifications, [getKey(notification)]: notification });
|
||||
},
|
||||
[notifications]
|
||||
[notifications, getKey]
|
||||
);
|
||||
|
||||
const { markUnseen, markSeen, markCleared } = useMemo(
|
||||
() => ({
|
||||
markUnseen: (notification: Notification) => {
|
||||
if (notification.status < NotificationStatus.Unseen)
|
||||
if (notification.status < NotificationStatus.Unseen) {
|
||||
updateStatus(notification, NotificationStatus.Unseen);
|
||||
}
|
||||
},
|
||||
markSeen: (notification: Notification) => {
|
||||
if (notification.status < NotificationStatus.Seen)
|
||||
if (notification.status < NotificationStatus.Seen) {
|
||||
updateStatus(notification, NotificationStatus.Seen);
|
||||
}
|
||||
},
|
||||
markCleared: (notification: Notification) => {
|
||||
if (notification.status < NotificationStatus.Cleared)
|
||||
if (notification.status < NotificationStatus.Cleared) {
|
||||
updateStatus(notification, NotificationStatus.Cleared);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[updateStatus]
|
||||
@@ -97,33 +132,37 @@ const useNotificationsContext = () => {
|
||||
|
||||
const notification = notifications[key];
|
||||
|
||||
// New unique key - create new notification
|
||||
if (!notification) {
|
||||
const notification = (notifications[key] = {
|
||||
id,
|
||||
type,
|
||||
timestamps: {},
|
||||
updateKey,
|
||||
} as Notification);
|
||||
// Filter out notifications that are not enabled
|
||||
if (notificationPreferences[type] !== false) {
|
||||
// New unique key - create new notification
|
||||
if (!notification) {
|
||||
const notification = (notifications[key] = {
|
||||
id,
|
||||
type,
|
||||
timestamps: {},
|
||||
updateKey,
|
||||
} as Notification);
|
||||
updateStatus(
|
||||
notification,
|
||||
isNew ? NotificationStatus.Triggered : NotificationStatus.Cleared
|
||||
);
|
||||
} else if (JSON.stringify(updateKey) !== JSON.stringify(notification.updateKey)) {
|
||||
// updateKey changed - update existing notification
|
||||
|
||||
updateStatus(
|
||||
notification,
|
||||
isNew ? NotificationStatus.Triggered : NotificationStatus.Cleared
|
||||
);
|
||||
}
|
||||
const notification = notifications[key];
|
||||
|
||||
// updateKey changed - update existing notification
|
||||
else if (JSON.stringify(updateKey) !== JSON.stringify(notification.updateKey)) {
|
||||
const notification = notifications[key];
|
||||
|
||||
notification.updateKey = updateKey;
|
||||
updateStatus(notification, NotificationStatus.Updated);
|
||||
notification.updateKey = updateKey;
|
||||
updateStatus(notification, NotificationStatus.Updated);
|
||||
}
|
||||
} else {
|
||||
// Notification is disabled - remove it
|
||||
delete notifications[key];
|
||||
}
|
||||
|
||||
notificationsDisplayData[key] = displayData;
|
||||
setNotificationsDisplayData({ ...notificationsDisplayData });
|
||||
},
|
||||
[notifications, updateStatus]
|
||||
[notifications, updateStatus, notificationPreferences[type]]
|
||||
),
|
||||
|
||||
lastUpdated: notificationsLastUpdated,
|
||||
@@ -136,10 +175,8 @@ const useNotificationsContext = () => {
|
||||
)
|
||||
);
|
||||
|
||||
const onNotificationAction = useCallback(
|
||||
async (notification: Notification) => await actions[notification.type]?.(notification.id),
|
||||
[actions]
|
||||
);
|
||||
const onNotificationAction = async (notification: Notification) =>
|
||||
await actions[notification.type]?.(notification.id);
|
||||
|
||||
// Push notifications
|
||||
const [hasEnabledPush, setHasEnabledPush] = useLocalStorage({
|
||||
@@ -186,7 +223,7 @@ const useNotificationsContext = () => {
|
||||
renotify: true,
|
||||
tag: getKey(notification),
|
||||
data: notification,
|
||||
body: displayData.description,
|
||||
description: displayData.body,
|
||||
icon: iconUrl ?? '/favicon.svg',
|
||||
badge: iconUrl ?? '/favicon.svg',
|
||||
image: iconUrl ?? '/favicon.svg',
|
||||
@@ -208,7 +245,7 @@ const useNotificationsContext = () => {
|
||||
|
||||
setPushNotificationsLastUpdated(Date.now());
|
||||
})();
|
||||
}, [hasEnabledPush, notifications, onNotificationAction, markSeen]);
|
||||
}, [hasEnabledPush, notifications, markSeen]);
|
||||
|
||||
// Menu state
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
@@ -239,5 +276,13 @@ const useNotificationsContext = () => {
|
||||
// Menu state
|
||||
isMenuOpen,
|
||||
setIsMenuOpen,
|
||||
|
||||
// Notification Preferences
|
||||
notificationPreferences,
|
||||
setNotificationPreferences,
|
||||
getNotificationPreferenceForType: useCallback(
|
||||
(type: NotificationType) => notificationPreferences[type],
|
||||
[notificationPreferences]
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ExternalNavKeplrDialog } from '@/views/dialogs/ExternalNavKeplrDialog';
|
||||
import { MnemonicExportDialog } from '@/views/dialogs/MnemonicExportDialog';
|
||||
import { MobileSignInDialog } from '@/views/dialogs/MobileSignInDialog';
|
||||
import { OnboardingDialog } from '@/views/dialogs/OnboardingDialog';
|
||||
import { PreferencesDialog } from '@/views/dialogs/PreferencesDialog';
|
||||
import { RateLimitDialog } from '@/views/dialogs/RateLimitDialog';
|
||||
import { ReceiveDialog } from '@/views/dialogs/ReceiveDialog';
|
||||
import { RestrictedGeoDialog } from '@/views/dialogs/RestrictedGeoDialog';
|
||||
@@ -56,6 +57,7 @@ export const DialogManager = () => {
|
||||
[DialogTypes.MobileSignIn]: <MobileSignInDialog {...modalProps} />,
|
||||
[DialogTypes.Onboarding]: <OnboardingDialog {...modalProps} />,
|
||||
[DialogTypes.OrderDetails]: <OrderDetailsDialog {...modalProps} />,
|
||||
[DialogTypes.Preferences]: <PreferencesDialog {...modalProps} />,
|
||||
[DialogTypes.RateLimit]: <RateLimitDialog {...modalProps} />,
|
||||
[DialogTypes.Receive]: <ReceiveDialog {...modalProps} />,
|
||||
[DialogTypes.RestrictedGeo]: <RestrictedGeoDialog {...modalProps} />,
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { useMemo } from 'react';
|
||||
import styled, { css } from 'styled-components';
|
||||
|
||||
import { NotificationStatus } from '@/constants/notifications';
|
||||
import { ButtonSize } from '@/constants/buttons';
|
||||
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { useBreakpoints } from '@/hooks/useBreakpoints';
|
||||
|
||||
import { ToastArea } from '@/components/ToastArea';
|
||||
import { Toast } from '@/components/Toast';
|
||||
import { Button } from '@/components/Button';
|
||||
|
||||
import styled from 'styled-components';
|
||||
import { Toast } from '@/components/Toast';
|
||||
import { ToastArea } from '@/components/ToastArea';
|
||||
|
||||
type StyleProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const MAX_TOASTS = 10;
|
||||
|
||||
export const NotificationsToastArea = ({ className }: StyleProps) => {
|
||||
const {
|
||||
notifications,
|
||||
@@ -26,10 +29,9 @@ export const NotificationsToastArea = ({ className }: StyleProps) => {
|
||||
} = useNotifications();
|
||||
|
||||
const { isMobile } = useBreakpoints();
|
||||
|
||||
return (
|
||||
<StyledToastArea swipeDirection={isMobile ? 'up' : 'right'} className={className}>
|
||||
{Object.values(notifications)
|
||||
const notificationMap = useMemo(() => {
|
||||
return (
|
||||
Object.values(notifications)
|
||||
// Sort by time of first trigger
|
||||
.sort(
|
||||
(n1, n2) =>
|
||||
@@ -42,44 +44,69 @@ export const NotificationsToastArea = ({ className }: StyleProps) => {
|
||||
displayData: getDisplayData(notification),
|
||||
}))
|
||||
.filter(({ displayData }) => displayData)
|
||||
.map(({ notification, key, displayData }) => (
|
||||
<Toast
|
||||
key={key}
|
||||
isOpen={notification.status < NotificationStatus.Unseen}
|
||||
slotIcon={displayData.icon}
|
||||
slotTitle={displayData.title}
|
||||
slotDescription={displayData.description}
|
||||
slotCustomContent={displayData.customContent}
|
||||
slotAction={
|
||||
<Button size={ButtonSize.Small} onClick={() => onNotificationAction(notification)}>
|
||||
{displayData.actionDescription}
|
||||
</Button>
|
||||
}
|
||||
actionDescription={displayData.actionDescription}
|
||||
actionAltText={displayData.actionAltText}
|
||||
duration={isMenuOpen ? Infinity : displayData.toastDuration}
|
||||
sensitivity={displayData.toastSensitivity}
|
||||
setIsOpen={(isOpen, isClosedFromTimeout) => {
|
||||
if (!isOpen)
|
||||
if (isClosedFromTimeout)
|
||||
// Toast timer expired without user interaction
|
||||
markUnseen(notification);
|
||||
// Toast interacted with or dismissed
|
||||
else markSeen(notification);
|
||||
}}
|
||||
lastUpdated={notification.timestamps[notification.status]}
|
||||
/>
|
||||
))}
|
||||
.slice(-MAX_TOASTS)
|
||||
);
|
||||
}, [notifications, getKey, getDisplayData]);
|
||||
|
||||
if (isMenuOpen) return null;
|
||||
|
||||
return (
|
||||
<StyledToastArea swipeDirection={isMobile ? 'up' : 'right'} className={className}>
|
||||
{notificationMap.map(({ notification, key, displayData }, idx) => (
|
||||
<StyledToast
|
||||
key={key}
|
||||
layer={notificationMap.length - 1 - idx}
|
||||
isOpen={notification.status < NotificationStatus.Unseen}
|
||||
notification={notification}
|
||||
slotIcon={displayData.icon}
|
||||
slotTitle={displayData.title}
|
||||
slotTitleLeft={displayData.slotTitleLeft}
|
||||
slotTitleRight={displayData.slotTitleRight}
|
||||
slotDescription={displayData.body}
|
||||
slotCustomContent={displayData.renderCustomBody?.({ isToast: true, notification })}
|
||||
onClick={() => onNotificationAction(notification)}
|
||||
slotAction={
|
||||
<Button size={ButtonSize.Small} onClick={() => onNotificationAction(notification)}>
|
||||
{displayData.actionDescription}
|
||||
</Button>
|
||||
}
|
||||
actionDescription={displayData.actionDescription}
|
||||
actionAltText={displayData.actionAltText}
|
||||
duration={displayData.toastDuration ?? Infinity}
|
||||
sensitivity={displayData.toastSensitivity}
|
||||
setIsOpen={(isOpen, isClosedFromTimeout) => {
|
||||
if (!isOpen)
|
||||
if (isClosedFromTimeout)
|
||||
// Toast timer expired without user interaction
|
||||
markUnseen(notification);
|
||||
// Toast interacted with or dismissed
|
||||
else markSeen(notification);
|
||||
}}
|
||||
lastUpdated={notification.timestamps[notification.status]}
|
||||
/>
|
||||
))}
|
||||
</StyledToastArea>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledToastArea = styled(ToastArea)`
|
||||
position: absolute;
|
||||
width: min(16rem, 100%);
|
||||
width: min(17.5rem, 100%);
|
||||
inset: 0 0 0 auto;
|
||||
|
||||
padding: 0.75rem 0.75rem 0.75rem 0;
|
||||
|
||||
mask-image: linear-gradient(to left, transparent, white 0.5rem);
|
||||
`;
|
||||
|
||||
const StyledToast = styled(Toast)<{ layer: number }>`
|
||||
// Stacked toast
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
${({ layer }) => css`
|
||||
right: calc(${layer} * -2px);
|
||||
top: calc(${layer} * 2px);
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Abacus, { type Nullable } from '@dydxprotocol/v4-abacus';
|
||||
import Long from 'long';
|
||||
import type { IndexedTx } from '@cosmjs/stargate';
|
||||
import { encodeJson } from '@dydxprotocol/v4-client-js';
|
||||
|
||||
import {
|
||||
CompositeClient,
|
||||
@@ -195,8 +196,9 @@ class DydxChainTransactions implements AbacusDYDXChainTransactionsProtocol {
|
||||
throw new StatefulOrderError('Stateful order has failed to commit.', tx);
|
||||
}
|
||||
|
||||
const parsedTx = this.parseToPrimitives(tx);
|
||||
const hash = parsedTx?.hash;
|
||||
const encodedTx = encodeJson(tx);
|
||||
const parsedTx = JSON.parse(encodedTx);
|
||||
const hash = parsedTx.hash.toUpperCase();
|
||||
|
||||
if (isTestnet) {
|
||||
console.log(
|
||||
@@ -206,7 +208,7 @@ class DydxChainTransactions implements AbacusDYDXChainTransactionsProtocol {
|
||||
);
|
||||
} else console.log(`txHash: ${hash}`);
|
||||
|
||||
return JSON.stringify(parsedTx);
|
||||
return encodedTx;
|
||||
} catch (error) {
|
||||
if (error?.name !== 'BroadcastError') {
|
||||
log('DydxChainTransactions/placeOrderTransaction', error);
|
||||
@@ -236,9 +238,14 @@ class DydxChainTransactions implements AbacusDYDXChainTransactionsProtocol {
|
||||
goodTilBlockTime || undefined
|
||||
);
|
||||
|
||||
const parsedTx = this.parseToPrimitives(tx);
|
||||
const encodedTx = encodeJson(tx);
|
||||
|
||||
return JSON.stringify(parsedTx);
|
||||
if (import.meta.env.MODE === 'development') {
|
||||
const parsedTx = JSON.parse(encodedTx);
|
||||
console.log(parsedTx, parsedTx.hash.toUpperCase());
|
||||
}
|
||||
|
||||
return encodedTx;
|
||||
} catch (error) {
|
||||
log('DydxChainTransactions/cancelOrderTransaction', error);
|
||||
|
||||
@@ -434,8 +441,6 @@ class DydxChainTransactions implements AbacusDYDXChainTransactionsProtocol {
|
||||
const parseDelegations = this.parseToPrimitives(delegations);
|
||||
callback(JSON.stringify(parseDelegations));
|
||||
break;
|
||||
// Do not implement Transfers (yet)
|
||||
case QueryType.Transfers:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { setApiState } from '@/state/app';
|
||||
import { setAssets } from '@/state/assets';
|
||||
import { setConfigs } from '@/state/configs';
|
||||
import { setInputs } from '@/state/inputs';
|
||||
import { updateNotifications } from '@/state/notifications';
|
||||
import { setHistoricalFundings, setLiveTrades, setMarkets, setOrderbook } from '@/state/perpetuals';
|
||||
import { isTruthy } from '../isTruthy';
|
||||
|
||||
@@ -50,6 +51,7 @@ class AbacusStateNotifier implements AbacusStateNotificationProtocol {
|
||||
}
|
||||
|
||||
notificationsChanged(notifications: kollections.List<AbacusNotification>): void {
|
||||
this.store?.dispatch(updateNotifications(notifications.toArray()));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
|
||||
import abacusStateManager from '@/lib/abacus';
|
||||
|
||||
import appMiddleware from './appMiddleware';
|
||||
import localizationMiddleware from './localizationMiddleware';
|
||||
|
||||
@@ -13,6 +14,7 @@ import { inputsSlice } from './inputs';
|
||||
import { layoutSlice } from './layout';
|
||||
import { localizationSlice } from './localization';
|
||||
import { perpetualsSlice } from './perpetuals';
|
||||
import { notificationsSlice } from './notifications';
|
||||
|
||||
export const commandMenuSlices = [layoutSlice, localizationSlice];
|
||||
|
||||
@@ -26,6 +28,7 @@ export const store = configureStore({
|
||||
inputs: inputsSlice.reducer,
|
||||
layout: layoutSlice.reducer,
|
||||
localization: localizationSlice.reducer,
|
||||
notifications: notificationsSlice.reducer,
|
||||
perpetuals: perpetualsSlice.reducer,
|
||||
},
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
import { LOCALE_DATA, SupportedLocale, TOOLTIPS } from '@dydxprotocol/v4-localization';
|
||||
import {
|
||||
LOCALE_DATA,
|
||||
NOTIFICATIONS,
|
||||
SupportedLocale,
|
||||
TOOLTIPS,
|
||||
} from '@dydxprotocol/v4-localization';
|
||||
|
||||
import {
|
||||
type LocaleData,
|
||||
@@ -26,6 +31,7 @@ const getNewLocaleData = ({
|
||||
|
||||
const newLocaleData = {
|
||||
...LOCALE_DATA[locale],
|
||||
...NOTIFICATIONS[locale],
|
||||
TOOLTIPS: TOOLTIPS[locale],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import type { PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import { AbacusNotification } from '@/constants/abacus';
|
||||
|
||||
export interface NotificationsState {
|
||||
abacusNotifications: AbacusNotification[];
|
||||
}
|
||||
|
||||
const initialState: NotificationsState = {
|
||||
abacusNotifications: [],
|
||||
};
|
||||
|
||||
export const notificationsSlice = createSlice({
|
||||
name: 'Notifications',
|
||||
initialState,
|
||||
reducers: {
|
||||
updateNotifications: (state, action: PayloadAction<AbacusNotification[]>) => {
|
||||
state.abacusNotifications = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { updateNotifications } = notificationsSlice.actions;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { RootState } from './_store';
|
||||
|
||||
export const getAbacusNotifications = (state: RootState) => state.notifications.abacusNotifications;
|
||||
@@ -12,8 +12,8 @@ export const headerMixins = {
|
||||
--trigger-open-textColor: var(--color-text-2);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--trigger-hover-backgroundColor);
|
||||
color: var(--trigger-hover-textColor);
|
||||
--trigger-backgroundColor: var(--trigger-hover-backgroundColor);
|
||||
--trigger-textColor: var(--trigger-hover-textColor);
|
||||
}
|
||||
`,
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type OrderStatus, AbacusOrderStatus } from '@/constants/abacus';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { AbacusOrderStatus } from '@/constants/abacus';
|
||||
|
||||
import { Icon } from '@/components/Icon';
|
||||
import {
|
||||
OrderCanceledIcon,
|
||||
OrderFilledIcon,
|
||||
@@ -9,49 +10,57 @@ import {
|
||||
OrderPendingIcon,
|
||||
} from '@/icons';
|
||||
|
||||
import styled from 'styled-components';
|
||||
import { Icon } from '@/components/Icon';
|
||||
|
||||
type ElementProps = {
|
||||
status: OrderStatus;
|
||||
status: string;
|
||||
totalFilled: number;
|
||||
};
|
||||
|
||||
export const OrderStatusIcon = ({ status, totalFilled }: ElementProps) => (
|
||||
<$Icon
|
||||
{...{
|
||||
[AbacusOrderStatus.open.name]:
|
||||
totalFilled > 0
|
||||
? {
|
||||
iconComponent: OrderPartiallyFilledIcon,
|
||||
color: 'var(--color-warning)',
|
||||
}
|
||||
: {
|
||||
iconComponent: OrderOpenIcon,
|
||||
color: 'var(--color-text-2)',
|
||||
},
|
||||
[AbacusOrderStatus.filled.name]: {
|
||||
iconComponent: OrderFilledIcon,
|
||||
color: 'var(--color-success)',
|
||||
},
|
||||
[AbacusOrderStatus.cancelled.name]: {
|
||||
iconComponent: OrderCanceledIcon,
|
||||
color: 'var(--color-error)',
|
||||
},
|
||||
[AbacusOrderStatus.canceling.name]: {
|
||||
iconComponent: OrderPendingIcon,
|
||||
color: 'var(--color-error)',
|
||||
},
|
||||
[AbacusOrderStatus.pending.name]: {
|
||||
iconComponent: OrderPendingIcon,
|
||||
color: 'var(--color-text-2)',
|
||||
},
|
||||
[AbacusOrderStatus.untriggered.name]: {
|
||||
iconComponent: OrderPendingIcon,
|
||||
color: 'var(--color-text-2)',
|
||||
},
|
||||
}[status.name]}
|
||||
/>
|
||||
);
|
||||
type StyleProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const OrderStatusIcon = ({ className, status, totalFilled }: ElementProps & StyleProps) => {
|
||||
const { iconComponent, color } = {
|
||||
[AbacusOrderStatus.open.rawValue]:
|
||||
totalFilled > 0
|
||||
? {
|
||||
iconComponent: OrderPartiallyFilledIcon,
|
||||
color: 'var(--color-warning)',
|
||||
}
|
||||
: {
|
||||
iconComponent: OrderOpenIcon,
|
||||
color: 'var(--color-text-2)',
|
||||
},
|
||||
[AbacusOrderStatus.partiallyFilled.rawValue]: {
|
||||
iconComponent: OrderPartiallyFilledIcon,
|
||||
color: 'var(--color-warning)',
|
||||
},
|
||||
[AbacusOrderStatus.filled.rawValue]: {
|
||||
iconComponent: OrderFilledIcon,
|
||||
color: 'var(--color-success)',
|
||||
},
|
||||
[AbacusOrderStatus.cancelled.rawValue]: {
|
||||
iconComponent: OrderCanceledIcon,
|
||||
color: 'var(--color-error)',
|
||||
},
|
||||
[AbacusOrderStatus.canceling.rawValue]: {
|
||||
iconComponent: OrderPendingIcon,
|
||||
color: 'var(--color-error)',
|
||||
},
|
||||
[AbacusOrderStatus.pending.rawValue]: {
|
||||
iconComponent: OrderPendingIcon,
|
||||
color: 'var(--color-text-2)',
|
||||
},
|
||||
[AbacusOrderStatus.untriggered.rawValue]: {
|
||||
iconComponent: OrderPendingIcon,
|
||||
color: 'var(--color-text-2)',
|
||||
},
|
||||
}[status];
|
||||
|
||||
return <$Icon className={className} iconComponent={iconComponent} color={color} />;
|
||||
};
|
||||
|
||||
const $Icon = styled(Icon)<{ color: string }>`
|
||||
color: ${({ color }) => color};
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import { useCallback, useState, useMemo } from 'react';
|
||||
import styled, { type AnyStyledComponent } from 'styled-components';
|
||||
import { Root, Trigger, Content } from '@radix-ui/react-collapsible';
|
||||
import { StatusResponse } from '@0xsquid/sdk';
|
||||
|
||||
import { useInterval, useStringGetter } from '@/hooks';
|
||||
|
||||
import { STRING_KEYS } from '@/constants/localization';
|
||||
import { AlertType } from '@/constants/alerts';
|
||||
|
||||
import { formatSeconds } from '@/lib/timeUtils';
|
||||
|
||||
import { AlertMessage } from '@/components/AlertMessage';
|
||||
import { Output, OutputType } from '@/components/Output';
|
||||
import { WithReceipt } from '@/components/WithReceipt';
|
||||
import { Icon, IconName } from '@/components/Icon';
|
||||
import { TransferStatusSteps } from '@/views/TransferStatusSteps';
|
||||
import { LoadingDots } from '@/components/Loading/LoadingDots';
|
||||
|
||||
import { layoutMixins } from '@/styles/layoutMixins';
|
||||
|
||||
type ElementProps = {
|
||||
type: 'withdrawal' | 'deposit';
|
||||
toAmount?: number;
|
||||
triggeredAt?: number;
|
||||
status?: StatusResponse;
|
||||
};
|
||||
|
||||
export const TransferStatusToast = ({
|
||||
type,
|
||||
toAmount,
|
||||
triggeredAt = Date.now(),
|
||||
status,
|
||||
}: ElementProps) => {
|
||||
const stringGetter = useStringGetter();
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [secondsLeft, setSecondsLeft] = useState<number>(0);
|
||||
|
||||
// @ts-ignore status.errors is not in the type definition but can be returned
|
||||
const error = status?.errors?.length ? status?.errors[0] : status?.error;
|
||||
|
||||
const updateSecondsLeft = useCallback(() => {
|
||||
const fromChainEta = (status?.fromChain?.chainData?.estimatedRouteDuration || 0) * 1000;
|
||||
const toChainEta = (status?.toChain?.chainData?.estimatedRouteDuration || 0) * 1000;
|
||||
setSecondsLeft(Math.floor((triggeredAt + fromChainEta + toChainEta - Date.now()) / 1000));
|
||||
}, [status]);
|
||||
|
||||
useInterval({ callback: updateSecondsLeft });
|
||||
|
||||
if (!status) return <LoadingDots size={3} />;
|
||||
|
||||
const inProgressStatusString =
|
||||
type === 'deposit'
|
||||
? secondsLeft > 0
|
||||
? STRING_KEYS.DEPOSIT_STATUS
|
||||
: STRING_KEYS.DEPOSIT_STATUS_SHORTLY
|
||||
: secondsLeft > 0
|
||||
? STRING_KEYS.WITHDRAW_STATUS
|
||||
: STRING_KEYS.WITHDRAW_STATUS_SHORTLY;
|
||||
|
||||
const statusString =
|
||||
type === 'deposit'
|
||||
? status?.squidTransactionStatus === 'success'
|
||||
? STRING_KEYS.DEPOSIT_COMPLETE
|
||||
: inProgressStatusString
|
||||
: status?.squidTransactionStatus === 'success'
|
||||
? STRING_KEYS.WITHDRAW_COMPLETE
|
||||
: inProgressStatusString;
|
||||
|
||||
return (
|
||||
<Styled.Root open={open} onOpenChange={setOpen}>
|
||||
<WithReceipt
|
||||
hideReceipt={!open}
|
||||
side="bottom"
|
||||
slotReceipt={
|
||||
<Styled.Receipt>
|
||||
<TransferStatusSteps status={status} type={type} />
|
||||
</Styled.Receipt>
|
||||
}
|
||||
>
|
||||
<Styled.BridgingStatus>
|
||||
<Styled.Status>
|
||||
{stringGetter({
|
||||
key: statusString,
|
||||
params: {
|
||||
AMOUNT_USD: <Styled.InlineOutput type={OutputType.Fiat} value={toAmount} />,
|
||||
ESTIMATED_DURATION: (
|
||||
<Styled.InlineOutput
|
||||
type={OutputType.Text}
|
||||
value={formatSeconds(Math.max(secondsLeft || 0, 0))}
|
||||
/>
|
||||
),
|
||||
},
|
||||
})}
|
||||
</Styled.Status>
|
||||
{error && (
|
||||
<AlertMessage type={AlertType.Error}>
|
||||
{stringGetter({
|
||||
key: STRING_KEYS.SOMETHING_WENT_WRONG_WITH_MESSAGE,
|
||||
params: {
|
||||
ERROR_MESSAGE: error.message || stringGetter({ key: STRING_KEYS.UNKNOWN_ERROR }),
|
||||
},
|
||||
})}
|
||||
</AlertMessage>
|
||||
)}
|
||||
<Styled.Trigger>
|
||||
<Styled.TriggerIcon>
|
||||
<Icon iconName={IconName.Caret} />
|
||||
</Styled.TriggerIcon>
|
||||
{stringGetter({ key: open ? STRING_KEYS.HIDE_DETAILS : STRING_KEYS.VIEW_DETAILS })}
|
||||
</Styled.Trigger>
|
||||
</Styled.BridgingStatus>
|
||||
</WithReceipt>
|
||||
</Styled.Root>
|
||||
);
|
||||
};
|
||||
|
||||
const Styled: Record<string, AnyStyledComponent> = {};
|
||||
|
||||
Styled.Root = styled(Root)`
|
||||
margin: 0.5rem -1rem -1rem -1rem;
|
||||
color: var(--color-text-0);
|
||||
font: var(--font-small-book);
|
||||
`;
|
||||
|
||||
Styled.Receipt = styled.div`
|
||||
padding: 0 1rem;
|
||||
`;
|
||||
|
||||
Styled.BridgingStatus = styled.div`
|
||||
${layoutMixins.flexColumn};
|
||||
background-color: var(--color-layer-3);
|
||||
|
||||
gap: 0.5rem;
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
`;
|
||||
|
||||
Styled.Status = styled.div`
|
||||
color: var(--color-text-0);
|
||||
|
||||
font-size: 0.875rem;
|
||||
`;
|
||||
|
||||
Styled.InlineOutput = styled(Output)`
|
||||
display: inline-block;
|
||||
|
||||
color: var(--color-text-1);
|
||||
`;
|
||||
|
||||
Styled.Content = styled(Content)`
|
||||
${layoutMixins.flexColumn};
|
||||
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
`;
|
||||
|
||||
Styled.Step = styled.div`
|
||||
${layoutMixins.row};
|
||||
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
|
||||
Styled.Trigger = styled(Trigger)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
|
||||
color: var(--color-accent);
|
||||
user-select: none;
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
Styled.TriggerIcon = styled.span`
|
||||
width: 0.75em;
|
||||
|
||||
display: inline-flex;
|
||||
transition: rotate 0.3s var(--ease-out-expo);
|
||||
|
||||
${Styled.Trigger}[data-state='open'] & {
|
||||
rotate: -0.5turn;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import styled, { AnyStyledComponent } from 'styled-components';
|
||||
|
||||
import { STRING_KEYS } from '@/constants/localization';
|
||||
import { NotificationType } from '@/constants/notifications';
|
||||
|
||||
import { useStringGetter } from '@/hooks';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
|
||||
import { ComboboxDialogMenu } from '@/components/ComboboxDialogMenu';
|
||||
import { Switch } from '@/components/Switch';
|
||||
|
||||
export const usePreferenceMenu = () => {
|
||||
const stringGetter = useStringGetter();
|
||||
|
||||
// Notifications
|
||||
const { notificationPreferences, setNotificationPreferences } = useNotifications();
|
||||
const [enabledNotifs, setEnabledNotifs] = useState(notificationPreferences);
|
||||
|
||||
const toggleNotifPreference = (type: NotificationType) =>
|
||||
setEnabledNotifs((prev) => ({ ...prev, [type]: !prev[type] }));
|
||||
|
||||
useEffect(() => {
|
||||
setNotificationPreferences(enabledNotifs);
|
||||
}, [enabledNotifs]);
|
||||
|
||||
const notificationSection = useMemo(
|
||||
() => ({
|
||||
group: 'Notifications',
|
||||
groupLabel: stringGetter({ key: STRING_KEYS.NOTIFICATIONS }),
|
||||
items: [
|
||||
{
|
||||
value: NotificationType.AbacusGenerated,
|
||||
label: stringGetter({ key: STRING_KEYS.TRADING }),
|
||||
slotAfter: (
|
||||
<Switch
|
||||
name={NotificationType.AbacusGenerated}
|
||||
checked={enabledNotifs[NotificationType.AbacusGenerated]}
|
||||
onCheckedChange={(enabled: boolean) => null}
|
||||
/>
|
||||
),
|
||||
onSelect: () => toggleNotifPreference(NotificationType.AbacusGenerated),
|
||||
},
|
||||
{
|
||||
value: NotificationType.SquidTransfer,
|
||||
label: stringGetter({ key: STRING_KEYS.TRANSFERS }),
|
||||
slotAfter: (
|
||||
<Switch
|
||||
name={NotificationType.SquidTransfer}
|
||||
checked={enabledNotifs[NotificationType.SquidTransfer]}
|
||||
onCheckedChange={(enabled: boolean) => null}
|
||||
/>
|
||||
),
|
||||
onSelect: () => toggleNotifPreference(NotificationType.SquidTransfer),
|
||||
},
|
||||
],
|
||||
}),
|
||||
[stringGetter, enabledNotifs]
|
||||
);
|
||||
|
||||
return [notificationSection];
|
||||
};
|
||||
|
||||
type ElementProps = {
|
||||
setIsOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const PreferencesDialog = ({ setIsOpen }: ElementProps) => {
|
||||
const stringGetter = useStringGetter();
|
||||
const preferenceItems = usePreferenceMenu();
|
||||
|
||||
return (
|
||||
<Styled.ComboboxDialogMenu
|
||||
isOpen
|
||||
title={stringGetter({ key: STRING_KEYS.PREFERENCES })}
|
||||
items={preferenceItems}
|
||||
setIsOpen={setIsOpen}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Styled: Record<string, AnyStyledComponent> = {};
|
||||
|
||||
Styled.ComboboxDialogMenu = styled(ComboboxDialogMenu)`
|
||||
--dialog-content-paddingBottom: 0.5rem;
|
||||
`;
|
||||
@@ -10,6 +10,7 @@ import { AlertType } from '@/constants/alerts';
|
||||
import { ButtonSize } from '@/constants/buttons';
|
||||
import { STRING_KEYS } from '@/constants/localization';
|
||||
import { ENVIRONMENT_CONFIG_MAP } from '@/constants/networks';
|
||||
import { NotificationStatus } from '@/constants/notifications';
|
||||
import { NumberSign } from '@/constants/numbers';
|
||||
import type { EvmAddress } from '@/constants/wallets';
|
||||
|
||||
@@ -244,6 +245,7 @@ export const DepositForm = ({ onDeposit, onError }: DepositFormProps) => {
|
||||
fromChainId: chainIdStr || undefined,
|
||||
toAmount: summary?.usdcSize || undefined,
|
||||
triggeredAt: Date.now(),
|
||||
notificationStatus: NotificationStatus.Triggered,
|
||||
});
|
||||
abacusStateManager.clearTransferInputValues();
|
||||
setFromAmount('');
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AlertType } from '@/constants/alerts';
|
||||
import { ButtonSize } from '@/constants/buttons';
|
||||
import { STRING_KEYS } from '@/constants/localization';
|
||||
import { ENVIRONMENT_CONFIG_MAP } from '@/constants/networks';
|
||||
import { NotificationStatus } from '@/constants/notifications';
|
||||
import { NumberSign } from '@/constants/numbers';
|
||||
|
||||
import {
|
||||
@@ -171,6 +172,7 @@ export const WithdrawForm = () => {
|
||||
toChainId: chainIdStr || undefined,
|
||||
toAmount: debouncedAmountBN.toNumber(),
|
||||
triggeredAt: Date.now(),
|
||||
notificationStatus: NotificationStatus.Triggered,
|
||||
});
|
||||
abacusStateManager.clearTransferInputValues();
|
||||
setWithdrawAmount('');
|
||||
|
||||
@@ -184,6 +184,12 @@ export const AccountMenu = () => {
|
||||
},
|
||||
].filter(isTruthy)
|
||||
: []),
|
||||
{
|
||||
value: 'Preferences',
|
||||
icon: <Icon iconName={IconName.Gear} />,
|
||||
label: stringGetter({ key: STRING_KEYS.PREFERENCES }),
|
||||
onSelect: () => dispatch(openDialog({ type: DialogTypes.Preferences })),
|
||||
},
|
||||
{
|
||||
value: 'Disconnect',
|
||||
icon: <Icon iconName={IconName.BoxClose} />,
|
||||
|
||||
@@ -3,18 +3,17 @@ import styled from 'styled-components';
|
||||
import { layoutMixins } from '@/styles/layoutMixins';
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
import { ButtonAction, ButtonSize } from '@/constants/buttons';
|
||||
import { STRING_KEYS } from '@/constants/localization';
|
||||
import { type Notification, NotificationStatus } from '@/constants/notifications';
|
||||
|
||||
import { useBreakpoints } from '@/hooks';
|
||||
import { useBreakpoints, useStringGetter } from '@/hooks';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import { CloseIcon } from '@/icons';
|
||||
|
||||
import { Button } from '@/components/Button';
|
||||
import { ButtonAction, ButtonSize } from '@/constants/buttons';
|
||||
import { ComboboxDialogMenu } from '@/components/ComboboxDialogMenu';
|
||||
import { DialogPlacement } from '@/components/Dialog';
|
||||
import { Output, OutputType } from '@/components/Output';
|
||||
import { IconButton } from '@/components/IconButton';
|
||||
import { Notification as NotificationCard } from '@/components/Notification';
|
||||
import { Toolbar } from '@/components/Toolbar';
|
||||
|
||||
type ElementProps = {
|
||||
@@ -27,13 +26,14 @@ export const NotificationsMenu = ({
|
||||
placement = DialogPlacement.Sidebar,
|
||||
}: ElementProps) => {
|
||||
const { isTablet } = useBreakpoints();
|
||||
const stringGetter = useStringGetter();
|
||||
|
||||
const {
|
||||
notifications,
|
||||
getDisplayData,
|
||||
getKey,
|
||||
|
||||
markSeen,
|
||||
markCleared,
|
||||
markAllCleared,
|
||||
|
||||
onNotificationAction,
|
||||
@@ -65,14 +65,12 @@ export const NotificationsMenu = ({
|
||||
const items: Parameters<typeof ComboboxDialogMenu>[0]['items'] = useMemo(
|
||||
() =>
|
||||
(Object.entries(notificationsByStatus) as unknown as [NotificationStatus, Notification[]][])
|
||||
// .filter(([status]) => status !== NotificationStatus.Cleared)
|
||||
.filter(([status]) => status < NotificationStatus.Cleared)
|
||||
.map(([status, notifications]) => ({
|
||||
group: status,
|
||||
groupLabel: {
|
||||
[NotificationStatus.Triggered]: 'New',
|
||||
// [NotificationStatus.Updated]: 'Updates',
|
||||
[NotificationStatus.Triggered]: stringGetter({ key: STRING_KEYS.NEW }),
|
||||
[NotificationStatus.Seen]: 'Seen',
|
||||
[NotificationStatus.Cleared]: 'Archived',
|
||||
}[status as number],
|
||||
|
||||
items: notifications
|
||||
@@ -83,59 +81,40 @@ export const NotificationsMenu = ({
|
||||
)
|
||||
.map((notification) => ({
|
||||
notification,
|
||||
key: getKey(notification),
|
||||
displayData: getDisplayData(notification),
|
||||
key: getKey?.(notification),
|
||||
displayData: getDisplayData?.(notification),
|
||||
}))
|
||||
.map(({ notification, key, displayData }) => ({
|
||||
value: key,
|
||||
label: displayData.title ?? '',
|
||||
description: displayData.customMenuContent || displayData.description,
|
||||
slotBefore: !displayData.customMenuContent && displayData.icon,
|
||||
slotAfter: !displayData.customMenuContent && (
|
||||
<>
|
||||
<$Output
|
||||
type={OutputType.RelativeTime}
|
||||
value={
|
||||
notification.timestamps[NotificationStatus.Updated] ||
|
||||
notification.timestamps[NotificationStatus.Triggered]
|
||||
}
|
||||
/>
|
||||
|
||||
{notification.status < NotificationStatus.Seen ? <$UnreadIndicator /> : null}
|
||||
|
||||
{notification.status < NotificationStatus.Cleared ? (
|
||||
<$IconButton
|
||||
iconComponent={CloseIcon}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
/*if (notification.status < NotificationStatus.Seen) {
|
||||
markSeen(notification);
|
||||
} else*/ if (notification.status < NotificationStatus.Cleared) {
|
||||
markCleared(notification);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
label: displayData.title,
|
||||
description: displayData.body,
|
||||
slotCustomContent: displayData.renderCustomBody?.({ notification }) ?? (
|
||||
<NotificationCard
|
||||
isToast={false}
|
||||
slotIcon={displayData.icon}
|
||||
slotTitle={displayData.title}
|
||||
slotDescription={displayData.body}
|
||||
notification={notification}
|
||||
/>
|
||||
),
|
||||
disabled: notification.status === NotificationStatus.Cleared,
|
||||
onSelect: () => {
|
||||
onNotificationAction(notification);
|
||||
markSeen(notification);
|
||||
onNotificationAction?.(notification);
|
||||
markSeen?.(notification);
|
||||
},
|
||||
})),
|
||||
}))
|
||||
.filter(({ items }) => items.length),
|
||||
[notificationsByStatus, getDisplayData, onNotificationAction, markSeen]
|
||||
[notificationsByStatus, getDisplayData, onNotificationAction, markSeen, stringGetter]
|
||||
);
|
||||
|
||||
return (
|
||||
<ComboboxDialogMenu
|
||||
<$ComboboxDialogMenu
|
||||
withItemBorders
|
||||
isOpen={isMenuOpen || placement === DialogPlacement.Inline}
|
||||
setIsOpen={setIsMenuOpen}
|
||||
items={items}
|
||||
title="Notifications"
|
||||
title={stringGetter({ key: STRING_KEYS.NOTIFICATIONS })}
|
||||
slotTrigger={
|
||||
<$TriggerContainer>
|
||||
{slotTrigger}
|
||||
@@ -150,11 +129,11 @@ export const NotificationsMenu = ({
|
||||
state={{ isLoading: isEnablingPush }}
|
||||
onClick={enablePush}
|
||||
>
|
||||
Enable Push Notifications
|
||||
{stringGetter({ key: STRING_KEYS.ENABLE_PUSH_NOTIFICATIONS })}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size={ButtonSize.Small} action={ButtonAction.Secondary} onClick={disablePush}>
|
||||
Disable Push Notifications
|
||||
{stringGetter({ key: STRING_KEYS.DISABLE_PUSH_NOTIFICATIONS })}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -168,7 +147,7 @@ export const NotificationsMenu = ({
|
||||
),
|
||||
}}
|
||||
>
|
||||
Clear All
|
||||
{stringGetter({ key: STRING_KEYS.CLEAR_ALL })}
|
||||
</Button>
|
||||
</$FooterToolbar>
|
||||
}
|
||||
@@ -178,6 +157,14 @@ export const NotificationsMenu = ({
|
||||
);
|
||||
};
|
||||
|
||||
const $ComboboxDialogMenu = styled(ComboboxDialogMenu)`
|
||||
--comboboxDialogMenu-item-padding: 0;
|
||||
|
||||
[cmdk-list] > [cmdk-list-sizer] > * {
|
||||
box-shadow: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const $UnreadIndicator = styled.div`
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
@@ -198,16 +185,6 @@ const $TriggerUnreadIndicator = styled($UnreadIndicator)`
|
||||
top: -0.325rem;
|
||||
`;
|
||||
|
||||
const $Output = styled(Output)`
|
||||
color: var(--color-text-0);
|
||||
`;
|
||||
|
||||
const $IconButton = styled(IconButton)`
|
||||
--button-border: none;
|
||||
--button-textColor: var(--color-text-0);
|
||||
--button-hover-textColor: var(--color-text-1);
|
||||
`;
|
||||
|
||||
const $FooterToolbar = styled(Toolbar)`
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import styled, { type AnyStyledComponent } from 'styled-components';
|
||||
import { OrderSide } from '@dydxprotocol/v4-client-js';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
|
||||
import {
|
||||
AbacusOrderStatus,
|
||||
AbacusOrderType,
|
||||
KotlinIrEnumValues,
|
||||
ORDER_STATUS_STRINGS,
|
||||
TRADE_TYPES,
|
||||
} from '@/constants/abacus';
|
||||
|
||||
import { STRING_KEYS } from '@/constants/localization';
|
||||
import { TRADE_TYPE_STRINGS, TradeTypes } from '@/constants/trade';
|
||||
import { useStringGetter } from '@/hooks';
|
||||
import { layoutMixins } from '@/styles/layoutMixins';
|
||||
|
||||
import { AssetIcon } from '@/components/AssetIcon';
|
||||
import { Details } from '@/components/Details';
|
||||
import { Notification, NotificationProps } from '@/components/Notification';
|
||||
import { OrderSideTag } from '@/components/OrderSideTag';
|
||||
import { OrderStatusIcon } from '@/views/OrderStatusIcon';
|
||||
import { Output, OutputType } from '@/components/Output';
|
||||
|
||||
import { getMarketData } from '@/state/perpetualsSelectors';
|
||||
|
||||
type ElementProps = {
|
||||
data: {
|
||||
AMOUNT: string;
|
||||
ASSET: string;
|
||||
AVERAGE_PRICE: string;
|
||||
FILLED_AMOUNT: string;
|
||||
MARKET: string;
|
||||
ORDER_STATUS: string;
|
||||
ORDER_TYPE: string;
|
||||
ORDER_TYPE_TEXT: string;
|
||||
PRICE: string;
|
||||
SIDE: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TradeNotificationProps = NotificationProps & ElementProps;
|
||||
|
||||
export const TradeNotification = ({ isToast, data, notification }: TradeNotificationProps) => {
|
||||
const stringGetter = useStringGetter();
|
||||
const { AVERAGE_PRICE, FILLED_AMOUNT, MARKET, ORDER_TYPE, ORDER_STATUS, SIDE } = data;
|
||||
const marketData = useSelector(getMarketData(MARKET), shallowEqual);
|
||||
const { assetId } = marketData ?? {};
|
||||
const orderType = ORDER_TYPE as KotlinIrEnumValues<typeof AbacusOrderType>;
|
||||
const tradeType = TRADE_TYPES[orderType];
|
||||
const titleKey = tradeType && TRADE_TYPE_STRINGS[tradeType]?.tradeTypeKey;
|
||||
const orderStatus = ORDER_STATUS as KotlinIrEnumValues<typeof AbacusOrderStatus>;
|
||||
|
||||
return (
|
||||
<Notification
|
||||
isToast={isToast}
|
||||
notification={notification}
|
||||
slotIcon={<AssetIcon symbol={assetId} />}
|
||||
slotTitle={titleKey && stringGetter({ key: titleKey })}
|
||||
slotTitleRight={
|
||||
<Styled.OrderStatus>
|
||||
{stringGetter({ key: ORDER_STATUS_STRINGS[orderStatus] })}
|
||||
<Styled.OrderStatusIcon status={orderStatus} totalFilled={parseFloat(FILLED_AMOUNT)} />
|
||||
</Styled.OrderStatus>
|
||||
}
|
||||
slotCustomContent={
|
||||
<Styled.Details
|
||||
items={[
|
||||
{
|
||||
key: 'size',
|
||||
label: (
|
||||
<Styled.Label>
|
||||
{stringGetter({ key: STRING_KEYS.SIZE })}
|
||||
<OrderSideTag
|
||||
orderSide={SIDE === STRING_KEYS.BUY ? OrderSide.BUY : OrderSide.SELL}
|
||||
/>
|
||||
</Styled.Label>
|
||||
),
|
||||
value: (
|
||||
<Output type={OutputType.Asset} value={FILLED_AMOUNT} tag={marketData?.assetId} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'price',
|
||||
label: stringGetter({ key: STRING_KEYS.PRICE }),
|
||||
value:
|
||||
ORDER_TYPE === TradeTypes.MARKET ? (
|
||||
<span>{stringGetter({ key: STRING_KEYS.MARKET_ORDER_SHORT })}</span>
|
||||
) : (
|
||||
<Output
|
||||
type={OutputType.Fiat}
|
||||
value={AVERAGE_PRICE}
|
||||
fractionDigits={marketData?.configs?.displayTickSizeDecimals}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Styled: Record<string, AnyStyledComponent> = {};
|
||||
|
||||
Styled.Label = styled.span`
|
||||
${layoutMixins.row}
|
||||
gap: 0.5ch;
|
||||
`;
|
||||
|
||||
Styled.OrderStatus = styled(Styled.Label)`
|
||||
color: var(--color-text-0);
|
||||
font: var(--font-small-book);
|
||||
`;
|
||||
|
||||
Styled.OrderStatusIcon = styled(OrderStatusIcon)`
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
`;
|
||||
|
||||
Styled.Details = styled(Details)`
|
||||
--details-item-height: 1.5rem;
|
||||
|
||||
dd {
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
`;
|
||||
+7
-3
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import styled, { css, keyframes, type AnyStyledComponent } from 'styled-components';
|
||||
import styled, { css, type AnyStyledComponent } from 'styled-components';
|
||||
import { StatusResponse } from '@0xsquid/sdk';
|
||||
|
||||
import { useStringGetter, useSelectedNetwork, useURLConfigs } from '@/hooks';
|
||||
@@ -18,6 +18,10 @@ type ElementProps = {
|
||||
type: 'withdrawal' | 'deposit';
|
||||
};
|
||||
|
||||
type StyleProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
enum TransferStatusStep {
|
||||
FromChain,
|
||||
Bridge,
|
||||
@@ -25,7 +29,7 @@ enum TransferStatusStep {
|
||||
Complete,
|
||||
}
|
||||
|
||||
export const TransferStatusSteps = ({ status, type }: ElementProps) => {
|
||||
export const TransferStatusSteps = ({ className, status, type }: ElementProps & StyleProps) => {
|
||||
const stringGetter = useStringGetter();
|
||||
const { selectedNetwork } = useSelectedNetwork();
|
||||
const { mintscan: mintscanTxUrl } = useURLConfigs();
|
||||
@@ -101,7 +105,7 @@ export const TransferStatusSteps = ({ status, type }: ElementProps) => {
|
||||
if (!status) return <LoadingDots size={3} />;
|
||||
|
||||
return (
|
||||
<Styled.BridgingStatus>
|
||||
<Styled.BridgingStatus className={className}>
|
||||
{steps.map((step) => (
|
||||
<Styled.Step key={step.step}>
|
||||
<Styled.row>
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useCallback, useState, useMemo, MouseEvent } from 'react';
|
||||
import styled, { css, type AnyStyledComponent } from 'styled-components';
|
||||
|
||||
import { useInterval, useStringGetter } from '@/hooks';
|
||||
|
||||
import { AlertType } from '@/constants/alerts';
|
||||
import { STRING_KEYS } from '@/constants/localization';
|
||||
import { TransferNotifcation } from '@/constants/notifications';
|
||||
|
||||
import { formatSeconds } from '@/lib/timeUtils';
|
||||
|
||||
import { AlertMessage } from '@/components/AlertMessage';
|
||||
import { Collapsible } from '@/components/Collapsible';
|
||||
import { Icon, IconName } from '@/components/Icon';
|
||||
import { LoadingDots } from '@/components/Loading/LoadingDots';
|
||||
import { Notification, NotificationProps } from '@/components/Notification';
|
||||
import { Output, OutputType } from '@/components/Output';
|
||||
import { WithReceipt } from '@/components/WithReceipt';
|
||||
|
||||
import { layoutMixins } from '@/styles/layoutMixins';
|
||||
|
||||
import { TransferStatusSteps } from './TransferStatusSteps';
|
||||
|
||||
type ElementProps = {
|
||||
type: 'withdrawal' | 'deposit';
|
||||
transfer: TransferNotifcation;
|
||||
triggeredAt?: number;
|
||||
};
|
||||
|
||||
export const TransferStatusNotification = ({
|
||||
isToast,
|
||||
notification,
|
||||
slotIcon,
|
||||
slotTitle,
|
||||
slotDescription,
|
||||
transfer,
|
||||
type,
|
||||
triggeredAt = Date.now(),
|
||||
}: ElementProps & NotificationProps) => {
|
||||
const stringGetter = useStringGetter();
|
||||
const [open, setOpen] = useState<boolean>(false);
|
||||
const [secondsLeft, setSecondsLeft] = useState<number>(0);
|
||||
const { fromChainId, status, txHash, toAmount } = transfer;
|
||||
|
||||
// @ts-ignore status.errors is not in the type definition but can be returned
|
||||
const error = status?.errors?.length ? status?.errors[0] : status?.error;
|
||||
|
||||
const updateSecondsLeft = useCallback(() => {
|
||||
const fromChainEta = (status?.fromChain?.chainData?.estimatedRouteDuration || 0) * 1000;
|
||||
const toChainEta = (status?.toChain?.chainData?.estimatedRouteDuration || 0) * 1000;
|
||||
setSecondsLeft(Math.floor((triggeredAt + fromChainEta + toChainEta - Date.now()) / 1000));
|
||||
}, [status]);
|
||||
|
||||
useInterval({ callback: updateSecondsLeft });
|
||||
|
||||
const inProgressStatusString =
|
||||
type === 'deposit'
|
||||
? secondsLeft > 0
|
||||
? STRING_KEYS.DEPOSIT_STATUS
|
||||
: STRING_KEYS.DEPOSIT_STATUS_SHORTLY
|
||||
: secondsLeft > 0
|
||||
? STRING_KEYS.WITHDRAW_STATUS
|
||||
: STRING_KEYS.WITHDRAW_STATUS_SHORTLY;
|
||||
|
||||
const statusString =
|
||||
type === 'deposit'
|
||||
? status?.squidTransactionStatus === 'success'
|
||||
? STRING_KEYS.DEPOSIT_COMPLETE
|
||||
: inProgressStatusString
|
||||
: status?.squidTransactionStatus === 'success'
|
||||
? STRING_KEYS.WITHDRAW_COMPLETE
|
||||
: inProgressStatusString;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<Styled.Status>
|
||||
{stringGetter({
|
||||
key: statusString,
|
||||
params: {
|
||||
AMOUNT_USD: <Styled.InlineOutput type={OutputType.Fiat} value={toAmount} />,
|
||||
ESTIMATED_DURATION: (
|
||||
<Styled.InlineOutput
|
||||
type={OutputType.Text}
|
||||
value={formatSeconds(Math.max(secondsLeft || 0, 0))}
|
||||
/>
|
||||
),
|
||||
},
|
||||
})}
|
||||
</Styled.Status>
|
||||
{error && (
|
||||
<AlertMessage type={AlertType.Error}>
|
||||
{stringGetter({
|
||||
key: STRING_KEYS.SOMETHING_WENT_WRONG_WITH_MESSAGE,
|
||||
params: {
|
||||
ERROR_MESSAGE: error.message || stringGetter({ key: STRING_KEYS.UNKNOWN_ERROR }),
|
||||
},
|
||||
})}
|
||||
</AlertMessage>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const transferNotif = (
|
||||
<Notification
|
||||
isToast={isToast}
|
||||
notification={notification}
|
||||
slotIcon={isToast && slotIcon}
|
||||
slotTitle={slotTitle}
|
||||
slotCustomContent={
|
||||
!status ? (
|
||||
<LoadingDots size={3} />
|
||||
) : (
|
||||
<Styled.BridgingStatus>
|
||||
{content}
|
||||
{!isToast && status?.squidTransactionStatus !== 'success' && (
|
||||
<Styled.TransferStatusSteps status={status} type={type} />
|
||||
)}
|
||||
</Styled.BridgingStatus>
|
||||
)
|
||||
}
|
||||
slotAction={
|
||||
isToast &&
|
||||
status &&
|
||||
!error && (
|
||||
<Styled.Trigger
|
||||
isOpen={open}
|
||||
onClick={(e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setOpen(!open);
|
||||
}}
|
||||
>
|
||||
<Icon iconName={IconName.Caret} />{' '}
|
||||
{stringGetter({
|
||||
key: open ? STRING_KEYS.HIDE_DETAILS : STRING_KEYS.VIEW_DETAILS,
|
||||
})}
|
||||
</Styled.Trigger>
|
||||
)
|
||||
}
|
||||
withClose={false}
|
||||
/>
|
||||
);
|
||||
|
||||
return isToast ? (
|
||||
<WithReceipt
|
||||
hideReceipt={!open}
|
||||
side="bottom"
|
||||
slotReceipt={
|
||||
<Collapsible open={open} onOpenChange={setOpen} label="" withTrigger={false}>
|
||||
<Styled.Receipt>
|
||||
<TransferStatusSteps status={status} type={type} />
|
||||
</Styled.Receipt>
|
||||
</Collapsible>
|
||||
}
|
||||
>
|
||||
{transferNotif}
|
||||
</WithReceipt>
|
||||
) : (
|
||||
transferNotif
|
||||
);
|
||||
};
|
||||
|
||||
const Styled: Record<string, AnyStyledComponent> = {};
|
||||
|
||||
Styled.BridgingStatus = styled.div`
|
||||
${layoutMixins.flexColumn};
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
|
||||
Styled.Status = styled.div<{ withMarginBottom?: boolean }>`
|
||||
color: var(--color-text-0);
|
||||
font-size: 0.875rem;
|
||||
|
||||
${({ withMarginBottom }) =>
|
||||
withMarginBottom &&
|
||||
css`
|
||||
margin-bottom: 0.5rem;
|
||||
`}
|
||||
`;
|
||||
|
||||
Styled.InlineOutput = styled(Output)`
|
||||
display: inline-block;
|
||||
|
||||
color: var(--color-text-1);
|
||||
`;
|
||||
|
||||
Styled.Step = styled.div`
|
||||
${layoutMixins.row};
|
||||
|
||||
gap: 0.5rem;
|
||||
`;
|
||||
|
||||
Styled.TransferStatusSteps = styled(TransferStatusSteps)`
|
||||
padding: 0.5rem 0 0;
|
||||
`;
|
||||
|
||||
Styled.Trigger = styled.button<{ isOpen?: boolean }>`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
|
||||
color: var(--color-accent);
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
svg {
|
||||
transition: rotate 0.3s var(--ease-out-expo);
|
||||
}
|
||||
|
||||
${({ isOpen }) =>
|
||||
isOpen &&
|
||||
css`
|
||||
& > svg {
|
||||
rotate: -0.5turn;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
Styled.Receipt = styled.div`
|
||||
padding: 0 1rem;
|
||||
`;
|
||||
Reference in New Issue
Block a user