diff --git a/package-lock.json b/package-lock.json index e26e1a947..14963b71a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,14 +14,14 @@ "@invariant-labs/locker-eclipse-sdk": "^0.0.20", "@invariant-labs/points-sdk": "^0.0.3", "@invariant-labs/sdk-eclipse": "^0.0.63", - "@irys/web-upload": "*", + "@irys/web-upload": "^0.0.14", "@irys/web-upload-solana": "^0.1.7", "@metaplex-foundation/js": "^0.20.1", "@metaplex-foundation/mpl-token-metadata": "^2.13.0", "@mui/icons-material": "^5.15.15", "@mui/material": "^5.15.15", "@mui/x-charts": "^7.22.3", - "@nightlylabs/wallet-selector-solana": "^0.3.14", + "@nightlylabs/wallet-selector-solana": "^0.3.13", "@nivo/bar": "^0.87.0", "@nivo/line": "^0.86.0", "@project-serum/sol-wallet-adapter": "^0.2.5", @@ -2485,8 +2485,7 @@ "node_modules/@emotion/weak-memoize": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", - "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", - "license": "MIT" + "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" }, "node_modules/@esbuild/linux-x64": { "version": "0.25.0", @@ -2504,6 +2503,134 @@ "node": ">=18" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", @@ -3395,7 +3522,7 @@ "typescript": "^5.4.5" } }, - "node_modules/@invariant-labs/sdk-eclipse": { + "node_modules/@invariant-labs/points-sdk/node_modules/@invariant-labs/sdk-eclipse": { "version": "0.0.63", "resolved": "https://registry.npmjs.org/@invariant-labs/sdk-eclipse/-/sdk-eclipse-0.0.63.tgz", "integrity": "sha512-8ZrrK7c0PbizK16BZWtHpZ8clZa+yu9Gdc5CU985XMOQn/o4djuNgzOT24FPy5EA5PnNhvSbDZHABXmbuk+/Iw==", @@ -3405,6 +3532,16 @@ "chai": "^4.3.0" } }, + "node_modules/@invariant-labs/sdk-eclipse": { + "version": "0.0.85", + "resolved": "https://registry.npmjs.org/@invariant-labs/sdk-eclipse/-/sdk-eclipse-0.0.85.tgz", + "integrity": "sha512-l5REg0w+Xp3WjIjrk+/Xc6sBZfZVbIwnm0bKP2qRKc4ztsoJyHBel0SkXhET3WJYCtPk5RTwCkors/qFdVdxiA==", + "dependencies": { + "@coral-xyz/anchor": "^0.29.0", + "@solana/spl-token-registry": "^0.2.4484", + "chai": "^4.3.0" + } + }, "node_modules/@irys/arweave": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/@irys/arweave/-/arweave-0.0.2.tgz", @@ -6448,10 +6585,9 @@ "license": "MIT" }, "node_modules/@reduxjs/toolkit": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.5.1.tgz", - "integrity": "sha512-UHhy3p0oUpdhnSxyDjaRDYaw8Xra75UiLbCiRozVPHjfDwNYkh0TsVm/1OmTW8Md+iDAJmYPWUKMvsMc2GtpNg==", - "license": "MIT", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.5.0.tgz", + "integrity": "sha512-awNe2oTodsZ6LmRqmkFhtb/KH03hUhxOamEQy411m3Njj3BbFvoBovxo4Q1cBWnV1ErprVj9MlF0UPXkng0eyg==", "dependencies": { "immer": "^10.0.3", "redux": "^5.0.1", @@ -6543,6 +6679,188 @@ } } }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.8.tgz", + "integrity": "sha512-q217OSE8DTp8AFHuNHXo0Y86e1wtlfVrXiAlwkIvGRQv9zbc6mE3sjIVfwI8sYUyNxwOg0j/Vm1RKM04JcWLJw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.8.tgz", + "integrity": "sha512-Gigjz7mNWaOL9wCggvoK3jEIUUbGul656opstjaUSGC3eT0BM7PofdAJaBfPFWWkXNVAXbaQtC99OCg4sJv70Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.8.tgz", + "integrity": "sha512-02rVdZ5tgdUNRxIUrFdcMBZQoaPMrxtwSb+/hOfBdqkatYHR3lZ2A2EGyHq2sGOd0Owk80oV3snlDASC24He3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.8.tgz", + "integrity": "sha512-qIP/elwR/tq/dYRx3lgwK31jkZvMiD6qUtOycLhTzCvrjbZ3LjQnEM9rNhSGpbLXVJYQ3rq39A6Re0h9tU2ynw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.8.tgz", + "integrity": "sha512-IQNVXL9iY6NniYbTaOKdrlVP3XIqazBgJOVkddzJlqnCpRi/yAeSOa8PLcECFSQochzqApIOE1GHNu3pCz+BDA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.8.tgz", + "integrity": "sha512-TYXcHghgnCqYFiE3FT5QwXtOZqDj5GmaFNTNt3jNC+vh22dc/ukG2cG+pi75QO4kACohZzidsq7yKTKwq/Jq7Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.8.tgz", + "integrity": "sha512-A4iphFGNkWRd+5m3VIGuqHnG3MVnqKe7Al57u9mwgbyZ2/xF9Jio72MaY7xxh+Y87VAHmGQr73qoKL9HPbXj1g==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.8.tgz", + "integrity": "sha512-S0lqKLfTm5u+QTxlFiAnb2J/2dgQqRy/XvziPtDd1rKZFXHTyYLoVL58M/XFwDI01AQCDIevGLbQrMAtdyanpA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.8.tgz", + "integrity": "sha512-jpz9YOuPiSkL4G4pqKrus0pn9aYwpImGkosRKwNi+sJSkz+WU3anZe6hi73StLOQdfXYXC7hUfsQlTnjMd3s1A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.8.tgz", + "integrity": "sha512-KdSfaROOUJXgTVxJNAZ3KwkRc5nggDk+06P6lgi1HLv1hskgvxHUKZ4xtwHkVYJ1Rep4GNo+uEfycCRRxht7+Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.8.tgz", + "integrity": "sha512-NyF4gcxwkMFRjgXBM6g2lkT58OWztZvw5KkV2K0qqSnUEqCVcqdh2jN4gQrTn/YUpAcNKyFHfoOZEer9nwo6uQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.8.tgz", + "integrity": "sha512-LMJc999GkhGvktHU85zNTDImZVUCJ1z/MbAJTnviiWmmjyckP5aQsHtcujMjpNdMZPT2rQEDBlJfubhs3jsMfw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.8.tgz", + "integrity": "sha512-xAQCAHPj8nJq1PI3z8CIZzXuXCstquz7cIOL73HHdXiRcKk8Ywwqtx2wrIy23EcTn4aZ2fLJNBB8d0tQENPCmw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.8.tgz", + "integrity": "sha512-DdePVk1NDEuc3fOe3dPPTb+rjMtuFw89gw6gVWxQFAuEqqSdDKnrwzZHrUYdac7A7dXl9Q2Vflxpme15gUWQFA==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.34.8", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.8.tgz", @@ -6569,6 +6887,45 @@ "linux" ] }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.8.tgz", + "integrity": "sha512-YHYsgzZgFJzTRbth4h7Or0m5O74Yda+hLin0irAIobkLQFRQd1qWmnoVfwmKm9TXIZVAD0nZ+GEb2ICicLyCnQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.8.tgz", + "integrity": "sha512-r3NRQrXkHr4uWy5TOjTpTYojR9XmF0j/RYgKCef+Ag46FWUTltm5ziticv8LdNsDMehjJ543x/+TJAek/xBA2w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.34.8", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.8.tgz", + "integrity": "sha512-U0FaE5O1BCpZSeE6gBl3c5ObhePQSfk9vDRToMmTkbhCOgW4jqvtS5LGyQ76L1fH8sM0keRp4uDTsbjiUyjk0g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@scure/base": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.4.tgz", @@ -7788,14 +8145,14 @@ } }, "node_modules/@swc/core": { - "version": "1.10.18", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.10.18.tgz", - "integrity": "sha512-IUWKD6uQYGRy8w2X9EZrtYg1O3SCijlHbCXzMaHQYc1X7yjijQh4H3IVL9ssZZyVp2ZDfQZu4bD5DWxxvpyjvg==", + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.10.4.tgz", + "integrity": "sha512-ut3zfiTLORMxhr6y/GBxkHmzcGuVpwJYX4qyXWuBKkpw/0g0S5iO1/wW7RnLnZbAi8wS/n0atRZoaZlXWBkeJg==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.17" + "@swc/types": "^0.1.18" }, "engines": { "node": ">=10" @@ -7805,16 +8162,16 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.10.18", - "@swc/core-darwin-x64": "1.10.18", - "@swc/core-linux-arm-gnueabihf": "1.10.18", - "@swc/core-linux-arm64-gnu": "1.10.18", - "@swc/core-linux-arm64-musl": "1.10.18", - "@swc/core-linux-x64-gnu": "1.10.18", - "@swc/core-linux-x64-musl": "1.10.18", - "@swc/core-win32-arm64-msvc": "1.10.18", - "@swc/core-win32-ia32-msvc": "1.10.18", - "@swc/core-win32-x64-msvc": "1.10.18" + "@swc/core-darwin-arm64": "1.10.4", + "@swc/core-darwin-x64": "1.10.4", + "@swc/core-linux-arm-gnueabihf": "1.10.4", + "@swc/core-linux-arm64-gnu": "1.10.4", + "@swc/core-linux-arm64-musl": "1.10.4", + "@swc/core-linux-x64-gnu": "1.10.4", + "@swc/core-linux-x64-musl": "1.10.4", + "@swc/core-win32-arm64-msvc": "1.10.4", + "@swc/core-win32-ia32-msvc": "1.10.4", + "@swc/core-win32-x64-msvc": "1.10.4" }, "peerDependencies": { "@swc/helpers": "*" @@ -7825,10 +8182,90 @@ } } }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-bJbqZ51JghEZ8WaFetofkfkS3MWsS/V3vDvY+0r+SlLeocZwf8q8/GqcafnElHcU+zLV6yTi13fJwUce6ULiUQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.1.tgz", + "integrity": "sha512-9GGEoN0uxkLg3KocOVzMfe9c9/DxESXclsL/U2xVLa3pTFB5YnXhiCP5YBT/3Q7nSGLD+R2ALqkNlDoueUjvPw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-Lt7l/l0nfSTUzsWcVY3dtOPl5RtgCJ+Ya8IG4Aa3l6c7kLc6Sx4JpylpEIY9yhGidDy/uQ8KUg5kqUPtUrXrvQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-oe826cfuGukctTSpDjk7RJRDEJihQMAzvO5tdWK0wcy+zvMPFyH5Fg6cW0X4ST3M7fcV91/1T/iuiiD2SVamYw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-ABb4pnYeQp/JBJS5Qd2apTwOzpzrTebQFUiFjk0WgTKIr9T6SL3tLXMjgvbSXIath+1HnbCKFUwDXNQhgGFFTg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.10.18", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.10.18.tgz", - "integrity": "sha512-vTNmyRBVP+sZca+vtwygYPGTNudTU6Gl6XhaZZ7cEUTBr8xvSTgEmYXoK/2uzyXpaTUI4Bmtp1x81cGN0mMoLQ==", + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.10.4.tgz", + "integrity": "sha512-qJXh9D6Kf5xSdGWPINpLGixAbB5JX8JcbEJpRamhlDBoOcQC79dYfOMEIxWPhTS1DGLyFakAx2FX/b2VmQmj0g==", "cpu": [ "x64" ], @@ -7842,16 +8279,64 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.10.18", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.10.18.tgz", - "integrity": "sha512-1TZPReKhFCeX776XaT6wegknfg+g3zODve+r4oslFHI+g7cInfWlxoGNDS3niPKyuafgCdOjme2g3OF+zzxfsQ==", + "version": "1.10.4", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.10.4.tgz", + "integrity": "sha512-A76lIAeyQnHCVt0RL/pG+0er8Qk9+acGJqSZOZm67Ve3B0oqMd871kPtaHBM0BW3OZAhoILgfHW3Op9Q3mx3Cw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-H8Q78GwaKnCL4isHx8JRTRi6vUU6iMLbpegS2jzWWC1On7EePhkLx2eR8nEsaRIQB6rc3WqdIj74OgOpNoPi7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-Rx7cZ0OvqMb16fgmUSlPWQbH1+X355IDJhVQpUlpL+ezD/kkWmJix+4u2GVE/LHrfbdyZ4sjjIzSsCQxJV05Mw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-6bEEC/XU1lwYzUXY7BXj3nhe7iBF9+i9dVo+hbiVxXZMrD0LUd+7urOBM3NtVnDsUaR6Ge/g7aR+OfpgYscKOg==", "cpu": [ "x64" ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ - "linux" + "win32" ], "engines": { "node": ">=10" @@ -7876,7 +8361,6 @@ "version": "0.1.17", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.17.tgz", "integrity": "sha512-V5gRru+aD8YVyCOMAjMpWR1Ui577DD5KSJsHP8RAxopAH22jFz6GZd/qxqjO6MJHQhcsjvjOFXyDhyLQUnMveQ==", - "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3" } @@ -11688,10 +12172,9 @@ "peer": true }, "node_modules/electron-to-chromium": { - "version": "1.5.102", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.102.tgz", - "integrity": "sha512-eHhqaja8tE/FNpIiBrvBjFV/SSKpyWHLvxuR9dPTdo+3V9ppdLmFB7ZZQ98qNovcngPLYIz0oOBF9P0FfZef5Q==", - "license": "ISC" + "version": "1.5.76", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.76.tgz", + "integrity": "sha512-CjVQyG7n7Sr+eBXE86HIulnL5N8xZY1sgmOPGuq/F0Rr0FJq63lg0kEtOIDfZBk44FnDLf6FUJ+dsJcuiUDdDQ==" }, "node_modules/elliptic": { "version": "6.5.4", @@ -12982,9 +13465,8 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", - "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.0", + "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { @@ -13035,8 +13517,20 @@ "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "license": "ISC" + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } }, "node_modules/function-bind": { "version": "1.1.2", @@ -13079,14 +13573,13 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", - "license": "MIT", "dependencies": { - "call-bind-apply-helpers": "^1.0.1", + "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "get-proto": "^1.0.0", + "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", @@ -17320,9 +17813,9 @@ } }, "node_modules/react-confetti": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/react-confetti/-/react-confetti-6.2.2.tgz", - "integrity": "sha512-K+kTyOPgX+ZujMZ+Rmb7pZdHBvg+DzinG/w4Eh52WOB8/pfO38efnnrtEZNJmjTvLxc16RBYO+tPM68Fg8viBA==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/react-confetti/-/react-confetti-6.2.3.tgz", + "integrity": "sha512-Jt6Fy3jE7FrpKxeDQ3oh36Bz6LoYt4o+vU578ghuF//NxADlcauDYvWr24S8hHKnQ90Uv01XXOngnKVBdZ73zQ==", "dev": true, "license": "MIT", "dependencies": { @@ -18503,7 +18996,6 @@ "version": "9.0.4", "resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-9.0.4.tgz", "integrity": "sha512-yWZWN0M+bivtoNLnaDbtny4XchdAIF5Q4g/ZsC5UC61Ckbp0QczwO8fg44rV3uYmY4WHd+EZQbn90W1d8ojzqQ==", - "license": "LGPL-3.0-only", "dependencies": { "@swc/helpers": "^0.5.11", "@types/uuid": "^8.3.4", @@ -18609,7 +19101,6 @@ "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", - "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" } @@ -20417,10 +20908,9 @@ } }, "node_modules/vite": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.0.tgz", - "integrity": "sha512-xMSLJNEjNk/3DJRgWlPADDwaU9AgYRodDH2t6oENhJnIlmU9Hx1Q6VpjyXua/JdMw1WJRbnAgHJ9xgET9gnIAg==", - "license": "MIT", + "version": "5.4.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", + "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", "dependencies": { "esbuild": "^0.20.1", "postcss": "^8.4.36", @@ -20562,6 +21052,262 @@ "vite": "^2 || ^3 || ^4 || ^5 || ^6" } }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/vite/node_modules/@esbuild/linux-x64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", @@ -20578,6 +21324,102 @@ "node": ">=12" } }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/vite/node_modules/esbuild": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", diff --git a/package.json b/package.json index 4e8180001..82a11364c 100644 --- a/package.json +++ b/package.json @@ -22,15 +22,15 @@ "@emotion/styled": "^11.11.5", "@invariant-labs/locker-eclipse-sdk": "^0.0.20", "@invariant-labs/points-sdk": "^0.0.3", - "@invariant-labs/sdk-eclipse": "^0.0.63", - "@irys/web-upload": "*", - "@irys/web-upload-solana": "^0.1.7", + "@invariant-labs/sdk-eclipse": "^0.0.85", + "@irys/web-upload": "0.0.14", + "@irys/web-upload-solana": "0.1.7", "@metaplex-foundation/js": "^0.20.1", "@metaplex-foundation/mpl-token-metadata": "^2.13.0", "@mui/icons-material": "^5.15.15", "@mui/material": "^5.15.15", "@mui/x-charts": "^7.22.3", - "@nightlylabs/wallet-selector-solana": "^0.3.14", + "@nightlylabs/wallet-selector-solana": "^0.3.13", "@nivo/bar": "^0.87.0", "@nivo/line": "^0.86.0", "@project-serum/sol-wallet-adapter": "^0.2.5", diff --git a/src/components/EmptyPlaceholder/EmptyPlaceholder.tsx b/src/components/EmptyPlaceholder/EmptyPlaceholder.tsx index cb2900bd9..87aa30312 100644 --- a/src/components/EmptyPlaceholder/EmptyPlaceholder.tsx +++ b/src/components/EmptyPlaceholder/EmptyPlaceholder.tsx @@ -10,16 +10,25 @@ export interface IEmptyPlaceholder { className?: string style?: React.CSSProperties withButton?: boolean + mainTitle?: string + roundedCorners?: boolean + blurWidth?: string buttonName?: string + height?: string + newVersion?: boolean } export const EmptyPlaceholder: React.FC = ({ desc, onAction, withButton = true, - buttonName + buttonName, + mainTitle, + height, + newVersion = false, + roundedCorners = false }) => { - const { classes } = useStyles() + const { classes } = useStyles({ newVersion, roundedCorners, height }) return ( <> @@ -27,7 +36,9 @@ export const EmptyPlaceholder: React.FC = ({ Not connected - It's empty here... + + {mainTitle ? mainTitle : `It's empty here...`} + {desc?.length && {desc}} {withButton && ( + + + + + + ) +} + +export default PositionViewActionPopover diff --git a/src/components/Modals/PositionViewActionPopover/style.ts b/src/components/Modals/PositionViewActionPopover/style.ts new file mode 100644 index 000000000..c59a258fb --- /dev/null +++ b/src/components/Modals/PositionViewActionPopover/style.ts @@ -0,0 +1,93 @@ +import { colors, typography } from '@static/theme' +import { makeStyles } from 'tss-react/mui' + +const useStyles = makeStyles()(() => { + return { + root: { + background: colors.invariant.component, + width: 170, + borderRadius: 20, + marginTop: 8, + padding: 8 + }, + list: { + borderRadius: 5, + marginTop: 7 + }, + listItem: { + color: colors.invariant.textGrey, + background: colors.invariant.componentBcg, + borderRadius: 11, + padding: '6px 7px', + width: '100%', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + '&:hover': { + background: colors.invariant.light, + color: colors.white.main, + '@media (hover: none)': { + color: colors.invariant.textGrey, + background: colors.invariant.component + } + }, + '&:first-of-type': { + marginBottom: '4px' + }, + '&:not(:first-of-type)': { + margin: '4px 0' + }, + '&:last-child': { + marginTop: '4px' + }, + '&:disabled': { + background: colors.invariant.light, + color: colors.invariant.black, + pointerEvents: 'auto', + transition: 'all 0.2s', + '&:hover': { + boxShadow: 'none', + cursor: 'not-allowed', + filter: 'brightness(1.15)', + '@media (hover: none)': { + filter: 'none' + } + } + } + }, + title: { + ...typography.body1, + margin: 10 + }, + dotIcon: { + width: 12, + marginLeft: 'auto', + color: colors.invariant.green, + display: 'none' + }, + name: { + textTransform: 'capitalize', + ...typography.body2, + paddingTop: '1px' + }, + paper: { + background: 'transparent', + boxShadow: 'none' + }, + icon: { + float: 'left', + marginRight: 8, + opacity: 1 + }, + active: { + background: colors.invariant.light, + color: colors.white.main, + '& *': { + opacity: 1, + display: 'block' + } + } + } +}) + +export default useStyles diff --git a/src/components/Modals/SelectModals/SelectTokenModal/SelectTokenModal.tsx b/src/components/Modals/SelectModals/SelectTokenModal/SelectTokenModal.tsx index 0a4099307..5855847c2 100644 --- a/src/components/Modals/SelectModals/SelectTokenModal/SelectTokenModal.tsx +++ b/src/components/Modals/SelectModals/SelectTokenModal/SelectTokenModal.tsx @@ -2,7 +2,7 @@ import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline' import searchIcon from '@static/svg/lupa.svg' import { theme } from '@static/theme' import React, { forwardRef, memo, useEffect, useMemo, useRef, useState } from 'react' -import { areEqual, FixedSizeList as List, ListChildComponentProps } from 'react-window' +import { FixedSizeList as List, ListChildComponentProps } from 'react-window' import useStyles from '../style' import AddTokenModal from '@components/Modals/AddTokenModal/AddTokenModal' import { @@ -135,8 +135,7 @@ const RowItem: React.FC> = React.memo( ) - }, - areEqual + } ) export const SelectTokenModal: React.FC = memo( @@ -169,20 +168,15 @@ export const SelectTokenModal: React.FC = memo( setHideUnknown(hiddenUnknownTokens) }, [hiddenUnknownTokens]) - type IndexedSwapToken = SwapToken & { - index: number - strAddress: string - } - - const tokensWithIndexes = useMemo(() => { - return tokens.map( - (token, index): IndexedSwapToken => ({ + const tokensWithIndexes = useMemo( + () => + tokens.map((token, index) => ({ ...token, index, strAddress: token.assetAddress.toString() - }) - ) - }, [tokens]) + })), + [tokens] + ) useEffect(() => { tokensWithIndexes.forEach(token => { diff --git a/src/components/Modals/SelectNetwork/style.ts b/src/components/Modals/SelectNetwork/style.ts index e8a1df33d..bc48a98dd 100644 --- a/src/components/Modals/SelectNetwork/style.ts +++ b/src/components/Modals/SelectNetwork/style.ts @@ -61,8 +61,6 @@ const useStyles = makeStyles()(() => { boxShadow: 'none' }, icon: { - width: 22, - height: 22, float: 'left', marginRight: 8, opacity: 1 diff --git a/src/components/NewPosition/RangeSelector/RangeSelector.tsx b/src/components/NewPosition/RangeSelector/RangeSelector.tsx index c2e54b6d7..7827d18b2 100644 --- a/src/components/NewPosition/RangeSelector/RangeSelector.tsx +++ b/src/components/NewPosition/RangeSelector/RangeSelector.tsx @@ -433,6 +433,7 @@ export const RangeSelector: React.FC = ({ Active liquidity diff --git a/src/components/OverviewYourPositions/UserOverview.tsx b/src/components/OverviewYourPositions/UserOverview.tsx new file mode 100644 index 000000000..822e2dae6 --- /dev/null +++ b/src/components/OverviewYourPositions/UserOverview.tsx @@ -0,0 +1,329 @@ +import { + Box, + Checkbox, + FormControlLabel, + FormGroup, + Grid, + Typography, + useMediaQuery, + Skeleton, + ToggleButton, + ToggleButtonGroup +} from '@mui/material' +import { theme } from '@static/theme' +import { Overview } from './components/Overview/Overview' +import { YourWallet } from './components/YourWallet/YourWallet' +import { useDispatch, useSelector } from 'react-redux' +import { balanceLoading, swapTokens } from '@store/selectors/solanaWallet' +import { isLoadingPositionsList, positionsWithPoolsData } from '@store/selectors/positions' +import { DECIMAL, printBN } from '@invariant-labs/sdk-eclipse/lib/utils' +import { ProcessedPool } from '@store/types/userOverview' +import { useProcessedTokens } from '@store/hooks/userOverview/useProcessedToken' +import { useStyles } from './style' +import { actions as snackbarsActions } from '@store/reducers/snackbars' + +import { useMemo, useState } from 'react' +import classNames from 'classnames' +import { VariantType } from 'notistack' +import { network } from '@store/selectors/solanaConnection' + +export enum CardSwitcher { + Overview = 'Overview', + Wallet = 'Wallet' +} +export const UserOverview = () => { + const { classes } = useStyles() + const tokensList = useSelector(swapTokens) + const isBalanceLoading = useSelector(balanceLoading) + const processedPools = useProcessedTokens(tokensList, isBalanceLoading) + const isLoadingList = useSelector(isLoadingPositionsList) + const isDownLg = useMediaQuery(theme.breakpoints.down('lg')) + const isDownMd = useMediaQuery(theme.breakpoints.down('md')) + const list = useSelector(positionsWithPoolsData) + const [activePanel, setActivePanel] = useState(CardSwitcher.Overview) + const dispatch = useDispatch() + const currentNetwork = useSelector(network) + const handleSwitchPools = ( + _: React.MouseEvent, + newAlignment: CardSwitcher | null + ) => { + if (newAlignment !== null) { + setActivePanel(newAlignment) + } + } + + const initialHideUnknownTokensValue = + localStorage.getItem('HIDE_UNKNOWN_TOKENS') === 'true' || + localStorage.getItem('HIDE_UNKNOWN_TOKENS') === null + const [hideUnknownTokens, setHideUnknownTokens] = useState(initialHideUnknownTokensValue) + + const setHideUnknownTokensValue = (val: boolean) => { + localStorage.setItem('HIDE_UNKNOWN_TOKENS', val ? 'true' : 'false') + } + + const handleCheckbox = (e: React.ChangeEvent) => { + setHideUnknownTokens(e.target.checked) + setHideUnknownTokensValue(e.target.checked) + } + + const handleSnackbar = (message: string, variant: VariantType) => { + dispatch( + snackbarsActions.add({ + message: message, + variant: variant, + persist: false + }) + ) + } + + const data: Pick< + ProcessedPool, + 'id' | 'fee' | 'tokenX' | 'poolData' | 'tokenY' | 'lowerTickIndex' | 'upperTickIndex' + >[] = list.map(position => { + return { + id: position.id.toString() + '_' + position.pool.toString(), + poolData: position.poolData, + lowerTickIndex: position.lowerTickIndex, + upperTickIndex: position.upperTickIndex, + fee: +printBN(position.poolData.fee, DECIMAL - 2), + tokenX: { + decimal: position.tokenX.decimals, + coingeckoId: position.tokenX.coingeckoId, + assetsAddress: position.tokenX.assetAddress.toString(), + balance: position.tokenX.balance, + icon: position.tokenX.logoURI, + name: position.tokenX.symbol + }, + tokenY: { + decimal: position.tokenY.decimals, + balance: position.tokenY.balance, + assetsAddress: position.tokenY.assetAddress.toString(), + coingeckoId: position.tokenY.coingeckoId, + icon: position.tokenY.logoURI, + name: position.tokenY.symbol + } + } + }) + + const positionsDetails = useMemo(() => { + const positionsAmount = data.length + const inRageAmount = data.filter( + item => + item.poolData.currentTickIndex >= Math.min(item.lowerTickIndex, item.upperTickIndex) && + item.poolData.currentTickIndex < Math.max(item.lowerTickIndex, item.upperTickIndex) + ).length + const outOfRangeAmount = positionsAmount - inRageAmount + return { positionsAmount, inRageAmount, outOfRangeAmount } + }, [data]) + + const finalTokens = useMemo(() => { + if (hideUnknownTokens) { + return processedPools.filter(item => item.isUnknown !== true) + } + return processedPools.filter(item => item.decimal > 0) + }, [processedPools, hideUnknownTokens]) + + const renderPositionDetails = () => ( + + {isLoadingList ? ( + <> + + + + + + + ) : ( + <> + + All Positions: {positionsDetails.positionsAmount} + + + + Within Range: {positionsDetails.inRageAmount} + + + Outside Range: {positionsDetails.outOfRangeAmount} + + + + )} + + ) + + const renderTokensFound = () => ( + + {isBalanceLoading || isLoadingList ? ( + + ) : ( + `Tokens Found: ${finalTokens.length}` + )} + + ) + + return ( + + + + Overview + + + + {isDownLg && !isDownMd && ( + + + + + {renderPositionDetails()} + + + + + + + + + handleCheckbox(e)} + /> + } + label='Hide unknown tokens' + /> + + + {renderTokensFound()} + + + + + )} + + {isDownMd && ( + <> + + + + + + Liquidity + + + Your Wallet + + + + + + + {activePanel === CardSwitcher.Overview && ( + <> + + + {renderPositionDetails()} + + + )} + {activePanel === CardSwitcher.Wallet && ( + <> + + + + + + handleCheckbox(e)} + /> + } + label='Hide unknown tokens' + /> + + + {renderTokensFound()} + + + + )} + + + )} + + {!isDownLg && ( + <> + + + + + + + {renderPositionDetails()} + + + + + handleCheckbox(e)} + /> + } + label='Hide unknown tokens' + /> + + + {renderTokensFound()} + + + + )} + + ) +} diff --git a/src/components/OverviewYourPositions/components/HeaderSection/HeaderSection.tsx b/src/components/OverviewYourPositions/components/HeaderSection/HeaderSection.tsx new file mode 100644 index 000000000..2ca79c023 --- /dev/null +++ b/src/components/OverviewYourPositions/components/HeaderSection/HeaderSection.tsx @@ -0,0 +1,32 @@ +import { Typography, Box, Skeleton } from '@mui/material' +import { formatNumberWithoutSuffix } from '@utils/utils' +import { useStyles } from './style' + +interface HeaderSectionProps { + totalValue: number + loading?: boolean +} + +export const HeaderSection: React.FC = ({ totalValue, loading }) => { + const { classes } = useStyles() + + return ( + <> + + Liquidity Assets + {loading ? ( + <> + + + ) : ( + + $ + {Number.isNaN(totalValue) + ? 0 + : formatNumberWithoutSuffix(totalValue, { twoDecimals: true })} + + )} + + + ) +} diff --git a/src/components/OverviewYourPositions/components/HeaderSection/style.ts b/src/components/OverviewYourPositions/components/HeaderSection/style.ts new file mode 100644 index 000000000..f06c83aef --- /dev/null +++ b/src/components/OverviewYourPositions/components/HeaderSection/style.ts @@ -0,0 +1,27 @@ +import { makeStyles } from 'tss-react/mui' +import { colors, theme, typography } from '@static/theme' +import { Theme } from '@mui/material' + +export const useStyles = makeStyles()((_theme: Theme) => ({ + headerSkeleton: { + background: colors.invariant.light, + ...typography.heading1, + [theme.breakpoints.down('lg')]: { + marginTop: '16px' + } + }, + headerRow: { + display: 'flex', + alignItems: 'center', + [theme.breakpoints.up('lg')]: { + padding: '16px 24px' + }, + padding: '16px 0px', + + justifyContent: 'space-between' + }, + headerText: { + ...typography.heading2, + color: colors.invariant.text + } +})) diff --git a/src/components/OverviewYourPositions/components/LegendOverview/LegendOverview.tsx b/src/components/OverviewYourPositions/components/LegendOverview/LegendOverview.tsx new file mode 100644 index 000000000..a87e3fa48 --- /dev/null +++ b/src/components/OverviewYourPositions/components/LegendOverview/LegendOverview.tsx @@ -0,0 +1,82 @@ +import React from 'react' +import { Box, Typography, Grid } from '@mui/material' +import { typography } from '@static/theme' +import { + TokenColorOverride, + useAverageLogoColor +} from '@store/hooks/userOverview/useAverageLogoColor' +import { formatNumberWithoutSuffix } from '@utils/utils' +import { useStyles } from './styles' + +interface Position { + token: string + logo?: string + value: number +} +export type TokenRowClasses = Record< + 'container' | 'scrollContainer' | 'tokenRow' | 'logoContainer' | 'logo' | 'valueText', + string +> +interface LegendOverviewProps { + sortedPositions: Position[] + logoColors: Record + tokenColorOverrides: TokenColorOverride[] +} + +const getContainerHeight = (length: number): string => { + if (length <= 2) return '70px' + if (length <= 3) return '100px' + if (length <= 4) return '130px' + return '160px' +} + +export const LegendOverview: React.FC = ({ + sortedPositions, + logoColors, + tokenColorOverrides +}) => { + const { getTokenColor } = useAverageLogoColor() + const { classes } = useStyles() + + return ( + + Tokens + + + {sortedPositions.map(position => { + const textColor = getTokenColor( + position.token, + logoColors[position.logo ?? ''] ?? '', + tokenColorOverrides + ) + return ( + + + {`${position.token} + + + + + {position.token} + + + + + + ${formatNumberWithoutSuffix(position.value, { twoDecimals: true })} + + + + ) + })} + + + ) +} diff --git a/src/components/OverviewYourPositions/components/LegendOverview/styles.ts b/src/components/OverviewYourPositions/components/LegendOverview/styles.ts new file mode 100644 index 000000000..c48085c3a --- /dev/null +++ b/src/components/OverviewYourPositions/components/LegendOverview/styles.ts @@ -0,0 +1,53 @@ +import { makeStyles } from 'tss-react/mui' +import { colors, theme, typography } from '@static/theme' +import { Theme } from '@mui/material' + +export const useStyles = makeStyles()((_theme: Theme) => ({ + container: { + marginTop: '16px' + }, + scrollContainer: { + width: '97%', + marginTop: '8px', + marginLeft: '0 !important', + '&::-webkit-scrollbar': { + padding: '4px', + width: '4px' + }, + '&::-webkit-scrollbar-track': { + background: colors.invariant.componentDark + }, + '&::-webkit-scrollbar-thumb': { + background: colors.invariant.pink, + borderRadius: '4px' + } + }, + tokenHeaderLabel: { + ...typography.body2, + color: colors.invariant.textGrey + }, + tokenRow: { + paddingLeft: '0 !important', + display: 'flex', + paddingRight: '10px', + maxHeight: '32px', + [theme.breakpoints.down('lg')]: { + justifyContent: 'space-between' + }, + justifyContent: 'flex-start' + }, + logoContainer: { + display: 'flex', + alignItems: 'center' + }, + logo: { + width: '24px', + height: '24px', + borderRadius: '100%' + }, + valueText: { + ...typography.heading4, + color: colors.invariant.text, + textAlign: 'right' + } +})) diff --git a/src/components/OverviewYourPositions/components/MobileOverview/MobileOverview.tsx b/src/components/OverviewYourPositions/components/MobileOverview/MobileOverview.tsx new file mode 100644 index 000000000..18aebf43c --- /dev/null +++ b/src/components/OverviewYourPositions/components/MobileOverview/MobileOverview.tsx @@ -0,0 +1,111 @@ +import React, { useMemo, useState } from 'react' +import { Box, Grid, Typography } from '@mui/material' + +import { TokenPositionEntry } from '@store/types/userOverview' +import { formatNumberWithoutSuffix } from '@utils/utils' +import { isLoadingPositionsList } from '@store/selectors/positions' +import { useSelector } from 'react-redux' +import MobileOverviewSkeleton from '../Overview/skeletons/MobileOverviewSkeleton' +import SegmentFragmentTooltip from '../SegmentFragmentTooltip/SegmentFragmentTooltip' +import { useStyles } from './styles' +export interface ChartSegment { + start: number + width: number + color: string + token: string + value: number + logo: string | undefined + percentage: string +} + +interface MobileOverviewProps { + positions: TokenPositionEntry[] + totalAssets: number + chartColors: string[] +} + +const MobileOverview: React.FC = ({ positions, totalAssets, chartColors }) => { + const [selectedSegment, setSelectedSegment] = useState(null) + const isLoadingList = useSelector(isLoadingPositionsList) + const { classes } = useStyles() + + const sortedPositions = useMemo(() => { + return [...positions].sort((a, b) => b.value - a.value) + }, [positions]) + + const sortedChartColors = useMemo(() => { + const colorMap = positions.reduce((map, position, index) => { + map.set(position.token, chartColors[index]) + return map + }, new Map()) + + return sortedPositions.map(position => colorMap.get(position.token) ?? '') + }, [positions, sortedPositions, chartColors]) + + const segments: ChartSegment[] = useMemo(() => { + let currentPosition = 0 + return sortedPositions.map((position, index) => { + const percentage = (position.value / totalAssets) * 100 + const segment = { + start: currentPosition, + width: percentage, + color: sortedChartColors[index], + token: position.token, + value: position.value, + logo: position.logo, + percentage: percentage.toFixed(2) + } + currentPosition += percentage + return segment + }) + }, [sortedPositions, totalAssets, sortedChartColors]) + + return ( + + {isLoadingList ? ( + + ) : ( + <> + + {segments.map((segment, index) => ( + + ))} + + {segments.length > 0 ? ( + + Tokens + + + {segments.map(segment => ( + + + {'Token + + {segment.token}: + + + + + + ${formatNumberWithoutSuffix(segment.value, { twoDecimals: true })} + + + + ))} + + + ) : null} + + )} + + ) +} + +export default MobileOverview diff --git a/src/components/OverviewYourPositions/components/MobileOverview/styles.ts b/src/components/OverviewYourPositions/components/MobileOverview/styles.ts new file mode 100644 index 000000000..1a7c9c30f --- /dev/null +++ b/src/components/OverviewYourPositions/components/MobileOverview/styles.ts @@ -0,0 +1,81 @@ +import { makeStyles } from 'tss-react/mui' +import { colors, theme, typography } from '@static/theme' + +export const useStyles = makeStyles()(() => ({ + container: { + width: '100%', + marginTop: theme.spacing(2), + display: 'flex', + flexDirection: 'column', + justifyItems: 'center' + }, + tooltip: { + color: colors.invariant.text, + width: '150px', + background: colors.invariant.componentDark, + borderRadius: 12 + }, + chartContainer: { + height: '24px', + borderRadius: '8px', + overflow: 'hidden', + display: 'flex', + marginBottom: theme.spacing(3) + }, + tokenSection: { + marginTop: theme.spacing(2) + }, + tokenTitle: { + ...typography.body2, + fontWeight: 600, + color: colors.invariant.textGrey, + marginBottom: theme.spacing(2) + }, + tokenGrid: { + marginTop: theme.spacing(1), + width: '100% !important', + maxHeight: '120px', + marginLeft: '0 !important', + overflowY: 'auto', + paddingRight: '8px', + marginRight: '-4px', + marginBottom: '5px', + '&::-webkit-scrollbar': { + width: '4px' + }, + '&::-webkit-scrollbar-track': { + background: colors.invariant.newDark + }, + '&::-webkit-scrollbar-thumb': { + background: colors.invariant.pink, + borderRadius: '4px' + } + }, + tokenGridItem: { + paddingLeft: '0', + marginLeft: '0', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: theme.spacing(1) + }, + tokenLogoContainer: { + display: 'flex', + alignItems: 'center' + }, + tokenLogo: { + width: '24px', + height: '24px', + borderRadius: '100%' + }, + tokenSymbol: { + marginLeft: '8px', + ...typography.heading4 + }, + tokenValue: { + ...typography.heading4, + color: colors.invariant.text, + textAlign: 'right', + paddingLeft: '8px' + } +})) diff --git a/src/components/OverviewYourPositions/components/Overview/Overview.tsx b/src/components/OverviewYourPositions/components/Overview/Overview.tsx new file mode 100644 index 000000000..3680588bb --- /dev/null +++ b/src/components/OverviewYourPositions/components/Overview/Overview.tsx @@ -0,0 +1,229 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { Box, Typography, useMediaQuery } from '@mui/material' +import { HeaderSection } from '../HeaderSection/HeaderSection' +import { UnclaimedSection } from '../UnclaimedSection/UnclaimedSection' +import { useStyles } from './styles/styles' +import { ProcessedPool } from '@store/types/userOverview' +import { useDispatch, useSelector } from 'react-redux' +import { theme } from '@static/theme' +import ResponsivePieChart from '../OverviewPieChart/ResponsivePieChart' +import { + isLoadingPositionsList, + positionsWithPoolsData, + positionsList as list, + unclaimedFees, + PoolWithAddressAndIndex +} from '@store/selectors/positions' +import { getTokenPrice } from '@utils/utils' +import MobileOverview from '../MobileOverview/MobileOverview' +import LegendSkeleton from './skeletons/LegendSkeleton' +import { useAverageLogoColor } from '@store/hooks/userOverview/useAverageLogoColor' +import { useAgregatedPositions } from '@store/hooks/userOverview/useAgregatedPositions' +import icons from '@static/icons' +import { actions, PositionWithAddress } from '@store/reducers/positions' +import { LegendOverview } from '../LegendOverview/LegendOverview' +import { SwapToken } from '@store/selectors/solanaWallet' + +interface OverviewProps { + poolAssets: ProcessedPool[] +} + +export interface ISinglePositionData extends PositionWithAddress { + poolData: PoolWithAddressAndIndex + tokenX: SwapToken + tokenY: SwapToken + positionIndex: number + isLocked: boolean +} + +export type EmptyStateClasses = Record<'emptyState' | 'emptyStateText', string> + +export const Overview: React.FC = () => { + const positionList = useSelector(positionsWithPoolsData) + const isLg = useMediaQuery(theme.breakpoints.down('lg')) + const { isAllClaimFeesLoading } = useSelector(list) + const isLoadingList = useSelector(isLoadingPositionsList) + const { classes } = useStyles() + const dispatch = useDispatch() + + const [prices, setPrices] = useState>({}) + const [logoColors, setLogoColors] = useState>({}) + const [pendingColorLoads, setPendingColorLoads] = useState>(new Set()) + const { total: totalUnclaimedFee } = useSelector(unclaimedFees) + const { getAverageColor, getTokenColor, tokenColorOverrides } = useAverageLogoColor() + const { positions } = useAgregatedPositions(positionList, prices) + + const isColorsLoading = useMemo(() => pendingColorLoads.size > 0, [pendingColorLoads]) + + const sortedPositions = useMemo(() => { + return [...positions].sort((a, b) => b.value - a.value) + }, [positions]) + + const chartColors = useMemo( + () => + sortedPositions.map(position => + getTokenColor(position.token, logoColors[position.logo ?? ''] ?? '', tokenColorOverrides) + ), + [sortedPositions, logoColors, getTokenColor, tokenColorOverrides] + ) + + const totalAssets = useMemo( + () => positions.reduce((acc, position) => acc + position.value, 0), + [positions] + ) + + const handleClaimAll = () => { + dispatch(actions.claimAllFee()) + } + + const isDataReady = !isLoadingList && !isColorsLoading && Object.keys(prices).length > 0 + + const data = useMemo(() => { + if (!isDataReady) return [] + + const tokens: { label: string; value: number }[] = [] + sortedPositions.forEach(position => { + const existingToken = tokens.find(token => token.label === position.token) + if (existingToken) { + existingToken.value += position.value + } else { + tokens.push({ + label: position.name, + value: position.value + }) + } + }) + return tokens + }, [sortedPositions, isDataReady]) + + useEffect(() => { + if (Object.keys(prices).length > 0) { + dispatch(actions.setPrices(prices)) + } + }, [prices]) + + useEffect(() => { + const loadPrices = async () => { + const uniqueTokens = new Set() + positionList.forEach(position => { + uniqueTokens.add(position.tokenX.assetAddress.toString()) + uniqueTokens.add(position.tokenY.assetAddress.toString()) + }) + + const tokenArray = Array.from(uniqueTokens) + const priceResults = await Promise.all( + tokenArray.map(async token => ({ + token, + price: await getTokenPrice(token) + })) + ) + + const newPrices = priceResults.reduce( + (acc, { token, price }) => ({ + ...acc, + [token]: price ?? 0 + }), + {} + ) + + setPrices(newPrices) + } + + loadPrices() + }, [positionList]) + + useEffect(() => { + sortedPositions.forEach(position => { + if (position.logo && !logoColors[position.logo] && !pendingColorLoads.has(position.logo)) { + setPendingColorLoads(prev => new Set(prev).add(position.logo ?? '')) + + getAverageColor(position.logo, position.name) + .then(color => { + setLogoColors(prev => ({ + ...prev, + [position.logo ?? '']: color + })) + setPendingColorLoads(prev => { + const next = new Set(prev) + next.delete(position.logo ?? '') + return next + }) + }) + .catch(error => { + console.error('Error getting color for logo:', error) + setPendingColorLoads(prev => { + const next = new Set(prev) + next.delete(position.logo ?? '') + return next + }) + }) + } + }) + }, [sortedPositions, getAverageColor, logoColors, pendingColorLoads]) + + useEffect(() => { + if (Object.keys(prices).length > 0) { + dispatch(actions.calculateTotalUnclaimedFees()) + + const interval = setInterval(() => { + dispatch(actions.calculateTotalUnclaimedFees()) + }, 60000) + + return () => clearInterval(interval) + } + }, [dispatch, prices]) + + const EmptyState = ({ classes }: { classes: EmptyStateClasses }) => ( + + Empty portfolio + No liquidity found + + ) + + if (!isLoadingList && positions.length === 0) { + return ( + + + + + + ) + } + + return ( + + + + + {isLg ? ( + + ) : ( + + + {!isDataReady ? ( + + ) : ( + + )} + + + + + + + )} + + ) +} diff --git a/src/components/OverviewYourPositions/components/Overview/skeletons/LegendSkeleton.tsx b/src/components/OverviewYourPositions/components/Overview/skeletons/LegendSkeleton.tsx new file mode 100644 index 000000000..8049eac96 --- /dev/null +++ b/src/components/OverviewYourPositions/components/Overview/skeletons/LegendSkeleton.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import { Box, Grid, Skeleton, Typography } from '@mui/material' +import { useDesktopSkeleton } from './styles/useDesktopSkeleton' + +const LegendSkeleton: React.FC = () => { + const { classes } = useDesktopSkeleton() + + return ( + + Tokens + + + {[1, 2, 3, 4, 5].map(item => ( + + + + + + + + + + + ))} + + + ) +} + +export default LegendSkeleton diff --git a/src/components/OverviewYourPositions/components/Overview/skeletons/MobileOverviewSkeleton.tsx b/src/components/OverviewYourPositions/components/Overview/skeletons/MobileOverviewSkeleton.tsx new file mode 100644 index 000000000..cf4f4ea2c --- /dev/null +++ b/src/components/OverviewYourPositions/components/Overview/skeletons/MobileOverviewSkeleton.tsx @@ -0,0 +1,58 @@ +import React from 'react' +import { Box, Grid, Skeleton, Typography } from '@mui/material' +import { useMobileSkeletonStyle } from './styles/useMobileSkeleton' +const MobileOverviewSkeleton: React.FC = () => { + const { classes } = useMobileSkeletonStyle() + const segments = Array(3).fill(null) + + const getSegmentBorderRadius = (index: number) => { + if (index === 0) return '6px 0 0 6px' + if (index === segments.length - 1) return '0 6px 6px 0' + return '0' + } + + return ( + + + {segments.map((_, index) => ( + + ))} + + + + + Tokens + + + + {segments.map((_, index) => ( + + + + + + + + + + + + + + ))} + + + + ) +} + +export default MobileOverviewSkeleton diff --git a/src/components/OverviewYourPositions/components/Overview/skeletons/styles/useDesktopSkeleton.ts b/src/components/OverviewYourPositions/components/Overview/skeletons/styles/useDesktopSkeleton.ts new file mode 100644 index 000000000..4f52c5874 --- /dev/null +++ b/src/components/OverviewYourPositions/components/Overview/skeletons/styles/useDesktopSkeleton.ts @@ -0,0 +1,52 @@ +import { makeStyles } from 'tss-react/mui' +import { colors, typography, theme } from '@static/theme' + +export const useDesktopSkeleton = makeStyles()(() => ({ + container: { + marginTop: theme.spacing(2) + }, + tokenText: { + ...typography.body2, + color: colors.invariant.textGrey + }, + gridContainer: { + minHeight: '120px', + overflowY: 'auto', + marginLeft: '0 !important', + marginTop: '8px', + '&::-webkit-scrollbar': { + width: '4px' + }, + '&::-webkit-scrollbar-track': { + background: 'transparent' + }, + '&::-webkit-scrollbar-thumb': { + background: colors.invariant.pink, + borderRadius: '4px' + } + }, + gridItem: { + paddingLeft: '0 !important', + display: 'flex', + justifyContent: 'flex-start', + [theme.breakpoints.down('lg')]: { + justifyContent: 'space-between' + } + }, + logoContainer: { + display: 'flex', + alignItems: 'center' + }, + circularSkeleton: { + backgroundColor: colors.invariant.light + }, + textSkeleton: { + backgroundColor: colors.invariant.light, + ...typography.heading4 + }, + valueContainer: { + marginLeft: '8px', + display: 'flex', + justifyContent: 'flex-end' + } +})) diff --git a/src/components/OverviewYourPositions/components/Overview/skeletons/styles/useMobileSkeleton.ts b/src/components/OverviewYourPositions/components/Overview/skeletons/styles/useMobileSkeleton.ts new file mode 100644 index 000000000..e8b96874b --- /dev/null +++ b/src/components/OverviewYourPositions/components/Overview/skeletons/styles/useMobileSkeleton.ts @@ -0,0 +1,78 @@ +import { makeStyles } from 'tss-react/mui' +import { colors, theme, typography } from '@static/theme' + +export const useMobileSkeletonStyle = makeStyles()(() => ({ + container: { + width: '100%', + marginTop: theme.spacing(2) + }, + chartContainer: { + height: '24px', + borderRadius: '8px', + overflow: 'hidden', + display: 'flex', + marginBottom: theme.spacing(3) + }, + skeletonSegment: { + backgroundColor: 'rgba(255, 255, 255, 0.1)' + }, + tokenLabelContainer: { + height: '161px', + marginTop: theme.spacing(2) + }, + tokenTextSkeleton: { + marginBottom: theme.spacing(2), + width: '60px', + height: '24px' + }, + tokensHeaderLabel: { + ...typography.body2, + fontWeight: 600, + color: colors.invariant.textGrey + }, + gridContainer: { + marginTop: theme.spacing(1), + width: '100% !important', + maxHeight: '120px', + marginLeft: '0 !important', + overflowY: 'auto', + paddingRight: '8px', + marginRight: '-4px', + marginBottom: '5px', + '&::-webkit-scrollbar': { + width: '4px' + }, + '&::-webkit-scrollbar-track': { + background: colors.invariant.newDark + }, + '&::-webkit-scrollbar-thumb': { + background: colors.invariant.pink, + borderRadius: '4px' + } + }, + gridItem: { + paddingLeft: '0 !important', + marginLeft: '0 !important', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: theme.spacing(1) + }, + logoSkeleton: { + width: '24px', + height: '24px', + borderRadius: '100%', + backgroundColor: 'rgba(255, 255, 255, 0.1)' + }, + tokenSymbolSkeleton: { + width: '40px', + height: '24px', + backgroundColor: 'rgba(255, 255, 255, 0.1)' + }, + valueSkeleton: { + width: '100%', + height: '24px', + backgroundColor: 'rgba(255, 255, 255, 0.1)', + paddingLeft: '8px' + } +})) diff --git a/src/components/OverviewYourPositions/components/Overview/styles/styles.ts b/src/components/OverviewYourPositions/components/Overview/styles/styles.ts new file mode 100644 index 000000000..3787c4db0 --- /dev/null +++ b/src/components/OverviewYourPositions/components/Overview/styles/styles.ts @@ -0,0 +1,78 @@ +import { makeStyles } from 'tss-react/mui' +import { colors, theme, typography } from '@static/theme' +import { Theme } from '@mui/material' + +export const useStyles = makeStyles()((_theme: Theme) => ({ + container: { + width: '600px', + backgroundColor: colors.invariant.component, + borderTopLeftRadius: '24px', + + [theme.breakpoints.down('lg')]: { + borderTopRightRadius: '24px', + borderRight: `none`, + maxHeight: 'fit-content', + width: 'auto', + padding: '0px 16px 0px 16px' + }, + [theme.breakpoints.down('md')]: { + borderRadius: 24 + }, + borderRight: `1px solid ${colors.invariant.light}`, + display: 'flex', + flexDirection: 'column' + }, + tooltip: { + color: colors.invariant.text, + width: '150px', + background: colors.invariant.componentDark, + borderRadius: 12 + }, + subtitle: { + ...typography.body2, + color: colors.invariant.textGrey, + [theme.breakpoints.down('lg')]: { + marginTop: '16px' + } + }, + + pieChartSection: { + flex: '1 1 100%', + minHeight: 'fit-content', + [theme.breakpoints.down('lg')]: { + marginTop: '100px' + } + }, + legendSection: { + display: 'flex', + justifyContent: 'space-between', + flexDirection: 'row-reverse', + [theme.breakpoints.down('lg')]: { + justifyContent: 'center', + flexDirection: 'column' + } + }, + + segmentBox: { + height: '100%', + position: 'relative', + cursor: 'pointer', + transition: 'all 0.2s' + }, + emptyState: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + padding: '32px', + gap: '16px', + backgroundColor: colors.invariant.component, + background: + 'linear-gradient(360deg, rgba(32, 41, 70, 0.8) 0%, rgba(17, 25, 49, 0.8) 100%), linear-gradient(180deg, #010514 0%, rgba(1, 5, 20, 0) 100%)' + }, + emptyStateText: { + ...typography.heading2, + color: colors.invariant.text, + textAlign: 'center' + } +})) diff --git a/src/components/OverviewYourPositions/components/OverviewPieChart/ResponsivePieChart.tsx b/src/components/OverviewYourPositions/components/OverviewPieChart/ResponsivePieChart.tsx new file mode 100644 index 000000000..2ecbdbdaf --- /dev/null +++ b/src/components/OverviewYourPositions/components/OverviewPieChart/ResponsivePieChart.tsx @@ -0,0 +1,131 @@ +import { useState, useEffect } from 'react' +import { Box } from '@mui/material' +import { PieChart } from '@mui/x-charts' +import { colors } from '@static/theme' +import { useStyles } from './style' + +const ResponsivePieChart = ({ data, chartColors, isLoading = true }) => { + const [hoveredIndex, setHoveredIndex] = useState(null) + const [showRealData, setShowRealData] = useState(!isLoading) + + useEffect(() => { + if (!isLoading) { + const timer = setTimeout(() => { + setShowRealData(true) + }, 50) + return () => clearTimeout(timer) + } else { + setShowRealData(false) + } + }, [isLoading]) + + const total = data?.reduce((sum, item) => sum + item.value, 0) || 0 + + const loadingData = [{ value: 1, label: '' }] + + const getPathStyles = (index: number, isLoadingChart: boolean) => ({ + stroke: 'transparent', + outline: 'none', + opacity: isLoadingChart ? (showRealData ? 0 : 1) : showRealData ? 1 : 0, + filter: `drop-shadow(0px 0px ${hoveredIndex === index ? '4px' : '2px'} ${ + isLoadingChart ? colors.invariant.light : chartColors[index] + })`, + transition: 'all 0.3s ease-in-out' + }) + + const generateDynamicStyles = () => { + const styles: Record> = {} + const itemCount = data.length + + for (let i = 0; i < itemCount; i++) { + styles[`&:nth-of-type(${i + 1})`] = getPathStyles(i, false) + } + + return styles + } + + const commonChartProps = { + outerRadius: '50%', + innerRadius: '90%', + startAngle: -45, + endAngle: 315, + cx: '55%', + cy: '55%' + } + const { classes } = useStyles({ + chartColors, + hoveredColor: hoveredIndex !== null ? chartColors[hoveredIndex] : null + }) + return ( + + + '' + } + ]} + sx={{ + '& path': { + stroke: 'transparent', + outline: 'none' + } + }} + colors={[colors.invariant.light]} + tooltip={{ trigger: 'none' }} + slotProps={{ legend: { hidden: true } }} + width={300} + height={200} + /> + + + 0 ? data : [{}], + ...commonChartProps, + valueFormatter: item => { + if (!data) return '' + const percentage = ((item.value / total) * 100).toFixed(1) + return `$${item.value.toLocaleString()} (${percentage}%)` + } + } + ]} + onHighlightChange={item => { + if (showRealData) { + setHoveredIndex(item?.dataIndex ?? null) + } + }} + sx={{ + '& path': generateDynamicStyles() + }} + colors={isLoading ? [colors.invariant.light] : chartColors} + tooltip={{ + trigger: showRealData ? 'item' : 'none', + classes: { + root: classes.dark_background, + valueCell: classes.value_cell, + labelCell: classes.label_cell, + paper: classes.dark_paper, + table: classes.dark_table, + cell: classes.dark_cell, + mark: classes.dark_mark, + row: classes.dark_row + } + }} + slotProps={{ + legend: { hidden: true } + }} + width={300} + height={200} + /> + + + ) +} + +export default ResponsivePieChart diff --git a/src/components/OverviewYourPositions/components/OverviewPieChart/style.ts b/src/components/OverviewYourPositions/components/OverviewPieChart/style.ts new file mode 100644 index 000000000..cc602ee9a --- /dev/null +++ b/src/components/OverviewYourPositions/components/OverviewPieChart/style.ts @@ -0,0 +1,53 @@ +import { colors } from '@static/theme' +import { makeStyles } from 'tss-react/mui' + +export const useStyles = makeStyles<{ chartColors: string[]; hoveredColor: string | null }>()( + (_theme, { chartColors, hoveredColor }) => ({ + pieChartContainer: { + width: '100%', + height: '100%', + maxHeight: '200px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center' + }, + dark_background: { + backgroundColor: `${colors.invariant.componentDark} !important`, + borderRadius: '8px !important', + display: 'flex !important', + flexDirection: 'column', + padding: '8px !important', + minWidth: '150px !important', + boxShadow: '27px 39px 75px -30px #000' + }, + dark_paper: { + backgroundColor: `${colors.invariant.componentDark} !important`, + color: '#FFFFFF !important', + boxShadow: 'none !important' + }, + value_cell: { + color: '#fff !important' + }, + label_cell: { + color: `${hoveredColor || chartColors?.[0]} !important`, + fontWeight: 'bold' + }, + dark_table: { + color: '#FFFFFF !important', + display: 'flex !important', + flexDirection: 'column', + gap: '4px' + }, + dark_cell: { + padding: '2px 0 !important' + }, + dark_mark: { + display: 'none !important' + }, + dark_row: { + color: '#FFFFFF !important', + display: 'flex !important', + flexDirection: 'column' + } + }) +) diff --git a/src/components/OverviewYourPositions/components/SegmentFragmentTooltip/SegmentFragmentTooltip.tsx b/src/components/OverviewYourPositions/components/SegmentFragmentTooltip/SegmentFragmentTooltip.tsx new file mode 100644 index 000000000..807cf204e --- /dev/null +++ b/src/components/OverviewYourPositions/components/SegmentFragmentTooltip/SegmentFragmentTooltip.tsx @@ -0,0 +1,97 @@ +import { Tooltip, Box, Typography } from '@mui/material' +import { formatNumberWithoutSuffix } from '@utils/utils' +import React, { useMemo, useEffect } from 'react' +import { ChartSegment } from '../MobileOverview/MobileOverview' +import { useStyles } from './styles' + +interface TooltipClasses { + tooltip: string +} + +interface SegmentFragmentTooltipProps { + segment: ChartSegment + index: number + selectedSegment: number | null + setSelectedSegment: (index: number | null) => void + tooltipClasses: TooltipClasses +} + +const SegmentFragmentTooltip: React.FC = ({ + segment, + index, + selectedSegment, + setSelectedSegment, + tooltipClasses +}) => { + const { classes } = useStyles({ color: segment.color }) + useEffect(() => { + const handleClickOutside = (event: MouseEvent | TouchEvent) => { + const target = event.target as HTMLElement + if (!target.closest('.chart-container')) { + setSelectedSegment(null) + } + } + + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('touchstart', handleClickOutside) + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('touchstart', handleClickOutside) + } + }, [setSelectedSegment]) + + const getSegmentStyle = (segment: ChartSegment, isSelected: boolean) => ({ + backgroundColor: segment.color, + opacity: isSelected ? 1 : 0.8, + width: `${segment.width}%`, + height: '100%', + cursor: 'pointer', + transition: 'opacity 0.2s ease-in-out' + }) + + const handleClick = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault() + e.stopPropagation() + setSelectedSegment(selectedSegment === index ? null : index) + } + + const segmentFragmentTooltip = useMemo( + () => ( + e.stopPropagation()} + onClose={() => setSelectedSegment(null)} + title={ + + {segment.token} + + ${formatNumberWithoutSuffix(segment.value, { twoDecimals: true })} ( + {segment.percentage}%) + + + } + placement='top' + classes={{ + tooltip: tooltipClasses.tooltip + }}> + + + ), + [segment, index, selectedSegment, setSelectedSegment, tooltipClasses] + ) + + return segmentFragmentTooltip +} + +export default SegmentFragmentTooltip diff --git a/src/components/OverviewYourPositions/components/SegmentFragmentTooltip/styles.ts b/src/components/OverviewYourPositions/components/SegmentFragmentTooltip/styles.ts new file mode 100644 index 000000000..2569780cb --- /dev/null +++ b/src/components/OverviewYourPositions/components/SegmentFragmentTooltip/styles.ts @@ -0,0 +1,16 @@ +import { makeStyles } from 'tss-react/mui' +import { typography } from '@static/theme' +import { Theme } from '@mui/material' + +export const useStyles = makeStyles<{ color: string }>()((_theme: Theme, { color }) => ({ + segmentTokenLabel: { + color, + ...typography.body2, + fontWeight: '600', + marginBottom: 0.5 + }, + segmentTokenValue: { + marginBottom: 0.5, + ...typography.body2 + } +})) diff --git a/src/components/OverviewYourPositions/components/UnclaimedSection/UnclaimedSection.tsx b/src/components/OverviewYourPositions/components/UnclaimedSection/UnclaimedSection.tsx new file mode 100644 index 000000000..a283c148d --- /dev/null +++ b/src/components/OverviewYourPositions/components/UnclaimedSection/UnclaimedSection.tsx @@ -0,0 +1,74 @@ +import { Box, Typography, Button, Skeleton, useMediaQuery } from '@mui/material' +import { formatNumberWithoutSuffix } from '@utils/utils' +import { theme } from '@static/theme' +import loadingAnimation from '@static/gif/loading.gif' +import { useStyles } from './styles' + +interface UnclaimedSectionProps { + unclaimedTotal: number + handleClaimAll?: () => void + loading?: boolean +} + +export const UnclaimedSection: React.FC = ({ + unclaimedTotal, + handleClaimAll, + loading = false +}) => { + const { classes } = useStyles({ isLoading: loading || unclaimedTotal === 0 }) + const isLg = useMediaQuery(theme.breakpoints.down('lg')) + + return ( + + + + Unclaimed fees (total) + {!isLg && ( + + )} + + + {loading ? ( + + ) : ( + + ${formatNumberWithoutSuffix(unclaimedTotal, { twoDecimals: true })} + + )} + + {isLg && ( + + )} + + ) +} diff --git a/src/components/OverviewYourPositions/components/UnclaimedSection/styles.ts b/src/components/OverviewYourPositions/components/UnclaimedSection/styles.ts new file mode 100644 index 000000000..2a2bb0e8d --- /dev/null +++ b/src/components/OverviewYourPositions/components/UnclaimedSection/styles.ts @@ -0,0 +1,87 @@ +import { makeStyles } from 'tss-react/mui' +import { colors, theme, typography } from '@static/theme' +import { Theme } from '@mui/material' + +export const useStyles = makeStyles<{ isLoading: boolean }>()((_theme: Theme, { isLoading }) => ({ + container: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center' + }, + unclaimedSection: { + display: 'flex', + + flexDirection: 'column', + gap: '16px', + minHeight: '32px', + + [theme.breakpoints.up('lg')]: { + height: '57.5px', + padding: '0px 24px 0px 24px', + borderTop: `1px solid ${colors.invariant.light}`, + borderBottom: `1px solid ${colors.invariant.light}`, + + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between' + } + }, + + titleRow: { + display: 'flex', + alignItems: 'center', + gap: '16px', + justifyContent: 'space-between', + + [theme.breakpoints.up('lg')]: { + gap: 'auto', + flex: 1 + } + }, + + unclaimedTitle: { + ...typography.heading4, + color: colors.invariant.textGrey + }, + + unclaimedAmount: { + ...typography.heading3, + color: colors.invariant.text + }, + claimAllButton: { + ...typography.body1, + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + minWidth: '100px', + height: '32px', + marginLeft: '36px', + background: 'linear-gradient(180deg, #2EE09A 0%, #21A47C 100%)', + borderRadius: '12px', + fontFamily: 'Mukta', + fontStyle: 'normal', + textTransform: 'none', + color: colors.invariant.dark, + transition: 'all 0.3s ease', + + '&:hover': { + background: 'linear-gradient(180deg, #3FF2AB 0%, #25B487 100%)', + boxShadow: isLoading ? 'none' : '0 4px 15px rgba(46, 224, 154, 0.35)' + }, + + '&:active': { + boxShadow: isLoading ? 'none' : '0 2px 8px rgba(46, 224, 154, 0.35)' + }, + + [theme.breakpoints.down('lg')]: { + width: '100%', + marginLeft: 0 + }, + + '&:disabled': { + background: colors.invariant.light, + color: colors.invariant.dark + } + } +})) diff --git a/src/components/OverviewYourPositions/components/YourWallet/MobileCard.tsx b/src/components/OverviewYourPositions/components/YourWallet/MobileCard.tsx new file mode 100644 index 000000000..3f6f0be8a --- /dev/null +++ b/src/components/OverviewYourPositions/components/YourWallet/MobileCard.tsx @@ -0,0 +1,59 @@ +import { Box, Typography } from '@mui/material' +import icons from '@static/icons' +import { TokenPool, StrategyConfig } from '@store/types/userOverview' +import { formatNumberWithoutSuffix } from '@utils/utils' + +type MobileCardClasses = Record< + | 'mobileCard' + | 'mobileCardHeader' + | 'mobileTokenInfo' + | 'tokenIcon' + | 'tokenSymbol' + | 'mobileStatsContainer' + | 'mobileStatItem' + | 'mobileStatLabel' + | 'mobileStatValue' + | 'warningIcon' + | 'mobileActionsContainer', + string +> + +export const MobileCard: React.FC<{ + pool: TokenPool + classes: MobileCardClasses + renderActions: (pool: TokenPool, strategy: StrategyConfig) => JSX.Element + getStrategy: () => StrategyConfig +}> = ({ pool, classes, renderActions, getStrategy }) => { + const strategy = getStrategy() + return ( + + + + {pool.symbol} + {pool.isUnknown && } + + {pool.symbol} + + {renderActions(pool, strategy)} + + + + + Amount: + + + {formatNumberWithoutSuffix(pool.amount)} + + + + + Value: + + + ${pool.value.toFixed(2).toLocaleString().replace(',', '.')} + + + + + ) +} diff --git a/src/components/OverviewYourPositions/components/YourWallet/YourWallet.tsx b/src/components/OverviewYourPositions/components/YourWallet/YourWallet.tsx new file mode 100644 index 000000000..8ba75c73d --- /dev/null +++ b/src/components/OverviewYourPositions/components/YourWallet/YourWallet.tsx @@ -0,0 +1,409 @@ +import React, { useMemo } from 'react' +import { + Box, + Typography, + Skeleton, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow +} from '@mui/material' +import { StrategyConfig, TokenPool } from '@store/types/userOverview' +import { useNavigate } from 'react-router-dom' +import { DEFAULT_FEE_TIER, STRATEGIES } from '@store/consts/userStrategies' +import icons from '@static/icons' +import { NetworkType, USDC_MAIN, USDC_TEST, WETH_MAIN, WETH_TEST } from '@store/consts/static' +import { addressToTicker, formatNumberWithoutSuffix } from '@utils/utils' +import { useStyles } from './styles' +import { network } from '@store/selectors/solanaConnection' +import { MobileCard } from './MobileCard' +import { TooltipHover } from '@components/TooltipHover/TooltipHover' +import FileCopyOutlinedIcon from '@mui/icons-material/FileCopyOutlined' +import { shortenAddress } from '@utils/uiUtils' +import { VariantType } from 'notistack' +import { EmptyStateClasses } from '../Overview/Overview' + +interface YourWalletProps { + pools: TokenPool[] + handleSnackbar: (message: string, variant: VariantType) => void + isLoading: boolean + currentNetwork: NetworkType +} + +type SkeletonRowClasses = Record< + 'tableCell' | 'tokenContainer' | 'tokenInfo' | 'desktopActionCell' | 'valueSkeleton', + string +> + +type MobileSkeletonCardClasses = Record< + | 'mobileCard' + | 'mobileCardHeader' + | 'mobileCardHeader' + | 'mobileActionsContainer' + | 'mobileStatsContainer', + string +> + +const EmptyState = ({ classes }: { classes: EmptyStateClasses }) => ( + + Empty portfolio + No assets found + +) + +const SkeletonRow = ({ classes }: { classes: SkeletonRowClasses }) => ( + + + + + + + + + + + + + + + + + + + + + +) + +const MobileSkeletonCard = ({ classes }: { classes: MobileSkeletonCardClasses }) => ( + + + + + + + + + + + + + + + + + +) + +export const YourWallet: React.FC = ({ + pools = [], + isLoading, + handleSnackbar, + currentNetwork +}) => { + const { classes } = useStyles({ isLoading }) + const navigate = useNavigate() + const sortedPools = useMemo(() => [...pools].sort((a, b) => b.value - a.value), [pools]) + + const totalValue = useMemo( + () => sortedPools.reduce((sum, pool) => sum + pool.value, 0), + [sortedPools] + ) + + const findStrategy = (poolAddress: string) => { + const poolTicker = addressToTicker(currentNetwork, poolAddress) + let strategy = STRATEGIES.find(s => { + const tickerA = addressToTicker(currentNetwork, s.tokenAddressA) + const tickerB = s.tokenAddressB ? addressToTicker(currentNetwork, s.tokenAddressB) : undefined + return tickerA === poolTicker || tickerB === poolTicker + }) + + if (!strategy) { + strategy = { + tokenAddressA: poolAddress, + feeTier: DEFAULT_FEE_TIER + } + } + + return { + ...strategy, + tokenSymbolA: addressToTicker(currentNetwork, strategy.tokenAddressA), + tokenSymbolB: strategy.tokenAddressB + ? addressToTicker(currentNetwork, strategy.tokenAddressB) + : '-' + } + } + + const handleImageError = (e: React.SyntheticEvent) => { + e.currentTarget.src = icons.unknownToken + } + + const networkUrl = useMemo(() => { + switch (currentNetwork) { + case NetworkType.Mainnet: + return '' + case NetworkType.Testnet: + return '?cluster=testnet' + case NetworkType.Devnet: + return '?cluster=devnet' + default: + return '' + } + }, [network]) + + const renderMobileLoading = () => ( + + {Array(3) + .fill(0) + .map((_, index) => ( + + ))} + + ) + + const renderActions = (pool: TokenPool, strategy: StrategyConfig) => ( + <> + + { + const sourceToken = addressToTicker(currentNetwork, strategy.tokenAddressA) + const targetToken = + sourceToken === 'ETH' + ? currentNetwork === NetworkType.Mainnet + ? USDC_MAIN.address + : USDC_TEST.address + : currentNetwork === NetworkType.Mainnet + ? WETH_MAIN.address + : WETH_TEST.address + + navigate( + `/newPosition/${sourceToken}/${addressToTicker(currentNetwork, targetToken.toString())}/${strategy.feeTier}`, + { + state: { referer: 'portfolio' } + } + ) + }}> + Add + + + + { + const sourceToken = addressToTicker(currentNetwork, pool.id.toString()) + const targetToken = + sourceToken === 'ETH' + ? currentNetwork === NetworkType.Mainnet + ? USDC_MAIN.address + : USDC_TEST.address + : currentNetwork === NetworkType.Mainnet + ? WETH_MAIN.address + : WETH_TEST.address + navigate( + `/exchange/${sourceToken}/${addressToTicker(currentNetwork, targetToken.toString())}`, + { + state: { referer: 'portfolio' } + } + ) + }}> + Add + + + + { + window.open( + `https://eclipsescan.xyz/token/${pool.id.toString()}/${networkUrl}`, + '_blank', + 'noopener,noreferrer' + ) + }}> + {'Exchange'} + + + + ) + return ( + <> + + + Available Balance + {isLoading ? ( + + ) : ( + + ${formatNumberWithoutSuffix(totalValue)} + + )} + + + + + + + + Token Name + + + Value + + + Amount + + + Action + + + + + {isLoading ? ( + Array(4) + .fill(0) + .map((_, index) => ) + ) : sortedPools.length === 0 ? ( + + + + + + ) : ( + sortedPools.map(pool => { + const poolAddress = pool.id.toString() + const strategy = findStrategy(poolAddress) + + return ( + + + + + {pool.symbol} + {pool.isUnknown && ( + + )} + + + {pool.symbol.length <= 6 + ? pool.symbol + : shortenAddress(pool.symbol, 2)} + + + { + navigator.clipboard.writeText(poolAddress) + + handleSnackbar('Token address copied.', 'success') + }} + classes={{ root: classes.clipboardIcon }} + /> + + + + {renderActions(pool, strategy)} + + + + + + + $ + {formatNumberWithoutSuffix(pool.value.toFixed(2), { + twoDecimals: true + })} + + + + + + + {formatNumberWithoutSuffix(pool.amount)} + + + + + {renderActions(pool, strategy)} + + + ) + }) + )} + +
+
+
+ + + {isLoading ? ( + renderMobileLoading() + ) : sortedPools.length === 0 ? ( + + ) : ( + + {sortedPools.map(pool => ( + findStrategy(pool.id.toString())} + renderActions={renderActions} + /> + ))} + + )} + + + ) +} diff --git a/src/components/OverviewYourPositions/components/YourWallet/styles.ts b/src/components/OverviewYourPositions/components/YourWallet/styles.ts new file mode 100644 index 000000000..e901b7792 --- /dev/null +++ b/src/components/OverviewYourPositions/components/YourWallet/styles.ts @@ -0,0 +1,300 @@ +import { makeStyles } from 'tss-react/mui' +import { colors, typography, theme } from '@static/theme' +import { Theme } from '@mui/material' +export const useStyles = makeStyles<{ isLoading: boolean }>()((_theme: Theme, { isLoading }) => ({ + container: { + minWidth: '50%', + overflowX: 'hidden' + }, + divider: { + width: '100%', + height: '1px', + backgroundColor: colors.invariant.light, + margin: '24px 0' + }, + clipboardIcon: { + marginLeft: 4, + width: 18, + cursor: 'pointer', + color: colors.invariant.lightHover, + '&:hover': { + color: colors.invariant.text, + + '@media (hover: none)': { + color: colors.invariant.lightHover + } + } + }, + header: { + background: colors.invariant.component, + width: '100%', + display: 'flex', + padding: '16px 0px', + [theme.breakpoints.down('lg')]: { + borderTopLeftRadius: '24px' + }, + borderTopLeftRadius: 0, + borderTopRightRadius: '24px', + justifyContent: 'space-between', + alignItems: 'center', + borderBottom: `1px solid ${colors.invariant.light}` + }, + headerText: { + ...typography.heading2, + paddingInline: '16px', + color: colors.invariant.text + }, + tableContainer: { + borderBottomLeftRadius: 0, + borderBottomRightRadius: 0, + backgroundColor: colors.invariant.component, + height: '286px', + overflowY: 'scroll', + overflowX: 'hidden', + + '&::-webkit-scrollbar': { + width: '4px', + marginTop: '58.4px' + }, + '&::-webkit-scrollbar-track': { + background: colors.invariant.componentDark, + + marginTop: '58.4px' + }, + '&::-webkit-scrollbar-thumb': { + background: isLoading ? 'transparent' : colors.invariant.pink, + borderRadius: '4px' + } + }, + tableCell: { + borderBottom: `1px solid ${colors.invariant.light}`, + padding: '12px !important', + textAlign: 'center' + }, + headerCell: { + fontSize: '20px', + + textWrap: 'nowrap', + fontWeight: 600, + color: colors.invariant.textGrey, + borderBottom: `1px solid ${colors.invariant.light}`, + backgroundColor: colors.invariant.component, + position: 'sticky', + top: 0, + zIndex: 1 + }, + tokenContainer: { + display: 'flex', + alignItems: 'center', + gap: '8px', + [theme.breakpoints.down('md')]: { + gap: '16px', + width: '100%', + flexDirection: 'column', + justifyContent: 'center' + } + }, + tokenInfo: { + display: 'flex', + alignItems: 'center', + gap: '8px' + }, + tokenIcon: { + minWidth: 28, + maxWidth: 28, + height: 28, + borderRadius: '50%', + objectFit: 'cover' + }, + tokenSymbol: { + ...typography.heading4, + color: colors.invariant.text + }, + mobileCardContainer: { + maxHeight: '345px', + overflowY: 'auto', + paddingRight: '4px', + [theme.breakpoints.down('lg')]: { + '&:not(:first-child)': { + marginTop: '20px' + } + }, + '&::-webkit-scrollbar': { + width: '4px' + }, + '&::-webkit-scrollbar-track': { + background: colors.invariant.componentDark, + marginLeft: '10px' + }, + '&::-webkit-scrollbar-thumb': { + background: colors.invariant.pink, + borderRadius: '4px' + } + }, + valueSkeleton: { + borderRadius: '6px', + [theme.breakpoints.down('lg')]: { + width: '98%' + }, + width: '114%' + }, + statsContainer: { + backgroundColor: colors.invariant.light, + display: 'inline-flex', + width: '90%', + justifyContent: 'center', + alignItems: 'center', + [theme.breakpoints.down('lg')]: { + padding: '4px 6px' + }, + padding: '4px 12px', + maxHeight: '24px', + borderRadius: '6px', + gap: '16px' + }, + statsLabel: { + ...typography.caption1, + color: colors.invariant.textGrey + }, + statsValue: { + ...typography.body1, + color: colors.invariant.green + }, + actionIcon: { + height: 32, + background: 'none', + width: 32, + padding: 0, + margin: 0, + border: 'none', + display: 'flex', + justifyContent: 'flex-end', + alignItems: 'center', + color: colors.invariant.black, + textTransform: 'none', + transition: 'filter 0.2s linear', + '&:hover': { + filter: 'brightness(1.2)', + cursor: 'pointer', + '@media (hover: none)': { + filter: 'none' + } + } + }, + zebraRow: { + '& > tr:nth-of-type(odd)': { + background: `${colors.invariant.componentDark}` + } + }, + + mobileActionContainer: { + display: 'none', + [theme.breakpoints.down('md')]: { + display: 'flex', + gap: '8px', + padding: '12px 16px', + borderBottom: `1px solid ${colors.invariant.light}` + } + }, + desktopActionCell: { + padding: '17px', + + [theme.breakpoints.down('md')]: { + display: 'none' + } + }, + mobileActions: { + display: 'none', + [theme.breakpoints.down('md')]: { + display: 'flex', + gap: '8px' + } + }, + mobileContainer: { + display: 'none', + [theme.breakpoints.down('md')]: { + display: 'flex', + flexDirection: 'column' + } + }, + mobileCard: { + backgroundColor: colors.invariant.component, + borderRadius: '16px', + maxHeight: '107px', + padding: '16px', + '&:not(:first-child)': { + marginTop: '8px' + } + }, + mobileCardHeader: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: '16px' + }, + mobileTokenInfo: { + display: 'flex', + alignItems: 'center', + gap: '8px' + }, + mobileActionsContainer: { + display: 'flex', + gap: '8px' + }, + mobileStatsContainer: { + display: 'flex', + justifyContent: 'space-between', + gap: '8px' + }, + mobileStatItem: { + backgroundColor: colors.invariant.light, + borderRadius: '10px', + textAlign: 'center', + width: '100%', + minHeight: '24px' + }, + mobileStatLabel: { + ...typography.caption1, + color: colors.invariant.textGrey, + marginRight: '8px' + }, + mobileStatValue: { + ...typography.caption1, + color: colors.invariant.green + }, + desktopContainer: { + width: '600px', + [theme.breakpoints.down('md')]: { + display: 'none' + }, + [theme.breakpoints.down('lg')]: { + width: 'auto' + } + }, + emptyState: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + padding: '32px', + gap: '16px', + border: 'none', + background: + 'linear-gradient(360deg, rgba(32, 41, 70, 0.8) 0%, rgba(17, 25, 49, 0.8) 100%), linear-gradient(180deg, #010514 0%, rgba(1, 5, 20, 0) 100%)' + }, + emptyStateText: { + ...typography.heading2, + color: colors.invariant.text, + textAlign: 'center' + }, + warningIcon: { + position: 'absolute', + width: 12, + height: 12, + bottom: -4, + right: '67%', + [theme.breakpoints.down('lg')]: { + right: '53%' + } + } +})) diff --git a/src/components/OverviewYourPositions/style.ts b/src/components/OverviewYourPositions/style.ts new file mode 100644 index 000000000..b210f88f8 --- /dev/null +++ b/src/components/OverviewYourPositions/style.ts @@ -0,0 +1,172 @@ +import { makeStyles } from 'tss-react/mui' +import { colors, theme, typography } from '@static/theme' + +export const useStyles = makeStyles()(() => ({ + footer: { + maxWidth: 1201, + height: 48, + width: '100%', + borderTop: `1px solid ${colors.invariant.light}`, + display: 'flex', + justifyContent: 'space-between', + background: colors.invariant.component, + borderBottomLeftRadius: 24, + borderBottomRightRadius: 24, + [theme.breakpoints.down('lg')]: { + marginBottom: 32 + }, + [theme.breakpoints.down('md')]: { + borderRadius: 16, + border: 'none', + marginTop: 8 + } + }, + footerItem: { + width: '100%', + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + padding: 10, + paddingLeft: 16, + paddingRight: 16 + }, + overviewContainer: { + display: 'flex', + flexDirection: 'column', + marginBottom: '24px', + width: '100%' + }, + switchPoolsContainer: { + position: 'relative', + width: '100%', + backgroundColor: colors.invariant.component, + borderRadius: 10, + overflow: 'hidden', + display: 'flex', + height: 32, + marginBottom: '16px' + }, + overviewHeaderTitle: { + color: colors.invariant.text, + ...typography.heading4, + fontWeight: 500 + }, + switchPoolsMarker: { + position: 'absolute', + top: 0, + bottom: 0, + width: '50%', + backgroundColor: colors.invariant.light, + borderRadius: 10, + transition: 'all 0.3s ease', + zIndex: 1 + }, + switchPoolsButtonsGroup: { + position: 'relative', + zIndex: 2, + display: 'flex', + width: '100%' + }, + switchPoolsButton: { + ...typography.body2, + display: 'flex', + textWrap: 'nowrap', + justifyContent: 'center', + alignItems: 'center', + color: 'white', + flex: 1, + textTransform: 'none', + border: 'none', + borderRadius: 10, + zIndex: 2, + width: '50%', + '&.Mui-selected': { + backgroundColor: 'transparent' + }, + '&:hover': { + backgroundColor: 'transparent' + }, + '&.Mui-selected:hover': { + backgroundColor: 'transparent' + }, + '&:disabled': { + color: colors.invariant.componentBcg, + pointerEvents: 'auto', + transition: 'all 0.2s', + '&:hover': { + boxShadow: 'none', + cursor: 'not-allowed', + filter: 'brightness(1.15)', + '@media (hover: none)': { + filter: 'none' + } + } + }, + letterSpacing: '-0.03em', + paddingTop: 6, + paddingBottom: 6, + paddingLeft: 32, + paddingRight: 32 + }, + filtersContainer: { + display: 'none', + width: '100%', + [theme.breakpoints.down('md')]: { + display: 'flex' + } + }, + disabledSwitchButton: { + color: `${colors.invariant.textGrey} !important` + }, + footerCheckboxContainer: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 8 + }, + checkBoxLabel: { + '.MuiFormControlLabel-label': { + ...typography.body2, + color: `${colors.invariant.text}b6` + } + }, + footerText: { ...typography.body2 }, + footerPositionDetails: { + ...typography.body2 + }, + whiteText: { + color: colors.invariant.text + }, + greenText: { + color: `${colors.invariant.green}b2` + }, + pinkText: { + color: `${colors.invariant.pink}b2` + }, + greyText: { + color: colors.invariant.textGrey + }, + checkBox: { + width: 25, + height: 25, + marginLeft: 3, + marginRight: 3, + color: colors.invariant.newDark, + '&.Mui-checked': { + color: colors.invariant.green + }, + '& .MuiSvgIcon-root': { + fontSize: 25 + }, + padding: 0, + '& .MuiIconButton-label': { + width: 20, + height: 20, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + margin: 0 + } + } +})) diff --git a/src/components/PositionDetails/SinglePositionPlot/SinglePositionPlot.tsx b/src/components/PositionDetails/SinglePositionPlot/SinglePositionPlot.tsx index 6732e6c6b..727f00875 100644 --- a/src/components/PositionDetails/SinglePositionPlot/SinglePositionPlot.tsx +++ b/src/components/PositionDetails/SinglePositionPlot/SinglePositionPlot.tsx @@ -142,6 +142,7 @@ const SinglePositionPlot: React.FC = ({ Active liquidity diff --git a/src/components/PositionsList/PositionItem/PositionItem.stories.tsx b/src/components/PositionsList/PositionItem/PositionItem.stories.tsx index 2d16749b3..3d6b81d12 100644 --- a/src/components/PositionsList/PositionItem/PositionItem.stories.tsx +++ b/src/components/PositionsList/PositionItem/PositionItem.stories.tsx @@ -3,12 +3,12 @@ import { NetworkType } from '@store/consts/static' import type { Meta, StoryObj } from '@storybook/react' import { Keypair } from '@solana/web3.js' import { BN } from '@coral-xyz/anchor' -import { PositionItemDesktop } from './variants/PositionItemDesktop' +import { PositionItemMobile } from './variants/PositionMobileCard/PositionItemMobile' const meta = { title: 'Components/PositionItem', - component: PositionItemDesktop -} satisfies Meta + component: PositionItemMobile +} satisfies Meta export default meta type Story = StoryObj @@ -17,6 +17,10 @@ export const Primary: Story = { args: { tokenXName: 'BTC', tokenYName: 'AZERO', + isLockPositionModalOpen: false, + setAllowPropagation: () => {}, + setIsLockPositionModalOpen: () => {}, + isActive: false, tokenXIcon: 'https://raw.githubusercontent.com/solana-labs/token-list/main/assets/mainnet/EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v/logo.png', tokenYIcon: diff --git a/src/components/PositionsList/PositionItem/components/InactivePoolsPopover/InactivePoolsPopover.tsx b/src/components/PositionsList/PositionItem/components/InactivePoolsPopover/InactivePoolsPopover.tsx index 8969e56e6..e1f61c494 100644 --- a/src/components/PositionsList/PositionItem/components/InactivePoolsPopover/InactivePoolsPopover.tsx +++ b/src/components/PositionsList/PositionItem/components/InactivePoolsPopover/InactivePoolsPopover.tsx @@ -1,6 +1,6 @@ import useStyles from './style' import { Popover } from '@mui/material' -import PositionStatusTooltip from '../PositionStatusTooltip' +import PositionStatusTooltip from '../PositionStatusTooltip/PositionStatusTooltip' export interface IPromotedPoolPopover { open: boolean diff --git a/src/components/PositionsList/PositionItem/components/MinMaxChart/MinMaxChart.tsx b/src/components/PositionsList/PositionItem/components/MinMaxChart/MinMaxChart.tsx new file mode 100644 index 000000000..298933c93 --- /dev/null +++ b/src/components/PositionsList/PositionItem/components/MinMaxChart/MinMaxChart.tsx @@ -0,0 +1,128 @@ +import React from 'react' +import { Box, Typography } from '@mui/material' +import { colors, typography } from '@static/theme' +import { formatNumberWithSuffix } from '@utils/utils' +import { useMinMaxChartStyles } from './style' +import { CHART_CONSTANTS } from './consts' +import handleMax from '@static/svg/narrowChartMaxHandle.svg' +import handleMin from '@static/svg/narrowChartMinHandle.svg' +interface MinMaxChartProps { + min: number + max: number + current: number +} + +interface GradientBoxProps { + color: string + width: string + isOutOfBound: boolean + gradientDirection: 'left' | 'right' +} + +const GradientBox: React.FC = ({ color, width, isOutOfBound }) => ( + +) + +const CurrentValueIndicator: React.FC<{ + position: number + value: number +}> = ({ position, value }) => { + const { classes } = useMinMaxChartStyles() + return ( + + {formatNumberWithSuffix(value)} + + ) +} + +const PriceIndicatorLine: React.FC<{ position: number }> = ({ position }) => { + const { classes } = useMinMaxChartStyles() + + return ( + + ) +} + +const MinMaxLabels: React.FC<{ + min: number + max: number + classes: Record<'minMaxLabels', string> +}> = ({ min, max, classes }) => ( + + + {formatNumberWithSuffix(min)} + + + {formatNumberWithSuffix(max)} + + +) + +export const MinMaxChart: React.FC = ({ min, max, current }) => { + const calculateBoundedPosition = () => { + if (current < min) return -CHART_CONSTANTS.OVERFLOW_LIMIT_LEFT + if (current > max) return 100 + CHART_CONSTANTS.OVERFLOW_LIMIT_RIGHT / 2 + return ((current - min) / (max - min)) * 100 + } + const { classes } = useMinMaxChartStyles() + const isOutOfBounds = current < min || current > max + + const currentPosition = calculateBoundedPosition() + + return ( + + + + + + MIN + + + + + + + + MAX + + + + + + ) +} diff --git a/src/components/PositionsList/PositionItem/components/MinMaxChart/consts.ts b/src/components/PositionsList/PositionItem/components/MinMaxChart/consts.ts new file mode 100644 index 000000000..bc3c27c94 --- /dev/null +++ b/src/components/PositionsList/PositionItem/components/MinMaxChart/consts.ts @@ -0,0 +1,6 @@ +export const CHART_CONSTANTS = { + MAX_HANDLE_OFFSET: 100, + OVERFLOW_LIMIT_LEFT: 4, + OVERFLOW_LIMIT_RIGHT: 10, + CHART_PADDING: 21 +} as const diff --git a/src/components/PositionsList/PositionItem/components/MinMaxChart/style.ts b/src/components/PositionsList/PositionItem/components/MinMaxChart/style.ts new file mode 100644 index 000000000..a70fef288 --- /dev/null +++ b/src/components/PositionsList/PositionItem/components/MinMaxChart/style.ts @@ -0,0 +1,60 @@ +import { makeStyles } from 'tss-react/mui' +import { colors, typography } from '@static/theme' +import { CHART_CONSTANTS } from './consts' + +export const useMinMaxChartStyles = makeStyles()(() => ({ + container: { + width: '100%', + height: '55px', + display: 'flex', + marginTop: '18px', + justifyContent: 'flex-end', + alignItems: 'flex-end', + position: 'relative', + flexDirection: 'column' + }, + chart: { + width: '100%', + display: 'flex', + borderBottom: `1px solid ${colors.invariant.light}`, + position: 'relative', + overflow: 'visible' + }, + handleLeft: { + position: 'absolute', + left: 0, + top: -2, + zIndex: 10, + transform: `translateX(-${CHART_CONSTANTS.CHART_PADDING}px)` + }, + minMaxLabels: { + width: '100%', + display: 'flex', + justifyContent: 'space-between', + marginTop: '6px' + }, + handleRight: { + position: 'absolute', + left: `${CHART_CONSTANTS.MAX_HANDLE_OFFSET}%`, + top: -2, + zIndex: 10 + }, + currentValueIndicator: { + ...typography.caption2, + color: colors.invariant.yellow, + position: 'absolute', + transform: 'translateX(-50%)', + top: '-16px', + whiteSpace: 'nowrap', + zIndex: 11 + }, + priceLineIndicator: { + position: 'absolute', + width: '2px', + height: '25px', + backgroundColor: colors.invariant.yellow, + top: '0%', + transform: 'translateX(-50%)', + zIndex: 5 + } +})) diff --git a/src/components/PositionsList/PositionItem/components/PositionStatusTooltip.tsx b/src/components/PositionsList/PositionItem/components/PositionStatusTooltip/PositionStatusTooltip.tsx similarity index 100% rename from src/components/PositionsList/PositionItem/components/PositionStatusTooltip.tsx rename to src/components/PositionsList/PositionItem/components/PositionStatusTooltip/PositionStatusTooltip.tsx diff --git a/src/components/PositionsList/PositionItem/variants/PositionItemDesktop.tsx b/src/components/PositionsList/PositionItem/variants/PositionItemDesktop.tsx deleted file mode 100644 index 2774ed2a0..000000000 --- a/src/components/PositionsList/PositionItem/variants/PositionItemDesktop.tsx +++ /dev/null @@ -1,400 +0,0 @@ -import { Box, Grid, Hidden, Tooltip, Typography, useMediaQuery } from '@mui/material' -import SwapList from '@static/svg/swap-list.svg' -import { theme } from '@static/theme' -import { formatNumberWithSuffix } from '@utils/utils' -import classNames from 'classnames' -import { useCallback, useMemo, useRef, useState } from 'react' -import { useDesktopStyles } from './style/desktop' -import { TooltipHover } from '@components/TooltipHover/TooltipHover' -import { initialXtoY, tickerToAddress } from '@utils/utils' -import lockIcon from '@static/svg/lock.svg' -import unlockIcon from '@static/svg/unlock.svg' -import icons from '@static/icons' -import PromotedPoolPopover from '@components/Modals/PromotedPoolPopover/PromotedPoolPopover' -import { BN } from '@coral-xyz/anchor' -import { usePromotedPool } from '../hooks/usePromotedPool' -import { calculatePercentageRatio } from '../utils/calculations' -import { IPositionItem } from '@components/PositionsList/types' -import { useSharedStyles } from './style/shared' -import PositionStatusTooltip from '../components/PositionStatusTooltip' -import { NetworkType } from '@store/consts/static' -import { useSelector } from 'react-redux' -import { network as currentNetwork } from '@store/selectors/solanaConnection' - -export const PositionItemDesktop: React.FC = ({ - tokenXName, - tokenYName, - tokenXIcon, - poolAddress, - tokenYIcon, - fee, - min, - max, - valueX, - valueY, - position, - // liquidity, - poolData, - isActive = false, - currentPrice, - tokenXLiq, - tokenYLiq, - network, - isFullRange, - isLocked -}) => { - const { classes } = useDesktopStyles() - const { classes: sharedClasses } = useSharedStyles() - const airdropIconRef = useRef(null) - const [isPromotedPoolPopoverOpen, setIsPromotedPoolPopoverOpen] = useState(false) - - const isXs = useMediaQuery(theme.breakpoints.down('xs')) - const networkSelector = useSelector(currentNetwork) - const isDesktop = useMediaQuery(theme.breakpoints.up('lg')) - - const [xToY, setXToY] = useState( - initialXtoY(tickerToAddress(network, tokenXName), tickerToAddress(network, tokenYName)) - ) - - const { tokenXPercentage, tokenYPercentage } = calculatePercentageRatio( - tokenXLiq, - tokenYLiq, - currentPrice, - xToY - ) - - const { isPromoted, pointsPerSecond, estimated24hPoints } = usePromotedPool( - poolAddress, - position, - poolData - ) - - const handleMouseEnter = useCallback(() => { - setIsPromotedPoolPopoverOpen(true) - }, []) - - const handleMouseLeave = useCallback(() => { - setIsPromotedPoolPopoverOpen(false) - }, []) - - const [isTooltipOpen, setIsTooltipOpen] = useState(false) - - const handleTooltipEnter = useCallback(() => { - setIsTooltipOpen(true) - }, []) - - const handleTooltipLeave = useCallback(() => { - setIsTooltipOpen(false) - }, []) - - const feeFragment = useMemo( - () => ( - e.stopPropagation()} - title={ - isActive ? ( - <> - The position is active and currently earning a fee as long as the - current price is within the position's price range. - - ) : ( - <> - The position is inactive and not earning a fee as long as the current - price is outside the position's price range. - - ) - } - placement='top' - classes={{ - tooltip: sharedClasses.tooltip - }}> - - - {fee}% fee - - - - ), - [fee, classes, isActive] - ) - - const valueFragment = useMemo( - () => ( - - - Value - - - - {formatNumberWithSuffix(xToY ? valueY : valueX)} {xToY ? tokenYName : tokenXName} - - - - ), - [valueX, valueY, tokenXName, classes, isXs, isDesktop, tokenYName, xToY] - ) - const promotedIconContent = useMemo(() => { - if (isPromoted && isActive) { - return ( - <> -
e.stopPropagation()} - className={classes.actionButton} - onMouseEnter={handleMouseEnter} - onMouseLeave={handleMouseLeave}> - {'Airdrop'} -
- setIsPromotedPoolPopoverOpen(false)} - headerText={ - <> - This position is currently earning points - - } - pointsLabel={'Total points distributed across the pool per 24H:'} - estPoints={estimated24hPoints} - points={new BN(pointsPerSecond, 'hex').muln(24).muln(60).muln(60)} - /> - - ) - } - - return ( - setIsTooltipOpen(true)} - onClose={() => setIsTooltipOpen(false)} - enterTouchDelay={0} - leaveTouchDelay={0} - onClick={e => e.stopPropagation()} - title={ -
- -
- } - placement='top' - classes={{ - tooltip: sharedClasses.tooltip - }}> -
- {'Airdrop'} -
-
- ) - }, [ - isPromoted, - isActive, - isPromotedPoolPopoverOpen, - isTooltipOpen, - handleMouseEnter, - handleMouseLeave, - handleTooltipEnter, - handleTooltipLeave, - estimated24hPoints, - pointsPerSecond - ]) - - return ( - - - - - {xToY - - Arrow { - e.stopPropagation() - setXToY(!xToY) - }} - /> - - {xToY - - - - {xToY ? tokenXName : tokenYName} - {xToY ? tokenYName : tokenXName} - - - - - - {networkSelector === NetworkType.Mainnet && ( - - {promotedIconContent} - - )} - - {feeFragment} - - - {tokenXPercentage === 100 && ( - - {tokenXPercentage} - {'%'} {xToY ? tokenXName : tokenYName} - - )} - {tokenYPercentage === 100 && ( - - {tokenYPercentage} - {'%'} {xToY ? tokenYName : tokenXName} - - )} - - {tokenYPercentage !== 100 && tokenXPercentage !== 100 && ( - - {tokenXPercentage} - {'%'} {xToY ? tokenXName : tokenYName} {' - '} {tokenYPercentage} - {'%'} {xToY ? tokenYName : tokenXName} - - )} - - - - - <> - - MIN - MAX - - - {isFullRange ? ( - FULL RANGE - ) : ( - - {formatNumberWithSuffix(xToY ? min : 1 / max)} -{' '} - {formatNumberWithSuffix(xToY ? max : 1 / min)} {xToY ? tokenYName : tokenXName}{' '} - per {xToY ? tokenXName : tokenYName} - - )} - - - - - {valueFragment} - - {isLocked && ( - - {isLocked ? ( - - Lock - - ) : ( - - Lock - - )} - - )} - - - ) -} diff --git a/src/components/PositionsList/PositionItem/variants/PositionItemMobile.tsx b/src/components/PositionsList/PositionItem/variants/PositionItemMobile.tsx deleted file mode 100644 index 2422d96d3..000000000 --- a/src/components/PositionsList/PositionItem/variants/PositionItemMobile.tsx +++ /dev/null @@ -1,479 +0,0 @@ -import { Box, Grid, Tooltip, Typography, useMediaQuery } from '@mui/material' -import SwapList from '@static/svg/swap-list.svg' -import { theme } from '@static/theme' -import { formatNumberWithSuffix } from '@utils/utils' -import classNames from 'classnames' -import { useEffect, useMemo, useRef, useState } from 'react' -import { useMobileStyles } from './style/mobile' -import { TooltipHover } from '@components/TooltipHover/TooltipHover' -import { initialXtoY, tickerToAddress } from '@utils/utils' -import lockIcon from '@static/svg/lock.svg' -import unlockIcon from '@static/svg/unlock.svg' -import icons from '@static/icons' -import PromotedPoolPopover from '@components/Modals/PromotedPoolPopover/PromotedPoolPopover' -import { BN } from '@coral-xyz/anchor' -import { usePromotedPool } from '../hooks/usePromotedPool' -import { calculatePercentageRatio } from '../utils/calculations' -import { IPositionItem } from '@components/PositionsList/types' -import { useSharedStyles } from './style/shared' -import { InactivePoolsPopover } from '../components/InactivePoolsPopover/InactivePoolsPopover' -import { NetworkType } from '@store/consts/static' -import { network as currentNetwork } from '@store/selectors/solanaConnection' -import { useSelector } from 'react-redux' - -interface IPositionItemMobile extends IPositionItem { - setAllowPropagation: React.Dispatch> -} - -export const PositionItemMobile: React.FC = ({ - tokenXName, - tokenYName, - tokenXIcon, - poolAddress, - tokenYIcon, - fee, - min, - max, - valueX, - valueY, - position, - setAllowPropagation, - poolData, - isActive = false, - currentPrice, - tokenXLiq, - tokenYLiq, - network, - isFullRange, - isLocked -}) => { - const { classes } = useMobileStyles() - const { classes: sharedClasses } = useSharedStyles() - const airdropIconRef = useRef(null) - const [isPromotedPoolPopoverOpen, setIsPromotedPoolPopoverOpen] = useState(false) - const [isPromotedPoolInactive, setIsPromotedPoolInactive] = useState(false) - const isXs = useMediaQuery(theme.breakpoints.down('xs')) - const isDesktop = useMediaQuery(theme.breakpoints.up('lg')) - const networkSelector = useSelector(currentNetwork) - - const [xToY, setXToY] = useState( - initialXtoY(tickerToAddress(network, tokenXName), tickerToAddress(network, tokenYName)) - ) - - const { tokenXPercentage, tokenYPercentage } = calculatePercentageRatio( - tokenXLiq, - tokenYLiq, - currentPrice, - xToY - ) - - const { isPromoted, pointsPerSecond, estimated24hPoints } = usePromotedPool( - poolAddress, - position, - poolData - ) - - const [isFeeTooltipOpen, setIsFeeTooltipOpen] = useState(false) - const feeRef = useRef(null) - - const Overlay = () => ( -
{ - e.preventDefault() - e.stopPropagation() - setIsFeeTooltipOpen(false) - }} - style={{ - position: 'fixed', - top: 0, - left: 0, - right: 0, - bottom: 0, - zIndex: 1300, - backgroundColor: 'transparent' - }} - /> - ) - const feeFragment = useMemo( - () => ( - setIsFeeTooltipOpen(false)} - disableFocusListener - disableHoverListener - disableTouchListener - title={ - isActive ? ( - <> - The position is active and currently earning a fee as long as the - current price is within the position's price range. - - ) : ( - <> - The position is inactive and not earning a fee as long as the current - price is outside the position's price range. - - ) - } - placement='top' - classes={{ - tooltip: sharedClasses.tooltip - }}> - { - e.stopPropagation() - setIsFeeTooltipOpen(prev => !prev) - }} - style={{ cursor: 'pointer' }}> - - {fee}% fee - - - - ), - [fee, isActive, isFeeTooltipOpen, sharedClasses] - ) - const valueFragment = useMemo( - () => ( - - - Value - - - - {formatNumberWithSuffix(xToY ? valueX : valueY)} {xToY ? tokenXName : tokenYName} - - - - ), - [valueX, valueY, tokenXName, classes, isXs, isDesktop, tokenYName, xToY] - ) - - const handlePromotedInteraction = (event: React.MouseEvent | React.TouchEvent) => { - event.stopPropagation() - if (isPromotedPoolPopoverOpen) { - setIsPromotedPoolPopoverOpen(false) - setAllowPropagation(true) - } else { - setIsPromotedPoolPopoverOpen(true) - setAllowPropagation(false) - } - } - - const handleInactiveInteraction = (event: React.MouseEvent | React.TouchEvent) => { - event.stopPropagation() - - if (isPromotedPoolInactive) { - setIsPromotedPoolInactive(false) - setAllowPropagation(true) - } else { - setIsPromotedPoolInactive(true) - setAllowPropagation(false) - } - } - - useEffect(() => { - const PROPAGATION_ALLOW_TIME = 500 - - const handleClickOutside = (event: TouchEvent | MouseEvent) => { - if ( - airdropIconRef.current && - !(airdropIconRef.current as HTMLElement).contains(event.target as Node) && - !document.querySelector('.promoted-pool-popover')?.contains(event.target as Node) && - !document.querySelector('.promoted-pool-inactive-popover')?.contains(event.target as Node) - ) { - setIsPromotedPoolPopoverOpen(false) - setIsPromotedPoolInactive(false) - setTimeout(() => { - setAllowPropagation(true) - }, PROPAGATION_ALLOW_TIME) - } - } - - if (isPromotedPoolPopoverOpen || isPromotedPoolInactive) { - document.addEventListener('click', handleClickOutside) - document.addEventListener('touchstart', handleClickOutside) - } - - return () => { - document.removeEventListener('click', handleClickOutside) - document.removeEventListener('touchstart', handleClickOutside) - } - }, [isPromotedPoolPopoverOpen, isPromotedPoolInactive, setAllowPropagation]) - - const promotedIconFragment = useMemo(() => { - if (isPromoted && isActive) { - return ( - <> -
{ - if (window.matchMedia('(hover: hover)').matches) { - setIsPromotedPoolPopoverOpen(true) - } - }} - onPointerLeave={() => { - if (window.matchMedia('(hover: hover)').matches) { - setIsPromotedPoolPopoverOpen(false) - setAllowPropagation(true) - } - }}> - {'Airdrop'} -
- { - setIsPromotedPoolPopoverOpen(false) - }} - headerText={ - <> - This position is currently earning points - - } - pointsLabel={'Total points distributed across the pool per 24H:'} - estPoints={estimated24hPoints} - points={new BN(pointsPerSecond, 'hex').muln(24).muln(60).muln(60)} - /> - - ) - } - - return ( - <> - { - setIsPromotedPoolInactive(false) - }} - isActive={isActive} - isPromoted={isPromoted} - /> - -
{ - if (window.matchMedia('(hover: hover)').matches) { - setIsPromotedPoolInactive(true) - } - }} - onPointerLeave={() => { - if (window.matchMedia('(hover: hover)').matches) { - setIsPromotedPoolInactive(false) - setAllowPropagation(true) - } - }}> - {'Airdrop'} -
- - ) - }, [ - isPromoted, - isActive, - isPromotedPoolPopoverOpen, - isPromotedPoolInactive, - classes.actionButton, - airdropIconRef, - estimated24hPoints, - pointsPerSecond, - setIsPromotedPoolPopoverOpen, - setIsPromotedPoolInactive, - setAllowPropagation - ]) - - return ( - <> - {isFeeTooltipOpen && } - - - - - - {xToY - - Arrow { - e.stopPropagation() - setXToY(!xToY) - }} - /> - - {xToY - - - - {xToY ? tokenXName : tokenYName} - {xToY ? tokenYName : tokenXName} - - - - - {networkSelector === NetworkType.Mainnet && ( - - {promotedIconFragment} - - )} - - - - - - - {tokenXPercentage === 100 && ( - - {tokenXPercentage} - {'%'} {xToY ? tokenXName : tokenYName} - - )} - {tokenYPercentage === 100 && ( - - {tokenYPercentage} - {'%'} {xToY ? tokenYName : tokenXName} - - )} - - {tokenYPercentage !== 100 && tokenXPercentage !== 100 && ( - - {tokenXPercentage} - {'%'} {xToY ? tokenXName : tokenYName} {' - '} {tokenYPercentage} - {'%'} {xToY ? tokenYName : tokenXName} - - )} - - - - - - {feeFragment} - {valueFragment} - - - - <> - - MIN - MAX - - - {isFullRange ? ( - FULL RANGE - ) : ( - - {formatNumberWithSuffix(xToY ? min : 1 / max)} -{' '} - {formatNumberWithSuffix(xToY ? max : 1 / min)} {xToY ? tokenYName : tokenXName}{' '} - per {xToY ? tokenXName : tokenYName} - - )} - - - - - {isLocked && ( - - {isLocked ? ( - - Lock - - ) : ( - - Lock - - )} - - )} - - - - ) -} diff --git a/src/components/PositionsList/PositionItem/variants/PositionMobileCard/PositionItemMobile.tsx b/src/components/PositionsList/PositionItem/variants/PositionMobileCard/PositionItemMobile.tsx new file mode 100644 index 000000000..f8b0093e3 --- /dev/null +++ b/src/components/PositionsList/PositionItem/variants/PositionMobileCard/PositionItemMobile.tsx @@ -0,0 +1,524 @@ +import { Box, Button, Grid, Skeleton, Tooltip, Typography } from '@mui/material' +import SwapList from '@static/svg/swap-list.svg' +import { formatNumberWithSuffix } from '@utils/utils' +import classNames from 'classnames' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useMobileStyles } from './style/mobile' +import { TooltipHover } from '@components/TooltipHover/TooltipHover' +import { initialXtoY, tickerToAddress } from '@utils/utils' +import icons from '@static/icons' +import PromotedPoolPopover from '@components/Modals/PromotedPoolPopover/PromotedPoolPopover' +import { BN } from '@coral-xyz/anchor' +import { usePromotedPool } from '@store/hooks/positionList/usePromotedPool' +import { IPositionItem } from '@components/PositionsList/types' +import { useSharedStyles } from './style/shared' +import { InactivePoolsPopover } from '../../components/InactivePoolsPopover/InactivePoolsPopover' +import { NetworkType } from '@store/consts/static' +import { network as currentNetwork } from '@store/selectors/solanaConnection' +import { useDispatch, useSelector } from 'react-redux' +import { useUnclaimedFee } from '@store/hooks/positionList/useUnclaimedFee' +import { singlePositionData } from '@store/selectors/positions' +import { MinMaxChart } from '../../components/MinMaxChart/MinMaxChart' +import { blurContent, unblurContent } from '@utils/uiUtils' +import PositionViewActionPopover from '@components/Modals/PositionViewActionPopover/PositionViewActionPopover' +import LockLiquidityModal from '@components/Modals/LockLiquidityModal/LockLiquidityModal' +import { ILiquidityToken } from '@components/PositionDetails/SinglePositionInfo/consts' +import { actions as lockerActions } from '@store/reducers/locker' +import { lockerState } from '@store/selectors/locker' +import { actions as positionActions } from '@store/reducers/positions' +import { useNavigate } from 'react-router-dom' +import { ISinglePositionData } from '@components/OverviewYourPositions/components/Overview/Overview' + +interface IPositionItemMobile extends IPositionItem { + setAllowPropagation: React.Dispatch> + isLockPositionModalOpen: boolean + setIsLockPositionModalOpen: (value: boolean) => void +} + +export const PositionItemMobile: React.FC = ({ + tokenXName, + tokenYName, + tokenXIcon, + poolAddress, + tokenYIcon, + fee, + min, + max, + position, + id, + setAllowPropagation, + poolData, + isActive = false, + currentPrice, + tokenXLiq, + tokenYLiq, + network, + isLockPositionModalOpen, + setIsLockPositionModalOpen +}) => { + const { classes } = useMobileStyles() + const { classes: sharedClasses } = useSharedStyles() + const airdropIconRef = useRef(null) + const dispatch = useDispatch() + const navigate = useNavigate() + const [isPromotedPoolPopoverOpen, setIsPromotedPoolPopoverOpen] = useState(false) + const [isPromotedPoolInactive, setIsPromotedPoolInactive] = useState(false) + const positionSingleData: ISinglePositionData | undefined = useSelector( + singlePositionData(id ?? '') + ) + + const networkSelector = useSelector(currentNetwork) + + const { isPromoted, pointsPerSecond, estimated24hPoints } = usePromotedPool( + poolAddress, + position, + poolData + ) + + const handlePopoverState = (isOpen: boolean) => { + if (isOpen) { + setAllowPropagation(false) + } else { + setTimeout(() => { + setAllowPropagation(true) + }, 500) + } + } + const handleInteraction = (event: React.MouseEvent) => { + event.stopPropagation() + setIsPromotedPoolPopoverOpen(!isPromotedPoolPopoverOpen) + setAllowPropagation(false) + } + useEffect(() => { + const PROPAGATION_ALLOW_TIME = 500 + + const handleClickOutside = (event: MouseEvent) => { + const isClickInAirdropIcon = + airdropIconRef.current && + (airdropIconRef.current as HTMLElement).contains(event.target as Node) + const isClickInPromotedPopover = document + .querySelector('.promoted-pool-popover') + ?.contains(event.target as Node) + const isClickInInactivePopover = document + .querySelector('.promoted-pool-inactive-popover') + ?.contains(event.target as Node) + + if (!isClickInAirdropIcon && !isClickInPromotedPopover && !isClickInInactivePopover) { + if (isPromotedPoolPopoverOpen) { + setIsPromotedPoolPopoverOpen(false) + } + + if (isPromotedPoolInactive) { + setIsPromotedPoolInactive(false) + } + + setTimeout(() => { + setAllowPropagation(true) + }, PROPAGATION_ALLOW_TIME) + } + } + + if (isPromotedPoolPopoverOpen || isPromotedPoolInactive || isLockPositionModalOpen) { + document.addEventListener('click', handleClickOutside) + } else { + document.removeEventListener('click', handleClickOutside) + } + + return () => { + document.removeEventListener('click', handleClickOutside) + } + }, [isPromotedPoolPopoverOpen, isPromotedPoolInactive, isLockPositionModalOpen]) + + useEffect(() => { + setAllowPropagation(!isLockPositionModalOpen) + }, [isLockPositionModalOpen]) + const promotedIconFragment = useMemo(() => { + if (isPromoted && isActive) { + return ( + <> +
{ + event.stopPropagation() + setIsPromotedPoolPopoverOpen(!isPromotedPoolPopoverOpen) + setAllowPropagation(false) + }}> + {'Airdrop'} +
+ { + setIsPromotedPoolPopoverOpen(false) + setTimeout(() => { + setAllowPropagation(true) + }, 500) + }} + headerText={ + <> + This position is currently earning points + + } + pointsLabel={'Total points distributed across the pool per 24H:'} + estPoints={estimated24hPoints} + points={new BN(pointsPerSecond, 'hex').muln(24).muln(60).muln(60)} + /> + + ) + } + + return ( + <> + { + setIsPromotedPoolInactive(false) + handlePopoverState(false) + }} + isActive={isActive} + isPromoted={isPromoted} + /> +
{ + event.stopPropagation() + const newState = !isPromotedPoolInactive + setIsPromotedPoolInactive(newState) + handlePopoverState(newState) + }} + onPointerEnter={() => { + if (window.matchMedia('(hover: hover)').matches) { + setIsPromotedPoolInactive(true) + handlePopoverState(true) + } + }} + onPointerLeave={() => { + if (window.matchMedia('(hover: hover)').matches) { + setIsPromotedPoolInactive(false) + handlePopoverState(false) + } + }}> + {'Airdrop'} +
+ + ) + }, [ + isPromoted, + isActive, + isPromotedPoolPopoverOpen, + isPromotedPoolInactive, + classes.actionButton, + handleInteraction, + airdropIconRef, + estimated24hPoints, + pointsPerSecond + ]) + const [xToY, setXToY] = useState( + initialXtoY(tickerToAddress(network, tokenXName), tickerToAddress(network, tokenYName)) + ) + + const [isActionPopoverOpen, setActionPopoverOpen] = useState(false) + + const [anchorEl, setAnchorEl] = useState(null) + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget) + blurContent() + setActionPopoverOpen(true) + } + + const handleClose = () => { + unblurContent() + setActionPopoverOpen(false) + } + + const { tokenValueInUsd, tokenXPercentage, tokenYPercentage, unclaimedFeesInUSD } = + useUnclaimedFee({ + currentPrice, + id, + position, + tokenXLiq, + tokenYLiq, + positionSingleData, + xToY + }) + + const topSection = useMemo( + () => ( + + + e.stopPropagation()} + title={ + isActive ? ( + <> + The position is active and currently earning a fee + + ) : ( + <> + The position is inactive and not earning a fee + + ) + } + placement='top' + classes={{ tooltip: sharedClasses.tooltip }}> + + + {fee}% fee + + + + + + + {unclaimedFeesInUSD.loading ? ( + + ) : ( + + + Unclaimed Fee + + ${formatNumberWithSuffix(unclaimedFeesInUSD.value.toFixed(2))} + + + + )} + + + ), + [fee, isActive, unclaimedFeesInUSD] + ) + + const middleSection = useMemo( + () => ( + + + {tokenValueInUsd.loading ? ( + + ) : ( + + + Value + + ${formatNumberWithSuffix(tokenValueInUsd.value)} + + + + )} + + + + + + {tokenXPercentage === 100 && ( + + {tokenXPercentage}% {xToY ? tokenXName : tokenYName} + + )} + {tokenYPercentage === 100 && ( + + {tokenYPercentage}% {xToY ? tokenYName : tokenXName} + + )} + {tokenYPercentage !== 100 && tokenXPercentage !== 100 && ( + + {tokenXPercentage}% {xToY ? tokenXName : tokenYName} - {tokenYPercentage}%{' '} + {xToY ? tokenYName : tokenXName} + + )} + + + + + ), + [tokenValueInUsd, tokenXPercentage, tokenYPercentage, xToY] + ) + + const chartSection = useMemo( + () => ( + + + + ), + [min, max, currentPrice, xToY] + ) + + const lockPosition = () => { + dispatch(lockerActions.lockPosition({ index: 0, network })) + } + + const { value, tokenXLabel, tokenYLabel } = useMemo<{ + value: string + tokenXLabel: string + tokenYLabel: string + }>(() => { + const valueX = tokenXLiq + tokenYLiq / currentPrice + const valueY = tokenYLiq + tokenXLiq * currentPrice + return { + value: `${formatNumberWithSuffix(xToY ? valueX : valueY)} ${xToY ? tokenXName : tokenYName}`, + tokenXLabel: xToY ? tokenXName : tokenYName, + tokenYLabel: xToY ? tokenYName : tokenXName + } + }, [min, max, currentPrice, tokenXName, tokenYName, tokenXLiq, tokenYLiq, xToY]) + + const { success, inProgress } = useSelector(lockerState) + + return ( + + setIsLockPositionModalOpen(false)} + xToY={xToY} + tokenX={{ name: tokenXName, icon: tokenXIcon, liqValue: tokenXLiq } as ILiquidityToken} + tokenY={{ name: tokenYName, icon: tokenYIcon, liqValue: tokenYLiq } as ILiquidityToken} + onLock={lockPosition} + fee={`${fee}% fee`} + minMax={`${formatNumberWithSuffix(xToY ? min : 1 / max)}-${formatNumberWithSuffix(xToY ? max : 1 / min)} ${tokenYLabel} per ${tokenXLabel}`} + value={value} + isActive={isActive} + swapHandler={() => setXToY(!xToY)} + success={success} + inProgress={inProgress} + /> + { + dispatch( + positionActions.claimFee({ + index: positionSingleData?.positionIndex ?? 0, + isLocked: positionSingleData?.isLocked ?? false + }) + ) + }} + closePosition={() => { + dispatch( + positionActions.closePosition({ + positionIndex: positionSingleData?.positionIndex ?? 0, + onSuccess: () => { + navigate('/portfolio') + } + }) + ) + }} + onLockPosition={() => setIsLockPositionModalOpen(true)} + /> + + + + + {xToY + + Arrow { + e.stopPropagation() + setXToY(!xToY) + }} + /> + + {xToY + + + {xToY ? tokenXName : tokenYName} - {xToY ? tokenYName : tokenXName} + + + + + {networkSelector === NetworkType.Mainnet && <>{promotedIconFragment}} + + + + + + {topSection} + {middleSection} + {chartSection} + + ) +} diff --git a/src/components/PositionsList/PositionItem/variants/style/mobile.tsx b/src/components/PositionsList/PositionItem/variants/PositionMobileCard/style/mobile.tsx similarity index 63% rename from src/components/PositionsList/PositionItem/variants/style/mobile.tsx rename to src/components/PositionsList/PositionItem/variants/PositionMobileCard/style/mobile.tsx index 57ff89228..b34d8d9d4 100644 --- a/src/components/PositionsList/PositionItem/variants/style/mobile.tsx +++ b/src/components/PositionsList/PositionItem/variants/PositionMobileCard/style/mobile.tsx @@ -5,6 +5,8 @@ import { makeStyles } from 'tss-react/mui' export const useMobileStyles = makeStyles()((theme: Theme) => ({ root: { padding: 16, + height: '290px', + marginTop: '16px', flexWrap: 'wrap', [theme.breakpoints.down('sm')]: { padding: 8 @@ -21,6 +23,7 @@ export const useMobileStyles = makeStyles()((theme: Theme) => ({ actionButton: { display: 'flex', justifyContent: 'center', + marginRight: '8px', alignItems: 'center' }, minMax: { @@ -32,6 +35,24 @@ export const useMobileStyles = makeStyles()((theme: Theme) => ({ marginRight: 0, marginTop: '8px' }, + button: { + minWidth: '36px', + width: '36px', + height: '36px', + padding: 0, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + background: 'linear-gradient(180deg, #2EE09A 0%, #21A47C 100%)', + borderRadius: '16px', + color: colors.invariant.dark, + transition: 'all 0.3s ease', + '&:hover': { + background: 'linear-gradient(180deg, #3FF2AB 0%, #25B487 100%)', + boxShadow: '0 4px 15px rgba(46, 224, 154, 0.35)' + } + }, + mdInfo: { flexWrap: 'wrap', width: '100%' diff --git a/src/components/PositionsList/PositionItem/variants/style/shared.ts b/src/components/PositionsList/PositionItem/variants/PositionMobileCard/style/shared.ts similarity index 72% rename from src/components/PositionsList/PositionItem/variants/style/shared.ts rename to src/components/PositionsList/PositionItem/variants/PositionMobileCard/style/shared.ts index 33849f5e3..d9ccc22b5 100644 --- a/src/components/PositionsList/PositionItem/variants/style/shared.ts +++ b/src/components/PositionsList/PositionItem/variants/PositionMobileCard/style/shared.ts @@ -54,6 +54,7 @@ export const useSharedStyles = makeStyles()((theme: Theme) => ({ ...typography.heading2, color: colors.invariant.text, lineHeight: '40px', + textAlign: 'left', whiteSpace: 'nowrap', width: 180, [theme.breakpoints.down('xl')]: { @@ -103,15 +104,61 @@ export const useSharedStyles = makeStyles()((theme: Theme) => ({ flex: '1 1 0%' } }, + button: { + ...typography.body1, + display: 'flex', + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + justifySelf: 'center', + maxWidth: '36px', + maxHeight: '36px', + background: 'linear-gradient(180deg, #2EE09A 0%, #21A47C 100%)', + borderRadius: '16px', + fontFamily: 'Mukta', + fontStyle: 'normal', + textTransform: 'none', + color: colors.invariant.dark, + transition: 'all 0.3s ease', + '&:hover': { + background: 'linear-gradient(180deg, #3FF2AB 0%, #25B487 100%)', + boxShadow: '0 4px 15px rgba(46, 224, 154, 0.35)' + }, + '&:active': { + boxShadow: '0 2px 8px rgba(46, 224, 154, 0.35)' + }, + [theme.breakpoints.down('sm')]: { + width: '100%' + } + }, fee: { background: colors.invariant.light, borderRadius: 11, height: 36, - marginRight: 8, + // [theme.breakpoints.up(1361)]: { + // marginRight: 8 + // }, + // [theme.breakpoints.down(1361)]: { + // width: 'auto' + // }, + [theme.breakpoints.down('md')]: { marginRight: 0 } }, + actionButtonContainer: { + marginLeft: '16px', + display: 'flex', + justifyContent: 'center', + alignItems: 'center' + }, + unclaimedFeeContainer: { + display: 'flex', + alignItems: 'center', + gap: '8px', + width: '100%', + justifyContent: 'center' + }, activeFee: { background: colors.invariant.greenLinearGradient }, diff --git a/src/components/PositionsList/PositionItem/variants/PositionTables/PositionsTable.tsx b/src/components/PositionsList/PositionItem/variants/PositionTables/PositionsTable.tsx new file mode 100644 index 000000000..04e52d5ed --- /dev/null +++ b/src/components/PositionsList/PositionItem/variants/PositionTables/PositionsTable.tsx @@ -0,0 +1,118 @@ +import React from 'react' +import { + Box, + Table, + TableBody, + TableCell, + TableContainer, + TableFooter, + TableHead, + TableRow +} from '@mui/material' +import { PositionTableRow } from './PositionsTableRow' +import { IPositionItem } from '../../../types' +import { useNavigate } from 'react-router-dom' +import { usePositionTableStyle } from './styles/positionTable' +import { EmptyPlaceholder } from '@components/EmptyPlaceholder/EmptyPlaceholder' +import { generatePositionTableLoadingData } from '@utils/utils' + +interface IPositionsTableProps { + positions: Array + isLockPositionModalOpen: boolean + setIsLockPositionModalOpen: React.Dispatch> + noInitialPositions?: boolean + onAddPositionClick?: () => void + isLoading?: boolean +} + +export const PositionsTable: React.FC = ({ + positions, + isLockPositionModalOpen, + setIsLockPositionModalOpen, + noInitialPositions, + onAddPositionClick, + isLoading = false +}) => { + const { classes } = usePositionTableStyle({ isScrollHide: positions.length <= 5 || isLoading }) + const navigate = useNavigate() + + const displayData = isLoading ? generatePositionTableLoadingData() : positions + + return ( + + + + + + Pair name + + + Fee tier + + + Token ratio + + Value + Fee + Chart + Action + + + {!isLoading && positions.length === 0 ? ( + + + + + + + + ) : ( + + {displayData.map((position, index) => ( + { + if ( + !isLoading && + !(e.target as HTMLElement).closest('.action-button') && + !isLockPositionModalOpen + ) { + navigate(`/position/${position.id}`) + } + }} + key={position.poolAddress.toString() + index} + className={classes.tableBodyRow}> + + + ))} + + )} + + + + + + + + + + + +
+
+ ) +} diff --git a/src/components/PositionsList/PositionItem/variants/PositionTables/PositionsTableRow.tsx b/src/components/PositionsList/PositionItem/variants/PositionTables/PositionsTableRow.tsx new file mode 100644 index 000000000..f2ea28a5a --- /dev/null +++ b/src/components/PositionsList/PositionItem/variants/PositionTables/PositionsTableRow.tsx @@ -0,0 +1,595 @@ +import { + Grid, + TableRow, + TableCell, + Button, + Tooltip, + Typography, + useMediaQuery, + Box, + Skeleton +} from '@mui/material' +import { useCallback, useMemo, useRef, useState } from 'react' +import { MinMaxChart } from '../../components/MinMaxChart/MinMaxChart' +import { IPositionItem } from '../../../types' +import { colors, theme } from '@static/theme' +import PromotedPoolPopover from '@components/Modals/PromotedPoolPopover/PromotedPoolPopover' +import { BN } from '@coral-xyz/anchor' +import icons from '@static/icons' +import { initialXtoY, tickerToAddress, formatNumberWithoutSuffix } from '@utils/utils' +import classNames from 'classnames' +import { useDispatch, useSelector } from 'react-redux' +import { usePromotedPool } from '@store/hooks/positionList/usePromotedPool' +import { useSharedStyles } from '../PositionMobileCard/style/shared' +import { TooltipHover } from '@components/TooltipHover/TooltipHover' +import SwapList from '@static/svg/swap-list.svg' +import { network as currentNetwork } from '@store/selectors/solanaConnection' +import PositionStatusTooltip from '../../components/PositionStatusTooltip/PositionStatusTooltip' +import PositionViewActionPopover from '@components/Modals/PositionViewActionPopover/PositionViewActionPopover' +import React from 'react' +import { blurContent, unblurContent } from '@utils/uiUtils' +import { singlePositionData } from '@store/selectors/positions' +import LockLiquidityModal from '@components/Modals/LockLiquidityModal/LockLiquidityModal' +import { actions as lockerActions } from '@store/reducers/locker' +import { lockerState } from '@store/selectors/locker' +import { ILiquidityToken } from '@components/PositionDetails/SinglePositionInfo/consts' +import { useUnclaimedFee } from '@store/hooks/positionList/useUnclaimedFee' +import { usePositionTableRowStyle } from './styles/positionTableRow' +import { actions as positionActions } from '@store/reducers/positions' +import { useNavigate } from 'react-router-dom' +import { NetworkType } from '@store/consts/static' + +interface ILoadingStates { + pairName?: boolean + feeTier?: boolean + tokenRatio?: boolean + value?: boolean + unclaimedFee?: boolean + chart?: boolean + actions?: boolean +} + +interface IPositionsTableRow extends IPositionItem { + isLockPositionModalOpen: boolean + setIsLockPositionModalOpen: (value: boolean) => void + loading?: boolean | ILoadingStates + onLockPosition: (index: number, networkType: NetworkType) => void + onClaimFee: (index: number, isLocked: boolean) => void + onClosePosition: (positionIndex: number, onSuccess: () => void) => void +} + +export const PositionTableRow: React.FC = ({ + tokenXName, + tokenYName, + tokenXIcon, + poolAddress, + tokenYIcon, + currentPrice, + id, + fee, + min, + position, + max, + valueX, + valueY, + poolData, + isActive = false, + tokenXLiq, + tokenYLiq, + network, + loading, + isLockPositionModalOpen, + setIsLockPositionModalOpen +}) => { + const { classes } = usePositionTableRowStyle() + const { classes: sharedClasses } = useSharedStyles() + const [xToY, setXToY] = useState( + initialXtoY(tickerToAddress(network, tokenXName), tickerToAddress(network, tokenYName)) + ) + const positionSingleData = useSelector(singlePositionData(id ?? '')) + const networkType = useSelector(currentNetwork) + const airdropIconRef = useRef(null) + const [isPromotedPoolPopoverOpen, setIsPromotedPoolPopoverOpen] = useState(false) + const isXs = useMediaQuery(theme.breakpoints.down('xs')) + const navigate = useNavigate() + const isDesktop = useMediaQuery(theme.breakpoints.up('lg')) + + const isItemLoading = (item: keyof ILoadingStates): boolean => { + if (typeof loading === 'boolean') return loading + return loading?.[item] ?? false + } + + const { tokenValueInUsd, tokenXPercentage, tokenYPercentage, unclaimedFeesInUSD } = + useUnclaimedFee({ + currentPrice, + id, + position, + tokenXLiq, + tokenYLiq, + positionSingleData, + xToY + }) + + const { isPromoted, pointsPerSecond, estimated24hPoints } = usePromotedPool( + poolAddress, + position, + poolData + ) + + const pairNameContent = useMemo(() => { + if (isItemLoading('pairName')) { + return ( + + + + + + + ) + } + + return ( + + + {xToY + + Arrow { + e.stopPropagation() + setXToY(!xToY) + }} + /> + + {xToY + + + + {xToY ? tokenXName : tokenYName} - {xToY ? tokenYName : tokenXName} + + + ) + }, [loading, xToY, tokenXIcon, tokenYIcon, tokenXName, tokenYName]) + + const handleMouseEnter = useCallback(() => { + setIsPromotedPoolPopoverOpen(true) + }, []) + + const handleMouseLeave = useCallback(() => { + setIsPromotedPoolPopoverOpen(false) + }, []) + + const [isTooltipOpen, setIsTooltipOpen] = useState(false) + + const handleTooltipEnter = useCallback(() => { + setIsTooltipOpen(true) + }, []) + + const handleTooltipLeave = useCallback(() => { + setIsTooltipOpen(false) + }, []) + + const feeFragment = useMemo(() => { + if (isItemLoading('feeTier')) { + return ( + + ) + } + return ( + e.stopPropagation()} + title={ + isActive ? ( + <> + The position is active and currently earning a fee as long as the + current price is within the position's price range. + + ) : ( + <> + The position is inactive and not earning a fee as long as the current + price is outside the position's price range. + + ) + } + placement='top' + classes={{ + tooltip: sharedClasses.tooltip + }}> + + + {fee}% + + + + ) + }, [fee, classes, isActive]) + + const tokenRatioContent = useMemo(() => { + if (isItemLoading('tokenRatio')) { + return ( + + ) + } + + return ( + + {tokenXPercentage === 100 && ( + + {tokenXPercentage} + {'%'} {xToY ? tokenXName : tokenYName} + + )} + {tokenYPercentage === 100 && ( + + {tokenYPercentage} + {'%'} {xToY ? tokenYName : tokenXName} + + )} + + {tokenYPercentage !== 100 && tokenXPercentage !== 100 && ( + + {tokenXPercentage} + {'%'} {xToY ? tokenXName : tokenYName} {' - '} {tokenYPercentage} + {'%'} {xToY ? tokenYName : tokenXName} + + )} + + ) + }, [tokenXPercentage, tokenYPercentage, xToY, tokenXName, tokenYName, loading]) + + const valueFragment = useMemo(() => { + if (isItemLoading('value') || tokenValueInUsd.loading) { + return ( + + ) + } + + return ( + + + + {`$${formatNumberWithoutSuffix(tokenValueInUsd.value, { twoDecimals: true })}`} + + + + ) + }, [ + tokenValueInUsd, + valueX, + valueY, + tokenXName, + classes, + isXs, + isDesktop, + tokenYName, + xToY, + loading + ]) + + const unclaimedFee = useMemo(() => { + if (isItemLoading('unclaimedFee') || unclaimedFeesInUSD.loading) { + return ( + + ) + } + return ( + + + + ${formatNumberWithoutSuffix(unclaimedFeesInUSD.value, { twoDecimals: true })} + + + + ) + }, [unclaimedFeesInUSD, classes, loading]) + + const chartFragment = useMemo(() => { + if (isItemLoading('chart')) { + return ( + + ) + } + + return ( + + ) + }, [min, max, currentPrice, xToY, loading]) + + const actionsFragment = useMemo(() => { + if (isItemLoading('actions')) { + return ( + + ) + } + + return ( + + ) + }, [loading]) + + const promotedIconContent = useMemo(() => { + if (isPromoted && isActive) { + return ( + <> +
e.stopPropagation()} + className={classes.actionButton} + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave}> + {'Airdrop'} +
+ setIsPromotedPoolPopoverOpen(false)} + headerText={ + <> + This position is currently earning points + + } + pointsLabel={'Total points distributed across the pool per 24H:'} + estPoints={estimated24hPoints} + points={new BN(pointsPerSecond, 'hex').muln(24).muln(60).muln(60)} + /> + + ) + } + + return ( + setIsTooltipOpen(true)} + onClose={() => setIsTooltipOpen(false)} + enterTouchDelay={0} + leaveTouchDelay={0} + onClick={e => e.stopPropagation()} + title={ +
+ +
+ } + placement='top' + classes={{ + tooltip: sharedClasses.tooltip + }}> +
+ {'Airdrop'} +
+
+ ) + }, [ + isPromoted, + isActive, + isPromotedPoolPopoverOpen, + isTooltipOpen, + handleMouseEnter, + handleMouseLeave, + handleTooltipEnter, + handleTooltipLeave, + estimated24hPoints, + pointsPerSecond + ]) + + const [isActionPopoverOpen, setActionPopoverOpen] = React.useState(false) + + const [anchorEl, setAnchorEl] = React.useState(null) + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget) + blurContent() + setActionPopoverOpen(true) + } + + const handleClose = () => { + unblurContent() + setActionPopoverOpen(false) + } + + const dispatch = useDispatch() + + const lockPosition = () => { + dispatch(lockerActions.lockPosition({ index: 0, network: networkType })) + } + + const { value, tokenXLabel, tokenYLabel } = useMemo<{ + value: string + tokenXLabel: string + tokenYLabel: string + }>(() => { + const valueX = tokenXLiq + tokenYLiq / currentPrice + const valueY = tokenYLiq + tokenXLiq * currentPrice + return { + value: `${formatNumberWithoutSuffix(xToY ? valueX : valueY)} ${xToY ? tokenXName : tokenYName}`, + tokenXLabel: xToY ? tokenXName : tokenYName, + tokenYLabel: xToY ? tokenYName : tokenXName + } + }, [min, max, currentPrice, tokenXName, tokenYName, tokenXLiq, tokenYLiq, xToY]) + + const { success, inProgress } = useSelector(lockerState) + + return ( + + setIsLockPositionModalOpen(false)} + xToY={xToY} + tokenX={{ name: tokenXName, icon: tokenXIcon, liqValue: tokenXLiq } as ILiquidityToken} + tokenY={{ name: tokenYName, icon: tokenYIcon, liqValue: tokenYLiq } as ILiquidityToken} + onLock={lockPosition} + fee={`${fee}% fee`} + minMax={`${formatNumberWithoutSuffix(xToY ? min : 1 / max)}-${formatNumberWithoutSuffix(xToY ? max : 1 / min)} ${tokenYLabel} per ${tokenXLabel}`} + value={value} + isActive={isActive} + swapHandler={() => setXToY(!xToY)} + success={success} + inProgress={inProgress} + /> + { + dispatch( + positionActions.claimFee({ + index: positionSingleData?.positionIndex ?? 0, + isLocked: positionSingleData?.isLocked ?? false + }) + ) + }} + closePosition={() => { + dispatch( + positionActions.closePosition({ + positionIndex: positionSingleData?.positionIndex ?? 0, + onSuccess: () => { + navigate('/portfolio') + } + }) + ) + }} + onLockPosition={() => setIsLockPositionModalOpen(true)} + /> + + {pairNameContent} + + + + + {promotedIconContent} + {feeFragment} + + + + + {tokenRatioContent} + + + {valueFragment} + + {unclaimedFee} + + {chartFragment} + + + {actionsFragment} + + + ) +} diff --git a/src/components/PositionsList/PositionItem/variants/PositionTables/skeletons/PositionCardsSkeletonMobile.tsx b/src/components/PositionsList/PositionItem/variants/PositionTables/skeletons/PositionCardsSkeletonMobile.tsx new file mode 100644 index 000000000..ae357be9d --- /dev/null +++ b/src/components/PositionsList/PositionItem/variants/PositionTables/skeletons/PositionCardsSkeletonMobile.tsx @@ -0,0 +1,85 @@ +import { Box, Grid, Skeleton } from '@mui/material' +import { useMobileSkeletonStyles } from './styles/mobileSkeleton' + +const PositionCardsSkeletonMobile = () => { + const { classes } = useMobileSkeletonStyles() + const cards = [1, 2, 3] + + return ( + <> + {cards.map(index => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ))} + + ) +} + +export default PositionCardsSkeletonMobile diff --git a/src/components/PositionsList/PositionItem/variants/PositionTables/skeletons/styles/mobileSkeleton.ts b/src/components/PositionsList/PositionItem/variants/PositionTables/skeletons/styles/mobileSkeleton.ts new file mode 100644 index 000000000..471dc9825 --- /dev/null +++ b/src/components/PositionsList/PositionItem/variants/PositionTables/skeletons/styles/mobileSkeleton.ts @@ -0,0 +1,54 @@ +import { colors, theme } from '@static/theme' +import { makeStyles } from 'tss-react/mui' +export const useMobileSkeletonStyles = makeStyles()(() => ({ + card: { + height: '290px', + [theme.breakpoints.between('sm', 'lg')]: { + padding: '16px', + paddingTop: '16px' + }, + padding: '8px', + background: colors.invariant.component, + borderRadius: '24px', + '&:first-child': { + marginTop: '16px' + }, + marginBottom: '16px' + }, + + mobileCardSkeletonHeader: { + display: 'flex', + alignItems: 'center', + height: '36px' + }, + basicSkeleton: { + borderRadius: '10px', + margin: '0 auto' + }, + circularSkeleton: { + width: '28px', + height: '28px', + [theme.breakpoints.between('sm', 'lg')]: { + width: '40px', + height: '40px' + } + }, + circularSkeletonSmall: { + width: '24px', + height: '24px', + [theme.breakpoints.between('sm', 'lg')]: { + width: '30px', + height: '30px' + } + }, + + tokenIcons: { + display: 'flex', + alignItems: 'center', + gap: '8px' + }, + chartContainer: { + width: '80%', + margin: '0 auto' + } +})) diff --git a/src/components/PositionsList/PositionItem/variants/PositionTables/styles/positionTable.ts b/src/components/PositionsList/PositionItem/variants/PositionTables/styles/positionTable.ts new file mode 100644 index 000000000..06fe9e3b8 --- /dev/null +++ b/src/components/PositionsList/PositionItem/variants/PositionTables/styles/positionTable.ts @@ -0,0 +1,184 @@ +import { Theme } from '@mui/material' +import { colors } from '@static/theme' +import { makeStyles } from 'tss-react/mui' + +export const usePositionTableStyle = makeStyles<{ isScrollHide: boolean }>()( + (_theme: Theme, { isScrollHide }) => ({ + tableContainer: { + width: 'fit-content', + background: 'transparent', + boxShadow: 'none', + display: 'flex', + flexDirection: 'column' + }, + table: { + borderCollapse: 'separate', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden' + }, + cellBase: { + padding: '14px 20px', + background: 'inherit', + border: 'none', + borderTop: `1px solid ${colors.invariant.light}`, + + whiteSpace: 'nowrap', + textAlign: 'center' + }, + headerRow: { + height: '50px', + background: colors.invariant.component, + '& th:first-of-type': { + borderTopLeftRadius: '24px' + }, + '& th:last-child': { + borderTopRightRadius: '24px' + } + }, + headerCell: { + fontSize: '20px', + lineHeight: '24px', + borderBottom: `1px solid ${colors.invariant.light}`, + + color: colors.invariant.textGrey, + fontWeight: 600, + textAlign: 'left' + }, + + pairNameCell: { + width: '25%', + textAlign: 'left', + padding: '14px 41px 14px 22px !important' + }, + pointsCell: { + width: '8%', + '& > div': { + justifyContent: 'center' + } + }, + feeTierCell: { + width: '8%', + '& > div': { + justifyContent: 'center' + } + }, + tokenRatioCell: { + width: '18%', + '& > div': { + margin: '0 auto' + } + }, + valueCell: { + width: '9%', + '& .MuiGrid-root': { + justifyContent: 'center' + } + }, + feeCell: { + width: '10%', + '& .MuiGrid-root': { + justifyContent: 'center' + } + }, + chartCell: { + width: '16%', + '& > div': { + margin: '0 auto' + } + }, + actionCell: { + width: '8%', + padding: '14px 8px', + '& > button': { + margin: '0 auto' + } + }, + tableHead: { + display: 'table', + width: '100%', + tableLayout: 'fixed' + }, + tableBody: { + display: 'block', + height: 'calc(4 * (20px + 82px))', + overflowY: 'auto', + background: colors.invariant.component, + '&::-webkit-scrollbar': { + width: '4px' + }, + '&::-webkit-scrollbar-track': { + background: colors.invariant.componentDark + }, + '&::-webkit-scrollbar-thumb': { + background: isScrollHide ? 'transparent' : colors.invariant.pink, + borderRadius: '4px' + }, + + '& > tr:nth-of-type(even)': { + background: colors.invariant.component, + transition: 'filter .05s ease-in-out', + + '&:hover': { + filter: 'brightness(1.1)', + cursor: 'pointer' + } + }, + '& > tr:nth-of-type(odd)': { + background: `${colors.invariant.componentDark}F0`, + transition: 'filter .05s ease-in-out', + '&:hover': { + filter: 'brightness(1.1)', + + cursor: 'pointer' + } + }, + '& > tr': { + background: 'transparent', + '& td': { + borderBottom: `1px solid ${colors.invariant.light}` + } + }, + '& > tr:first-of-type td': { + borderTop: `1px solid ${colors.invariant.light}` + } + }, + tableBodyRow: { + display: 'table', + width: '100%', + height: '82.6px', + tableLayout: 'fixed' + }, + tableFooter: { + display: 'table', + width: '100%', + tableLayout: 'fixed' + }, + footerRow: { + background: colors.invariant.component, + height: '56.8px', + '& td:first-of-type': { + borderBottomLeftRadius: '24px' + }, + '& td:last-child': { + borderBottomRightRadius: '24px' + } + }, + + emptyContainer: { + border: 'none', + padding: 0, + height: '100%', + display: 'flex', + alignItems: 'center', + width: '100%' + }, + emptyWrapper: { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + height: '90%', + width: '100%' + } + }) +) diff --git a/src/components/PositionsList/PositionItem/variants/PositionTables/styles/positionTableRow.ts b/src/components/PositionsList/PositionItem/variants/PositionTables/styles/positionTableRow.ts new file mode 100644 index 000000000..6ff3a257f --- /dev/null +++ b/src/components/PositionsList/PositionItem/variants/PositionTables/styles/positionTableRow.ts @@ -0,0 +1,171 @@ +import { Theme } from '@mui/material' +import { colors } from '@static/theme' +import { makeStyles } from 'tss-react/mui' +export const usePositionTableRowStyle = makeStyles()((theme: Theme) => ({ + cellBase: { + padding: '20px', + paddingTop: '4px !important', + paddingBottom: '4px !important', + background: 'inherit', + border: 'none', + whiteSpace: 'nowrap', + textAlign: 'center' + }, + + pairNameCell: { + width: '25%', + textAlign: 'left', + padding: '14px 41px 14px 22px !important' + }, + itemCellContainer: { + width: 100, + [theme.breakpoints.down(1029)]: { + marginRight: 0 + }, + [theme.breakpoints.down('sm')]: { + width: 144, + paddingInline: 6 + } + }, + pointsCell: { + width: '8%', + '& > div': { + justifyContent: 'center' + } + }, + + feeTierCell: { + width: '10%', + padding: '0 !important', + '& > .MuiBox-root': { + justifyContent: 'center', + gap: '8px' + } + }, + + tokenRatioCell: { + paddingLeft: '15px', + + width: '18%', + '& > .MuiTypography-root': { + margin: '0 auto', + maxWidth: '90%' + } + }, + + valueCell: { + paddingLeft: 0, + width: '10%', + '& .MuiGrid-root': { + margin: '0 auto', + justifyContent: 'center' + } + }, + + feeCell: { + paddingLeft: 0, + + width: '10%', + '& .MuiGrid-root': { + margin: '0 auto', + justifyContent: 'center' + } + }, + + chartCell: { + width: '16%' + }, + + actionCell: { + width: '8%', + padding: '14px 8px', + '& > .MuiButton-root': { + margin: '0 auto' + } + }, + + iconsAndNames: { + width: 'fit-content', + display: 'flex', + alignItems: 'center' + }, + + icons: { + marginRight: 12, + display: 'flex', + alignItems: 'center', + gap: '4px' + }, + + tokenIcon: { + width: 40, + borderRadius: '100%', + [theme.breakpoints.down('sm')]: { + width: 28 + } + }, + + arrows: { + width: 36, + cursor: 'pointer', + transition: 'filter 0.2s', + '&:hover': { + filter: 'brightness(2)' + } + }, + + button: { + minWidth: '36px', + width: '36px', + height: '36px', + padding: 0, + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + background: 'linear-gradient(180deg, #2EE09A 0%, #21A47C 100%)', + borderRadius: '12px', + color: colors.invariant.dark, + transition: 'all 0.3s ease', + '&:hover': { + background: 'linear-gradient(180deg, #3FF2AB 0%, #25B487 100%)', + boxShadow: '0 4px 15px rgba(46, 224, 154, 0.35)' + } + }, + + blur: { + width: 120, + height: 30, + borderRadius: 16, + background: `linear-gradient(90deg, ${colors.invariant.component} 25%, ${colors.invariant.light} 50%, ${colors.invariant.component} 75%)`, + backgroundSize: '200% 100%', + animation: 'shimmer 2s infinite' + }, + + valueWrapper: { + margin: '0 auto', + width: '100%', + maxWidth: 144, + display: 'flex', + justifyContent: 'center' + }, + actionButton: { + background: 'none', + padding: 0, + margin: 0, + border: 'none', + display: 'inline-flex', + position: 'relative', + color: colors.invariant.black, + textTransform: 'none', + + transition: 'filter 0.2s linear', + + '&:hover': { + filter: 'brightness(1.2)', + cursor: 'pointer', + '@media (hover: none)': { + filter: 'none' + } + } + } +})) diff --git a/src/components/PositionsList/PositionItem/variants/style/desktop.ts b/src/components/PositionsList/PositionItem/variants/style/desktop.ts deleted file mode 100644 index 53df499b7..000000000 --- a/src/components/PositionsList/PositionItem/variants/style/desktop.ts +++ /dev/null @@ -1,293 +0,0 @@ -// import { Theme } from '@mui/material' -// import { colors, typography } from '@static/theme' -// import { makeStyles } from 'tss-react/mui' -// //desktop style -// export const useStyles = makeStyles()((theme: Theme) => ({ -// root: { -// background: colors.invariant.component, -// borderRadius: 24, -// padding: 20, -// flexWrap: 'nowrap', -// '&:not(:last-child)': { -// marginBottom: 20 -// }, - -// '&:hover': { -// background: `${colors.invariant.component}B0` -// }, - -// [theme.breakpoints.down('lg')]: { -// padding: 16, -// flexWrap: 'wrap' -// } -// }, -// icons: { -// marginRight: 12, -// width: 'fit-content', - -// [theme.breakpoints.down('lg')]: { -// marginRight: 12 -// } -// }, -// tokenIcon: { -// width: 40, -// borderRadius: '100%', - -// [theme.breakpoints.down('sm')]: { -// width: 28 -// } -// }, -// actionButton: { -// background: 'none', -// padding: 0, -// margin: 0, -// border: 'none', -// display: 'inline-flex', -// position: 'relative', -// color: colors.invariant.black, -// textTransform: 'none', - -// transition: 'filter 0.2s linear', - -// '&:hover': { -// filter: 'brightness(1.2)', -// cursor: 'pointer', -// '@media (hover: none)': { -// filter: 'none' -// } -// } -// }, -// arrows: { -// width: 36, -// marginLeft: 4, -// marginRight: 4, - -// [theme.breakpoints.down('lg')]: { -// width: 30 -// }, - -// [theme.breakpoints.down('sm')]: { -// width: 24 -// }, - -// '&:hover': { -// filter: 'brightness(2)' -// } -// }, -// names: { -// textOverflow: 'ellipsis', -// overflow: 'hidden', -// ...typography.heading2, -// color: colors.invariant.text, -// lineHeight: '40px', -// whiteSpace: 'nowrap', -// width: 180, -// [theme.breakpoints.down('xl')]: { -// ...typography.heading2 -// }, -// [theme.breakpoints.down('lg')]: { -// lineHeight: '32px', -// width: 'unset' -// }, -// [theme.breakpoints.down('sm')]: { -// ...typography.heading3, -// lineHeight: '25px' -// } -// }, -// infoText: { -// ...typography.body1, -// color: colors.invariant.lightGrey, -// whiteSpace: 'nowrap', -// textOverflow: 'ellipsis', -// overflow: 'hidden', -// [theme.breakpoints.down('sm')]: { -// ...typography.caption1, -// padding: '0 4px' -// } -// }, -// activeInfoText: { -// color: colors.invariant.black -// }, -// greenText: { -// ...typography.body1, -// color: colors.invariant.green, -// whiteSpace: 'nowrap', -// textOverflow: 'ellipsis', -// overflow: 'hidden', -// [theme.breakpoints.down('sm')]: { -// ...typography.caption1 -// } -// }, -// liquidity: { -// background: colors.invariant.light, -// borderRadius: 11, -// height: 36, -// width: 170, -// marginRight: 8, -// lineHeight: 20, -// paddingInline: 10, -// [theme.breakpoints.down('lg')]: { -// flex: '1 1 0%' -// } -// }, -// fee: { -// background: colors.invariant.light, -// borderRadius: 11, -// height: 36, -// width: 90, -// marginRight: 8, - -// [theme.breakpoints.down('md')]: { -// marginRight: 0 -// } -// }, -// activeFee: { -// background: colors.invariant.greenLinearGradient -// }, -// infoCenter: { -// flex: '1 1 0%' -// }, -// minMax: { -// background: colors.invariant.light, -// borderRadius: 11, -// height: 36, -// width: 320, -// paddingInline: 10, -// marginRight: 8, - -// [theme.breakpoints.down('md')]: { -// width: '100%', -// marginRight: 0, -// marginTop: 8 -// } -// }, -// dropdown: { -// background: colors.invariant.greenLinearGradient, -// borderRadius: 11, -// height: 36, -// width: 57, -// paddingInline: 10, -// marginRight: 8, - -// [theme.breakpoints.down(1029)]: { -// width: '100%', -// marginRight: 0, -// marginTop: 8 -// } -// }, -// dropdownLocked: { -// background: colors.invariant.lightHover -// }, -// dropdownText: { -// color: colors.invariant.black, -// width: '100%' -// }, -// value: { -// background: colors.invariant.light, -// borderRadius: 11, -// height: 36, -// width: 160, -// paddingInline: 12, -// marginRight: 8, - -// [theme.breakpoints.down(1029)]: { -// marginRight: 0 -// }, -// [theme.breakpoints.down('sm')]: { -// width: 144, -// paddingInline: 6 -// } -// }, -// mdInfo: { -// width: 'fit-content', -// flexWrap: 'nowrap', - -// [theme.breakpoints.down('lg')]: { -// flexWrap: 'nowrap', -// marginTop: 16, -// width: '100%' -// }, - -// [theme.breakpoints.down(1029)]: { -// flexWrap: 'wrap' -// } -// }, -// mdTop: { -// width: 'fit-content', - -// [theme.breakpoints.down('lg')]: { -// width: '100%', -// justifyContent: 'space-between' -// } -// }, -// iconsAndNames: { -// width: 'fit-content' -// }, -// label: { -// marginRight: 2 -// }, -// tooltip: { -// color: colors.invariant.textGrey, -// ...typography.caption4, -// lineHeight: '24px', -// background: colors.black.full, -// borderRadius: 12, -// fontSize: 14 -// } -// })) -import { Theme } from '@mui/material' -import { colors } from '@static/theme' -import { makeStyles } from 'tss-react/mui' - -export const useDesktopStyles = makeStyles()((theme: Theme) => ({ - root: { - padding: 20, - flexWrap: 'nowrap', - background: colors.invariant.component, - borderRadius: 24, - '&:not(:last-child)': { - marginBottom: 20 - }, - '&:hover': { - background: `${colors.invariant.component}B0` - } - }, - actionButton: { - display: 'inline-flex' - }, - minMax: { - background: colors.invariant.light, - borderRadius: 11, - height: 36, - width: 320, - paddingInline: 10, - marginRight: 8, - [theme.breakpoints.down('md')]: { - width: '100%', - marginRight: 0, - marginTop: 8 - } - }, - mdInfo: { - width: 'fit-content', - flexWrap: 'nowrap', - [theme.breakpoints.down('lg')]: { - flexWrap: 'nowrap', - marginTop: 16, - width: '100%' - }, - [theme.breakpoints.down(1029)]: { - flexWrap: 'wrap' - } - }, - mdTop: { - width: 'fit-content', - [theme.breakpoints.down('lg')]: { - width: '100%', - justifyContent: 'space-between' - } - }, - iconsAndNames: { - width: 'fit-content' - } -})) diff --git a/src/components/PositionsList/PositionsList.tsx b/src/components/PositionsList/PositionsList.tsx index 9e19cbc96..ee9401455 100644 --- a/src/components/PositionsList/PositionsList.tsx +++ b/src/components/PositionsList/PositionsList.tsx @@ -1,4 +1,3 @@ -import { EmptyPlaceholder } from '@components/EmptyPlaceholder/EmptyPlaceholder' import { INoConnected, NoConnected } from '@components/NoConnected/NoConnected' import { Box, @@ -9,18 +8,19 @@ import { Typography, useMediaQuery } from '@mui/material' -import loader from '@static/gif/loader.gif' import refreshIcon from '@static/svg/refresh.svg' import { useEffect, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useStyles } from './style' import { TooltipHover } from '@components/TooltipHover/TooltipHover' -import { PaginationList } from '@components/Pagination/Pagination' import { useDispatch } from 'react-redux' import { actions } from '@store/reducers/leaderboard' -import { PositionItemDesktop } from './PositionItem/variants/PositionItemDesktop' -import { PositionItemMobile } from './PositionItem/variants/PositionItemMobile' +import { PositionItemMobile } from './PositionItem/variants/PositionMobileCard/PositionItemMobile' import { IPositionItem } from './types' +import { blurContent, unblurContent } from '@utils/uiUtils' +import { PositionsTable } from './PositionItem/variants/PositionTables/PositionsTable' +import { EmptyPlaceholder } from '@components/EmptyPlaceholder/EmptyPlaceholder' +import PositionCardsSkeletonMobile from './PositionItem/variants/PositionTables/skeletons/PositionCardsSkeletonMobile' import { FilterSearch, ISearchToken } from '@components/FilterSearch/FilterSearch' import { NetworkType } from '@store/consts/static' import { theme } from '@static/theme' @@ -40,25 +40,19 @@ interface IProps { noConnectedBlockerProps: INoConnected itemsPerPage: number handleRefresh: () => void - // pageChanged: (page: number) => void length: number lockedLength: number - // loadedPages: Record - // getRemainingPositions: () => void noInitialPositions: boolean lockedData: IPositionItem[] currentNetwork: NetworkType } export const PositionsList: React.FC = ({ - initialPage, - setLastPage, data, onAddPositionClick, loading = false, showNoConnected = false, noConnectedBlockerProps, - itemsPerPage, handleRefresh, // pageChanged, // length, @@ -71,9 +65,7 @@ export const PositionsList: React.FC = ({ }) => { const { classes } = useStyles() const navigate = useNavigate() - const [defaultPage] = useState(initialPage) const dispatch = useDispatch() - const [page, setPage] = useState(initialPage) const [alignment, setAlignment] = useState(LiquidityPools.Standard) const [selectedFilters, setSelectedFilters] = useState([]) const isLg = useMediaQuery('@media (max-width: 1360px)') @@ -112,52 +104,85 @@ export const PositionsList: React.FC = ({ }) }, [currentData, selectedFilters]) - const handleChangePagination = (page: number): void => { - setLastPage(page) - setPage(page) - } - const handleSwitchPools = ( _: React.MouseEvent, newAlignment: LiquidityPools | null ) => { if (newAlignment !== null) { setAlignment(newAlignment) - setPage(1) } } - const paginator = (currentPage: number) => { - const page = currentPage || 1 - const perPage = itemsPerPage || 10 - const offset = (page - 1) * perPage - const paginatedItems = filteredData.slice(offset).slice(0, itemsPerPage) - const totalPages = Math.ceil(filteredData.length / perPage) + useEffect(() => { + dispatch(actions.getLeaderboardConfig()) + }, [dispatch]) - return { - page: page, - totalPages: totalPages, - data: paginatedItems - } - } + const [isLockPositionModalOpen, setIsLockPositionModalOpen] = useState(false) useEffect(() => { - setPage(1) - }, [selectedFilters]) + if (isLockPositionModalOpen) { + blurContent() + } else { + unblurContent() + } + }, [isLockPositionModalOpen]) - useEffect(() => { - setPage(initialPage) - }, []) + const [allowPropagation, setAllowPropagation] = useState(true) - useEffect(() => { - handleChangePagination(initialPage) - }, [initialPage]) + const renderContent = () => { + if (showNoConnected) { + return + } - useEffect(() => { - dispatch(actions.getLeaderboardConfig()) - }, [dispatch]) + if (!isLg) { + return ( + + ) + } else if (isLg && loading) { + return + } - const [allowPropagation, setAllowPropagation] = useState(true) + if (filteredData.length === 0 && !loading) { + return ( + + ) + } + + return filteredData.map((element, index) => ( + { + if (allowPropagation) { + navigate(`/position/${element.id}`) + } + }} + key={element.id} + className={classes.itemLink}> + + + )) + } return ( @@ -368,55 +393,8 @@ export const PositionsList: React.FC = ({ )} - {filteredData.length > 0 && !loading && !showNoConnected ? ( - paginator(page).data.map((element, index) => ( - { - if (allowPropagation) { - navigate(`/position/${element.id}`) - } - }} - key={element.id} - className={classes.itemLink}> - {isLg ? ( - - ) : ( - - )} - - )) - ) : showNoConnected ? ( - - ) : loading ? ( - - Loader - - ) : ( - - )} + {renderContent()} - {paginator(page).totalPages > 1 ? ( - - ) : null} ) } diff --git a/src/components/PositionsList/style.ts b/src/components/PositionsList/style.ts index 0448ecab1..df55da4c4 100644 --- a/src/components/PositionsList/style.ts +++ b/src/components/PositionsList/style.ts @@ -125,12 +125,7 @@ export const useStyles = makeStyles()((theme: Theme) => ({ cursor: 'pointer', '&:not(:last-child)': { - display: 'block', - marginBottom: 20, - - [theme.breakpoints.down('md')]: { - marginBottom: 16 - } + display: 'block' } }, searchIcon: { diff --git a/src/components/PriceRangePlot/PriceRangePlot.tsx b/src/components/PriceRangePlot/PriceRangePlot.tsx index 7e77829f3..ec39eca2f 100644 --- a/src/components/PriceRangePlot/PriceRangePlot.tsx +++ b/src/components/PriceRangePlot/PriceRangePlot.tsx @@ -68,7 +68,7 @@ export const PriceRangePlot: React.FC = ({ const containerRef = useRef(null) - const maxVal = useMemo(() => Math.max(...data.map(element => element?.y)), [data]) + const maxVal = useMemo(() => Math.max(...data.map(element => element.y)), [data]) const pointsOmitter = useCallback( (data: Array<{ x: number; y: number }>) => { @@ -88,8 +88,7 @@ export const PriceRangePlot: React.FC = ({ (dataAfterOmit.length > 0 && ((tick.x - dataAfterOmit[dataAfterOmit.length - 1].x) / (plotMax - plotMin) >= minXDist || - Math.abs(tick?.y - dataAfterOmit[dataAfterOmit.length - 1]?.y) / maxVal >= - minYChange)) + Math.abs(tick.y - dataAfterOmit[dataAfterOmit.length - 1].y) / maxVal >= minYChange)) ) { dataAfterOmit.push(tick) } @@ -117,7 +116,7 @@ export const PriceRangePlot: React.FC = ({ if (rangeData[rangeData.length - 1].x < leftRange.x) { rangeData.push({ x: leftRange.x, - y: rangeData[rangeData.length - 1]?.y + y: rangeData[rangeData.length - 1].y }) } @@ -126,7 +125,7 @@ export const PriceRangePlot: React.FC = ({ if (rangeData[0].x > Math.max(plotMin, data[0].x)) { rangeData.unshift({ x: Math.max(plotMin, data[0].x), - y: outData.length > 0 ? outData[outData.length - 1]?.y : 0 + y: outData.length > 0 ? outData[outData.length - 1].y : 0 }) } @@ -149,14 +148,14 @@ export const PriceRangePlot: React.FC = ({ if (!rangeData.length || rangeData[0].x > Math.max(plotMin, data[0].x)) { rangeData.unshift({ x: Math.max(plotMin, data[0].x), - y: outMinData.length > 0 ? outMinData[outMinData.length - 1]?.y : 0 + y: outMinData.length > 0 ? outMinData[outMinData.length - 1].y : 0 }) } if (rangeData[rangeData.length - 1].x < Math.min(plotMax, data[data.length - 1].x)) { rangeData.push({ x: Math.min(plotMax, data[data.length - 1].x), - y: rangeData[rangeData.length - 1]?.y + y: rangeData[rangeData.length - 1].y }) } @@ -175,25 +174,25 @@ export const PriceRangePlot: React.FC = ({ if (!rangeData.length) { rangeData.push({ x: Math.max(leftRange.x, plotMin), - y: data[lessThan - 1]?.y + y: data[lessThan - 1].y }) rangeData.push({ x: Math.min(rightRange.x, plotMax), - y: data[lessThan - 1]?.y + y: data[lessThan - 1].y }) } else { if (rangeData[0].x > leftRange.x) { rangeData.unshift({ x: leftRange.x, - y: rangeData[0]?.y + y: rangeData[0].y }) } if (rangeData[rangeData.length - 1].x < rightRange.x) { rangeData.push({ x: rightRange.x, - y: rangeData[rangeData.length - 1]?.y + y: rangeData[rangeData.length - 1].y }) } @@ -211,7 +210,7 @@ export const PriceRangePlot: React.FC = ({ if (!newRangeData.length || newRangeData[0].x > Math.max(plotMin, rangeData[0].x)) { newRangeData.unshift({ x: Math.max(plotMin, rangeData[0].x), - y: outMinData.length > 0 ? outMinData[outMinData.length - 1]?.y : 0 + y: outMinData.length > 0 ? outMinData[outMinData.length - 1].y : 0 }) } @@ -221,7 +220,7 @@ export const PriceRangePlot: React.FC = ({ ) { newRangeData.push({ x: Math.min(plotMax, rangeData[rangeData.length - 1].x), - y: newRangeData[newRangeData.length - 1]?.y + y: newRangeData[newRangeData.length - 1].y }) } @@ -248,7 +247,7 @@ export const PriceRangePlot: React.FC = ({ if (rangeData[0].x > rightRange.x) { rangeData.unshift({ x: rightRange.x, - y: rangeData[0]?.y + y: rangeData[0].y }) } @@ -257,7 +256,7 @@ export const PriceRangePlot: React.FC = ({ if (rangeData[rangeData.length - 1].x < Math.min(plotMax, data[data.length - 1].x)) { rangeData.push({ x: Math.min(plotMax, data[data.length - 1].x), - y: rangeData[rangeData.length - 1]?.y + y: rangeData[rangeData.length - 1].y }) } @@ -360,7 +359,7 @@ export const PriceRangePlot: React.FC = ({ disabled ) - const isNoPositions = data.every(tick => !(tick?.y > 0)) + const isNoPositions = data.every(tick => !(tick.y > 0)) return ( = ({ rpcAddress, useDefaultRpc, useCurrentRpc }) => { const [rpc] = useState(rpcAddress) - const [height, setHeight] = useState(document.body.scrollHeight) - - useEffect(() => { - const handleResize = () => setHeight(document.body.scrollHeight) - window.addEventListener('resize', handleResize) - - return () => window.removeEventListener('resize', handleResize) - }, []) const { classes } = useStyles() return ( <> -
+
Warning icon RPC Connection Error diff --git a/src/components/RpcErrorModal/style.ts b/src/components/RpcErrorModal/style.ts index 7f69c3245..b5da28a45 100644 --- a/src/components/RpcErrorModal/style.ts +++ b/src/components/RpcErrorModal/style.ts @@ -5,6 +5,7 @@ const useStyles = makeStyles()(theme => ({ background: { position: 'absolute', top: 0, + bottom: 0, left: 0, right: 0, zIndex: 51, diff --git a/src/components/Snackbar/CustomSnackbar/CustomSnackbar.tsx b/src/components/Snackbar/CustomSnackbar/CustomSnackbar.tsx index ff699ca20..210f06675 100644 --- a/src/components/Snackbar/CustomSnackbar/CustomSnackbar.tsx +++ b/src/components/Snackbar/CustomSnackbar/CustomSnackbar.tsx @@ -82,7 +82,6 @@ const CustomSnackbar = React.forwardRef( ) }}> Details - new tab Close @@ -96,7 +95,6 @@ const CustomSnackbar = React.forwardRef( window.open(link.href, '_blank') }}> {link.label} - new tab Close diff --git a/src/components/Snackbar/CustomSnackbar/style.ts b/src/components/Snackbar/CustomSnackbar/style.ts index 3165884de..cff58ae01 100644 --- a/src/components/Snackbar/CustomSnackbar/style.ts +++ b/src/components/Snackbar/CustomSnackbar/style.ts @@ -120,10 +120,7 @@ export const StyledIcon = styled('div')({ }) export const StyledDetails = styled('button')({ - display: 'flex', - alignItems: 'center', backgroundColor: 'transparent', - gap: 4, textTransform: 'uppercase', borderRadius: 6, border: 'none', @@ -142,7 +139,8 @@ export const StyledDetails = styled('button')({ transform: 'none' } }, - '& img': { - width: 10 + + '& *': { + width: 'auto !important' } }) diff --git a/src/components/Snackbar/index.tsx b/src/components/Snackbar/index.tsx index 08b98aab6..7a7faadb1 100644 --- a/src/components/Snackbar/index.tsx +++ b/src/components/Snackbar/index.tsx @@ -44,8 +44,6 @@ const Snackbar: React.FC = ({ maxSnack = 3, children }) => { styles={` .custom-snackbar-container { bottom: 90px !important; - z-index: 100 !important; - } `} /> diff --git a/src/components/Stats/Liquidity/Liquidity.tsx b/src/components/Stats/Liquidity/Liquidity.tsx index 8a8e744f1..b7fabc7c4 100644 --- a/src/components/Stats/Liquidity/Liquidity.tsx +++ b/src/components/Stats/Liquidity/Liquidity.tsx @@ -8,7 +8,6 @@ import { TimeData } from '@store/reducers/stats' import { Grid, Typography } from '@mui/material' import { formatNumberWithSuffix, trimZeros } from '@utils/utils' import { formatLargeNumber } from '@utils/uiUtils' -import useIsMobile from '@store/hooks/isMobile' interface LiquidityInterface { liquidityPercent: number | null @@ -43,7 +42,6 @@ const Liquidity: React.FC = ({ liquidityVolume = liquidityVolume ?? 0 const isLower = liquidityPercent < 0 - const isMobile = useIsMobile() const percentage = isLoading ? Math.random() * 200 - 100 : liquidityPercent return ( @@ -84,11 +82,7 @@ const Liquidity: React.FC = ({ })) } ]} - margin={ - isMobile - ? { top: 24, bottom: 24, left: 30, right: 12 } - : { top: 24, bottom: 24, left: 30, right: 24 } - } + margin={{ top: 24, bottom: 24, left: 30, right: 24 }} xScale={{ type: 'time', format: '%d/%m/%Y', @@ -109,7 +103,8 @@ const Liquidity: React.FC = ({ tickRotation: 0, tickValues: 5, renderTick: ({ x, y, value }) => ( - + + {' '} ({ @@ -9,20 +9,14 @@ export const useStyles = makeStyles()(() => ({ borderRadius: 24, paddingBlock: 24, paddingLeft: 24, - boxSizing: 'border-box', - [theme.breakpoints.down('sm')]: { - paddingLeft: 6 - } + boxSizing: 'border-box' }, liquidityContainer: { dispaly: 'flex', flexDirection: 'column', alignItems: 'flex-start', fontWeight: 'normal', - marginLeft: 24, - [theme.breakpoints.down('sm')]: { - marginLeft: 6 - } + marginLeft: 24 }, liquidityHeader: { diff --git a/src/components/Stats/PoolListItem/style.ts b/src/components/Stats/PoolListItem/style.ts index 236dc59ef..8aba46ed8 100644 --- a/src/components/Stats/PoolListItem/style.ts +++ b/src/components/Stats/PoolListItem/style.ts @@ -46,7 +46,6 @@ export const useStyles = makeStyles()(() => ({ [theme.breakpoints.down('sm')]: { gridTemplateColumns: 'auto 60px 60px 60px', - padding: '18px 12px', '& p': { justifyContent: 'flex-start', diff --git a/src/components/Stats/TokenListItem/style.ts b/src/components/Stats/TokenListItem/style.ts index f8e4430d8..134766fb9 100644 --- a/src/components/Stats/TokenListItem/style.ts +++ b/src/components/Stats/TokenListItem/style.ts @@ -26,8 +26,6 @@ export const useStyles = makeStyles()((theme: Theme) => ({ [theme.breakpoints.down('sm')]: { gridTemplateColumns: '30% 22.5% 32.5% 15%', - padding: '18px 12px', - '& p': { ...typography.caption1 } @@ -35,7 +33,6 @@ export const useStyles = makeStyles()((theme: Theme) => ({ }, tokenList: { - alignItems: 'center', color: colors.white.main, '& p': { ...typography.heading4 diff --git a/src/components/Stats/Volume/Volume.tsx b/src/components/Stats/Volume/Volume.tsx index 1dce6bade..2b05d2dc4 100644 --- a/src/components/Stats/Volume/Volume.tsx +++ b/src/components/Stats/Volume/Volume.tsx @@ -9,7 +9,6 @@ import { Grid, Typography, useMediaQuery } from '@mui/material' import { Box } from '@mui/system' import { formatNumberWithSuffix, trimZeros } from '@utils/utils' import { formatLargeNumber } from '@utils/uiUtils' -import useIsMobile from '@store/hooks/isMobile' interface StatsInterface { percentVolume: number | null @@ -44,7 +43,6 @@ const Volume: React.FC = ({ volume = volume ?? 0 const isXsDown = useMediaQuery(theme.breakpoints.down('xs')) - const isMobile = useIsMobile() const Theme = { axis: { @@ -118,7 +116,7 @@ const Volume: React.FC = ({ tickRotation: 0, tickValues: 5, renderTick: ({ x, y, value }) => ( - + {' '} ({ @@ -7,10 +7,7 @@ export const useStyles = makeStyles()(() => ({ backgroundColor: colors.invariant.component, borderRadius: 24, padding: 24, - boxSizing: 'border-box', - [theme.breakpoints.down('sm')]: { - padding: '24px 12px' - } + boxSizing: 'border-box' }, volumeContainer: { display: 'flex', diff --git a/src/components/Stats/volumeBar/style.ts b/src/components/Stats/volumeBar/style.ts index c6fd7dbcf..b6735701b 100644 --- a/src/components/Stats/volumeBar/style.ts +++ b/src/components/Stats/volumeBar/style.ts @@ -16,7 +16,7 @@ export const useStyles = makeStyles()(() => ({ justifyContent: 'space-between', [theme.breakpoints.down('sm')]: { - padding: '12px 12px', + padding: '12px 24px', gap: 8, flexDirection: 'column' } diff --git a/src/containers/HeaderWrapper/HeaderWrapper.tsx b/src/containers/HeaderWrapper/HeaderWrapper.tsx index c07499e9a..597e182c0 100644 --- a/src/containers/HeaderWrapper/HeaderWrapper.tsx +++ b/src/containers/HeaderWrapper/HeaderWrapper.tsx @@ -231,7 +231,7 @@ export const HeaderWrapper: React.FC = () => { dispatch( snackbarsActions.add({ - message: 'Wallet address copied', + message: 'Wallet address copied.', variant: 'success', persist: false }) diff --git a/src/containers/NewPositionWrapper/NewPositionWrapper.tsx b/src/containers/NewPositionWrapper/NewPositionWrapper.tsx index 593dd61e5..68bca3873 100644 --- a/src/containers/NewPositionWrapper/NewPositionWrapper.tsx +++ b/src/containers/NewPositionWrapper/NewPositionWrapper.tsx @@ -435,7 +435,7 @@ export const NewPositionWrapper: React.FC = ({ addNewTokenToLocalStorage(address, currentNetwork) dispatch( snackbarsActions.add({ - message: 'Token added', + message: 'Token added.', variant: 'success', persist: false }) @@ -444,7 +444,7 @@ export const NewPositionWrapper: React.FC = ({ .catch(() => { dispatch( snackbarsActions.add({ - message: 'Token add failed', + message: 'Token add failed.', variant: 'error', persist: false }) @@ -453,7 +453,7 @@ export const NewPositionWrapper: React.FC = ({ } else { dispatch( snackbarsActions.add({ - message: 'Token already in list', + message: 'Token already in list.', variant: 'info', persist: false }) diff --git a/src/containers/WrappedPositionsList/WrappedPositionsList.tsx b/src/containers/WrappedPositionsList/WrappedPositionsList.tsx index 222619226..a43855fb6 100644 --- a/src/containers/WrappedPositionsList/WrappedPositionsList.tsx +++ b/src/containers/WrappedPositionsList/WrappedPositionsList.tsx @@ -254,22 +254,6 @@ export const WrappedPositionsList: React.FC = () => { title: 'Start exploring liquidity pools right now!', descCustomText: 'Or, connect your wallet to see existing positions, and create a new one!' }} - // pageChanged={page => { - // const index = positionListPageToQueryPage(page) - - // if (walletStatus === Status.Initialized && walletAddress && !loadedPages[index] && length) { - // dispatch( - // actions.getPositionsListPage({ - // index, - // refresh: false - // }) - // ) - // } - // }} - // loadedPages={loadedPages} - // getRemainingPositions={() => { - // dispatch(actions.getRemainingPositions({ setLoaded: true })) - // }} length={list.length} lockedLength={lockedList.length} noInitialPositions={list.length === 0 && lockedList.length === 0} diff --git a/src/containers/WrappedSwap/WrappedSwap.tsx b/src/containers/WrappedSwap/WrappedSwap.tsx index 45c0b944f..c0817d847 100644 --- a/src/containers/WrappedSwap/WrappedSwap.tsx +++ b/src/containers/WrappedSwap/WrappedSwap.tsx @@ -120,8 +120,8 @@ export const WrappedSwap = ({ initialTokenFrom, initialTokenTo }: Props) => { const lastTokenFrom = tickerToAddress(networkType, initialTokenFrom) && initialTokenFrom !== '-' ? tickerToAddress(networkType, initialTokenFrom) - : localStorage.getItem(`INVARIANT_LAST_TOKEN_FROM_${networkType}`) ?? - WETH_MAIN.address.toString() + : (localStorage.getItem(`INVARIANT_LAST_TOKEN_FROM_${networkType}`) ?? + WETH_MAIN.address.toString()) const lastTokenTo = tickerToAddress(networkType, initialTokenTo) && initialTokenTo !== '-' @@ -180,7 +180,7 @@ export const WrappedSwap = ({ initialTokenFrom, initialTokenTo }: Props) => { addNewTokenToLocalStorage(address, networkType) dispatch( snackbarsActions.add({ - message: 'Token added', + message: 'Token added.', variant: 'success', persist: false }) @@ -189,7 +189,7 @@ export const WrappedSwap = ({ initialTokenFrom, initialTokenTo }: Props) => { .catch(() => { dispatch( snackbarsActions.add({ - message: 'Token add failed', + message: 'Token add failed.', variant: 'error', persist: false }) @@ -198,7 +198,7 @@ export const WrappedSwap = ({ initialTokenFrom, initialTokenTo }: Props) => { } else { dispatch( snackbarsActions.add({ - message: 'Token already in list', + message: 'Token already in list.', variant: 'info', persist: false }) @@ -289,13 +289,6 @@ export const WrappedSwap = ({ initialTokenFrom, initialTokenTo }: Props) => { second: tokensList[tokenToIndex].address }) ) - dispatch( - poolsActions.getNearestTicksForPair({ - tokenFrom: tokensList[tokenFromIndex].address, - tokenTo: tokensList[tokenToIndex].address, - allPools - }) - ) } const copyTokenAddressHandler = (message: string, variant: VariantType) => { diff --git a/src/pages/PortfolioPage/PortfolioPage.tsx b/src/pages/PortfolioPage/PortfolioPage.tsx index b30503808..136604cec 100644 --- a/src/pages/PortfolioPage/PortfolioPage.tsx +++ b/src/pages/PortfolioPage/PortfolioPage.tsx @@ -1,13 +1,52 @@ -import WrappedPositionsList from '@containers/WrappedPositionsList/WrappedPositionsList' -import { Grid } from '@mui/material' +import { Box, Grid, Typography } from '@mui/material' import useStyles from './styles' +import icons from '@static/icons' +import { colors, typography } from '@static/theme' +import ChangeWalletButton from '@components/Header/HeaderButton/ChangeWalletButton' +import { useDispatch, useSelector } from 'react-redux' +import { Status, actions as walletActions } from '@store/reducers/solanaWallet' +import { useMemo } from 'react' +import { status } from '@store/selectors/solanaWallet' +import { UserOverview } from '@components/OverviewYourPositions/UserOverview' +import WrappedPositionsList from '@containers/WrappedPositionsList/WrappedPositionsList' const PortfolioPage: React.FC = () => { const { classes } = useStyles() + const dispatch = useDispatch() + const walletStatus = useSelector(status) + + const isConnected = useMemo(() => walletStatus === Status.Initialized, [walletStatus]) + return ( - + {isConnected ? ( + <> + + + + ) : ( + + + + Wallet is not connected. + + + No liquidity positions to show. + + { + dispatch(walletActions.connect(false)) + }} + onDisconnect={() => { + dispatch(walletActions.disconnect()) + }} + connected={false} + className={classes.button} + /> + + )} ) diff --git a/src/pages/PortfolioPage/styles.ts b/src/pages/PortfolioPage/styles.ts index 45a95f147..704a49d09 100644 --- a/src/pages/PortfolioPage/styles.ts +++ b/src/pages/PortfolioPage/styles.ts @@ -1,3 +1,4 @@ +import { typography, colors } from '@static/theme' import { makeStyles } from 'tss-react/mui' const useStyles = makeStyles()(theme => { @@ -22,6 +23,42 @@ const useStyles = makeStyles()(theme => { paddingInline: 8 } }, + notConnectedPlaceholder: { + height: '400px', + width: '100%', + borderTopLeftRadius: '24px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + + [theme.breakpoints.down('md')]: { + justifyContent: 'flex-start', + paddingTop: '90px' + }, + + flexDirection: 'column', + borderTopRightRadius: '24px', + background: 'linear-gradient(180deg, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0) 100%)' + }, + button: { + height: 40, + width: 200, + marginTop: 20, + color: colors.invariant.componentBcg, + ...typography.body1, + textTransform: 'none', + borderRadius: 14, + background: colors.invariant.pinkLinearGradientOpacity, + + '&:hover': { + background: colors.invariant.pinkLinearGradient, + boxShadow: '0px 0px 16px rgba(239, 132, 245, 0.35)', + '@media (hover: none)': { + background: colors.invariant.pinkLinearGradientOpacity, + boxShadow: 'none' + } + } + }, innerContainer: { maxWidth: 1210, minHeight: '70vh', diff --git a/src/pages/RootPage.tsx b/src/pages/RootPage.tsx index d16b0f2e9..1b0ad8549 100644 --- a/src/pages/RootPage.tsx +++ b/src/pages/RootPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useCallback, memo, useState, useLayoutEffect, useRef } from 'react' +import { useEffect, useCallback, memo, useState, useLayoutEffect } from 'react' import { useDispatch, useSelector } from 'react-redux' import { useNavigate, useLocation, Outlet } from 'react-router-dom' import EventsHandlers from '@containers/EventsHandlers' @@ -11,14 +11,11 @@ import { toBlur } from '@utils/uiUtils' import useStyles from './style' import { status } from '@store/selectors/solanaWallet' import { Status as WalletStatus } from '@store/reducers/solanaWallet' -import { actions as walletActions } from '@store/reducers/solanaWallet' -import { actions as leaderboardActions } from '@store/reducers/leaderboard' import { actions } from '@store/reducers/positions' -import { DEFAULT_PUBLICKEY, NetworkType } from '@store/consts/static' + +import { NetworkType } from '@store/consts/static' import { network } from '@store/selectors/solanaConnection' import { NormalBanner } from '@components/Leaderboard/LeaderboardBanner/NormalBanner' -import { getEclipseWallet } from '@utils/web3/wallet' -import { leaderboardSelectors } from '@store/selectors/leaderboard' const BANNER_STORAGE_KEY = 'invariant-banner-state-3' const BANNER_HIDE_DURATION = 1000 * 60 * 60 * 24 // 24 hours @@ -79,43 +76,6 @@ const RootPage: React.FC = memo(() => { initConnection() }, [initConnection]) - const walletAddressRef = useRef('') - const itemsPerPage = useSelector(leaderboardSelectors.itemsPerPage) - useEffect(() => { - const intervalId = setInterval(() => { - const addr = getEclipseWallet().publicKey.toString() - if ( - !walletAddressRef.current || - (walletAddressRef.current === DEFAULT_PUBLICKEY.toString() && - addr !== DEFAULT_PUBLICKEY.toString()) - ) { - walletAddressRef.current = addr - return - } - - if ( - !document.hasFocus() && - walletAddressRef.current !== DEFAULT_PUBLICKEY.toString() && - walletAddressRef.current !== addr - ) { - walletAddressRef.current = addr - dispatch(walletActions.changeWalletInExtension()) - dispatch(leaderboardActions.getLeaderboardData({ page: 1, itemsPerPage })) - dispatch(actions.getPositionsList()) - } - - if ( - document.hasFocus() && - walletAddressRef.current !== DEFAULT_PUBLICKEY.toString() && - walletAddressRef.current !== addr - ) { - walletAddressRef.current = addr - } - }, 500) - - return () => clearInterval(intervalId) - }, []) - useEffect(() => { if (signerStatus === Status.Initialized && walletStatus === WalletStatus.Initialized) { dispatch(actions.getPositionsList()) diff --git a/src/static/icons.ts b/src/static/icons.ts index bc1ce1058..2878532d8 100644 --- a/src/static/icons.ts +++ b/src/static/icons.ts @@ -77,6 +77,8 @@ import githubFill from './svg/githubFill.svg' import mediumFill from './svg/MediumFill.svg' import docsFill from './svg/docsFill.svg' import tokenCreator from './svg/tokenCreator.svg' +import liquidityEmpty from './svg/liqudity_empty.svg' +import assetsEmpty from './svg/assets_empty.svg' const icons: { [key: string]: string } = { tokenCreator, @@ -157,7 +159,9 @@ const icons: { [key: string]: string } = { boostPoints, airdropGrey, infoError, - okxLogo + okxLogo, + liquidityEmpty, + assetsEmpty } export default icons diff --git a/src/static/svg/assets_empty.svg b/src/static/svg/assets_empty.svg new file mode 100644 index 000000000..23e0679ff --- /dev/null +++ b/src/static/svg/assets_empty.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/static/svg/liqudity_empty.svg b/src/static/svg/liqudity_empty.svg new file mode 100644 index 000000000..50b85396a --- /dev/null +++ b/src/static/svg/liqudity_empty.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/static/svg/narrowChartMaxHandle.svg b/src/static/svg/narrowChartMaxHandle.svg new file mode 100644 index 000000000..83ebbaab1 --- /dev/null +++ b/src/static/svg/narrowChartMaxHandle.svg @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/src/static/svg/narrowChartMinHandle.svg b/src/static/svg/narrowChartMinHandle.svg new file mode 100644 index 000000000..ef790b632 --- /dev/null +++ b/src/static/svg/narrowChartMinHandle.svg @@ -0,0 +1,18 @@ + + + + + + + \ No newline at end of file diff --git a/src/store/consts/static.ts b/src/store/consts/static.ts index 8ad708af3..b327b6c0b 100644 --- a/src/store/consts/static.ts +++ b/src/store/consts/static.ts @@ -809,7 +809,7 @@ export const DEFAULT_TOKEN_DECIMAL = 6 export const PRICE_QUERY_COOLDOWN = 60 * 1000 export const TIMEOUT_ERROR_MESSAGE = - 'Transaction has timed out. Check the details to confirm success' + 'Transaction has timed out. Check the details to confirm success.' export const MAX_CROSSES_IN_SINGLE_TX = 11 diff --git a/src/store/consts/userStrategies.ts b/src/store/consts/userStrategies.ts new file mode 100644 index 000000000..6e0c51776 --- /dev/null +++ b/src/store/consts/userStrategies.ts @@ -0,0 +1,10 @@ +import { StrategyConfig } from '@store/types/userOverview' +import { USDC_MAIN, WETH_MAIN } from './static' +export const DEFAULT_FEE_TIER = '0_10' +export const STRATEGIES: StrategyConfig[] = [ + { + tokenAddressA: WETH_MAIN.address.toString(), + tokenAddressB: USDC_MAIN.address.toString(), + feeTier: '0_09' + } +] diff --git a/src/components/PositionsList/PositionItem/hooks/usePromotedPool.ts b/src/store/hooks/positionList/usePromotedPool.ts similarity index 100% rename from src/components/PositionsList/PositionItem/hooks/usePromotedPool.ts rename to src/store/hooks/positionList/usePromotedPool.ts diff --git a/src/store/hooks/positionList/useUnclaimedFee.ts b/src/store/hooks/positionList/useUnclaimedFee.ts new file mode 100644 index 000000000..abad47dcd --- /dev/null +++ b/src/store/hooks/positionList/useUnclaimedFee.ts @@ -0,0 +1,223 @@ +import { calculatePercentageRatio } from '@components/PositionsList/PositionItem/utils/calculations' +import { IWallet } from '@invariant-labs/sdk-eclipse' +import { calculateClaimAmount } from '@invariant-labs/sdk-eclipse/lib/utils' +import { printBN } from '@utils/utils' +import { useEffect, useMemo, useState, useCallback, useRef } from 'react' +import { useLiquidity } from '../userOverview/useLiquidity' +import { usePositionTicks } from '../userOverview/usePositionTicks' +import { usePrices } from '../userOverview/usePrices' +import { IPositionItem } from '@components/PositionsList/types' +import { network as currentNetwork, rpcAddress } from '@store/selectors/solanaConnection' +import { getEclipseWallet } from '@utils/web3/wallet' +import { useSelector } from 'react-redux' +import { Tick } from '@invariant-labs/sdk-eclipse/lib/market' +import { ISinglePositionData } from '@components/OverviewYourPositions/components/Overview/Overview' + +const UPDATE_INTERVAL = 60000 + +interface PositionTicks { + lowerTick: Tick | undefined + upperTick: Tick | undefined + loading: boolean +} + +interface UnclaimedFeeHook extends IPositionItem { + positionSingleData?: ISinglePositionData + xToY: boolean +} + +export const useUnclaimedFee = ({ + currentPrice, + id, + position, + tokenXLiq, + tokenYLiq, + positionSingleData, + xToY +}: Pick< + UnclaimedFeeHook, + 'currentPrice' | 'id' | 'position' | 'tokenXLiq' | 'tokenYLiq' | 'positionSingleData' | 'xToY' +>) => { + const wallet = useMemo(() => getEclipseWallet(), []) + const networkType = useSelector(currentNetwork) + const rpc = useSelector(rpcAddress) + + const lastUpdateTimeRef = useRef(0) + const [shouldUpdate, setShouldUpdate] = useState(false) + const [isInitialLoad, setIsInitialLoad] = useState(true) + const [previousUnclaimedFees, setPreviousUnclaimedFees] = useState(0) + const [previousTokenValueInUsd, setPreviousTokenValueInUsd] = useState(0) + + const [positionTicks, setPositionTicks] = useState({ + lowerTick: undefined, + upperTick: undefined, + loading: false + }) + + const { tokenXPercentage, tokenYPercentage } = useMemo( + () => calculatePercentageRatio(tokenXLiq, tokenYLiq, currentPrice, xToY), + [tokenXLiq, tokenYLiq, currentPrice, xToY] + ) + + const { tokenXLiquidity, tokenYLiquidity } = useLiquidity(positionSingleData) + const { tokenXPriceData, tokenYPriceData } = usePrices({ + tokenX: { + assetsAddress: positionSingleData?.tokenX.assetAddress.toString(), + name: positionSingleData?.tokenX.name + }, + tokenY: { + assetsAddress: positionSingleData?.tokenY.assetAddress.toString(), + name: positionSingleData?.tokenY.name + } + }) + + const checkShouldUpdate = useCallback(() => { + const currentTime = Date.now() + if (isInitialLoad || currentTime - lastUpdateTimeRef.current >= UPDATE_INTERVAL) { + lastUpdateTimeRef.current = currentTime + return true + } + return false + }, [isInitialLoad]) + + useEffect(() => { + if (checkShouldUpdate()) { + setShouldUpdate(true) + } + + const interval = setInterval(() => { + if (checkShouldUpdate()) { + setShouldUpdate(true) + } + }, UPDATE_INTERVAL) + + return () => clearInterval(interval) + }, [checkShouldUpdate]) + const { + lowerTick, + upperTick, + loading: ticksLoading + } = usePositionTicks({ + positionId: id, + poolData: positionSingleData?.poolData, + lowerTickIndex: positionSingleData?.lowerTickIndex ?? 0, + upperTickIndex: positionSingleData?.upperTickIndex ?? 0, + networkType, + rpc, + wallet: wallet as IWallet, + shouldUpdate + }) + + useEffect(() => { + if (lowerTick && upperTick) { + setPositionTicks({ + lowerTick, + upperTick, + loading: ticksLoading + }) + + if (!ticksLoading) { + setShouldUpdate(false) + setIsInitialLoad(false) + } + } + }, [lowerTick, upperTick, ticksLoading]) + + const calculateUnclaimedFees = useCallback(() => { + if ( + !positionSingleData?.poolData || + typeof positionTicks.lowerTick === 'undefined' || + typeof positionTicks.upperTick === 'undefined' + ) { + return null + } + + const [bnX, bnY] = calculateClaimAmount({ + position, + tickLower: positionTicks.lowerTick, + tickUpper: positionTicks.upperTick, + tickCurrent: positionSingleData.poolData.currentTickIndex, + feeGrowthGlobalX: positionSingleData.poolData.feeGrowthGlobalX, + feeGrowthGlobalY: positionSingleData.poolData.feeGrowthGlobalY + }) + + return { + xAmount: +printBN(bnX, positionSingleData.tokenX.decimals), + yAmount: +printBN(bnY, positionSingleData.tokenY.decimals) + } + }, [position, positionTicks, positionSingleData]) + + const unclaimedFeesInUSD = useMemo(() => { + const loading = + positionTicks.loading || + !positionSingleData?.poolData || + tokenXPriceData.loading || + tokenYPriceData.loading || + typeof positionTicks.lowerTick === 'undefined' || + typeof positionTicks.upperTick === 'undefined' + + if (loading && !isInitialLoad && previousUnclaimedFees > 0) { + return { loading: false, value: previousUnclaimedFees } + } + + if (loading) { + return { loading: true, value: previousUnclaimedFees } + } + + const fees = calculateUnclaimedFees() + if (!fees) { + return { loading: true, value: previousUnclaimedFees } + } + + const totalValueInUSD = + fees.xAmount * tokenXPriceData.price + fees.yAmount * tokenYPriceData.price + + if (Math.abs(totalValueInUSD - previousUnclaimedFees) > 0.000001) { + setPreviousUnclaimedFees(totalValueInUSD) + } + + return { loading: false, value: totalValueInUSD } + }, [ + positionTicks, + positionSingleData, + tokenXPriceData, + tokenYPriceData, + previousUnclaimedFees, + isInitialLoad, + calculateUnclaimedFees + ]) + + const tokenValueInUsd = useMemo(() => { + const loading = tokenXPriceData.loading || tokenYPriceData.loading + + if (loading && !isInitialLoad && previousTokenValueInUsd > 0) { + return { loading: false, value: previousTokenValueInUsd } + } + + if (loading) { + return { loading: true, value: previousTokenValueInUsd } + } + + if (!tokenXLiquidity && !tokenYLiquidity) { + return { loading: false, value: 0 } + } + + const totalValue = + tokenXLiquidity * tokenXPriceData.price + tokenYLiquidity * tokenYPriceData.price + + if (Math.abs(totalValue - previousTokenValueInUsd) > 0.000001) { + setPreviousTokenValueInUsd(totalValue) + } + + return { loading: false, value: totalValue } + }, [ + tokenXLiquidity, + tokenYLiquidity, + tokenXPriceData, + tokenYPriceData, + previousTokenValueInUsd, + isInitialLoad + ]) + + return { tokenValueInUsd, unclaimedFeesInUSD, tokenXPercentage, tokenYPercentage } +} diff --git a/src/store/hooks/userOverview/useAgregatedPositions.ts b/src/store/hooks/userOverview/useAgregatedPositions.ts new file mode 100644 index 000000000..e8591ac6e --- /dev/null +++ b/src/store/hooks/userOverview/useAgregatedPositions.ts @@ -0,0 +1,106 @@ +import { ISinglePositionData } from '@components/OverviewYourPositions/components/Overview/Overview' +import { calculatePriceSqrt } from '@invariant-labs/sdk-eclipse' +import { getX, getY } from '@invariant-labs/sdk-eclipse/lib/math' +import { PublicKey } from '@solana/web3.js' +import { printBN } from '@utils/utils' +import { useMemo } from 'react' + +export const useAgregatedPositions = ( + positionList: ISinglePositionData[], + prices: Record +) => { + interface TokenPosition { + tokenX: { + symbol: string + decimals: number + name: string + assetAddress: PublicKey + logoURI: string + } + tokenY: { + symbol: string + name: string + decimals: number + assetAddress: PublicKey + logoURI: string + } + liquidity: number + upperTickIndex: number + lowerTickIndex: number + poolData: { + sqrtPrice: number + } + id: string + } + + interface TokenPositionEntry { + token: string + value: number + name: string + logo: string + positionId: string + } + + const calculateTokenValue = ( + position: TokenPosition, + isTokenX: boolean, + prices: Record + ): number => { + const token = isTokenX ? position.tokenX : position.tokenY + const getValue = isTokenX ? getX : getY + + const amount = getValue( + position.liquidity, + calculatePriceSqrt(position.upperTickIndex), + position.poolData.sqrtPrice, + calculatePriceSqrt(position.lowerTickIndex) + ) + + return +printBN(amount, token.decimals) * prices[token.assetAddress.toString()] + } + + const createPositionEntry = ( + position: TokenPosition, + isTokenX: boolean, + value: number + ): TokenPositionEntry => { + const token = isTokenX ? position.tokenX : position.tokenY + + return { + token: token.symbol, + value, + name: token.name, + logo: token.logoURI, + positionId: position.id + } + } + + const updateOrCreatePosition = ( + positions: TokenPositionEntry[], + position: TokenPosition, + isTokenX: boolean, + prices: Record + ): TokenPositionEntry[] => { + const token = isTokenX ? position.tokenX : position.tokenY + const value = calculateTokenValue(position, isTokenX, prices) + + const existingPosition = positions.find(p => p.token === token.symbol) + + if (existingPosition) { + existingPosition.value += value + return positions + } + + return [...positions, createPositionEntry(position, isTokenX, value)] + } + + const positions = useMemo(() => { + return positionList.reduce((acc: TokenPositionEntry[], position) => { + acc = updateOrCreatePosition(acc, position, true, prices) + acc = updateOrCreatePosition(acc, position, false, prices) + + return acc + }, []) + }, [positionList, prices]) + return { positions } +} diff --git a/src/store/hooks/userOverview/useAverageLogoColor.ts b/src/store/hooks/userOverview/useAverageLogoColor.ts new file mode 100644 index 000000000..f25d95546 --- /dev/null +++ b/src/store/hooks/userOverview/useAverageLogoColor.ts @@ -0,0 +1,167 @@ +import { useCallback } from 'react' + +export interface TokenColorOverride { + token: string + color: string +} + +export const useAverageLogoColor = () => { + interface RGBColor { + r: number + g: number + b: number + } + + const tokenColorOverrides: TokenColorOverride[] = [{ token: 'SOL', color: '#9945FF' }] + + const defaultTokenColors: Record = { + SOL: '#9945FF', + DEFAULT: '#7C7C7C' + } + + const getTokenColor = ( + token: string, + logoColor: string | undefined, + overrides: TokenColorOverride[] + ): string => { + const override = overrides.find(item => item.token === token) + if (override) return override.color + + if (logoColor) return logoColor + + return defaultTokenColors[token] || defaultTokenColors.DEFAULT + } + + const rgbToHex = ({ r, g, b }: RGBColor): string => { + const componentToHex = (c: number): string => { + const hex = Math.round(c).toString(16) + return hex.length === 1 ? '0' + hex : hex + } + return '#' + componentToHex(r) + componentToHex(g) + componentToHex(b) + } + + const calculateAverageColor = (imageData: Uint8ClampedArray): string => { + let totalR = 0 + let totalG = 0 + let totalB = 0 + let totalPixels = 0 + + for (let i = 0; i < imageData.length; i += 4) { + const alpha = imageData[i + 3] + if (alpha === 0) continue + + const alphaMultiplier = alpha / 255 + totalR += imageData[i] * alphaMultiplier + totalG += imageData[i + 1] * alphaMultiplier + totalB += imageData[i + 2] * alphaMultiplier + totalPixels++ + } + + if (totalPixels === 0) return defaultTokenColors.DEFAULT + + const averageColor: RGBColor = { + r: totalR / totalPixels, + g: totalG / totalPixels, + b: totalB / totalPixels + } + + return rgbToHex(averageColor) + } + + const getCorrectImageUrl = (url: string): string => { + if (url.includes('github.com') && url.includes('/blob/master/')) { + return url + .replace('github.com', 'raw.githubusercontent.com') + .replace('/blob/master/', '/master/') + } + + if (url.includes('statics.solscan.io')) { + const ref = new URL(url).searchParams.get('ref') + if (ref) { + try { + const decodedRef = Buffer.from(ref, 'hex').toString() + return decodedRef + } catch (e) { + console.warn('Failed to decode Solscan URL:', e) + } + } + } + + return url + } + + const loadImageWithFallback = (url: string): Promise => { + return new Promise((resolve, reject) => { + const img = new Image() + img.crossOrigin = 'anonymous' + img.referrerPolicy = 'no-referrer' + + img.onload = () => resolve(img) + img.onerror = () => { + const retryImg = new Image() + retryImg.onload = () => resolve(retryImg) + retryImg.onerror = () => reject(new Error('Failed to load image')) + retryImg.src = url + } + + img.src = getCorrectImageUrl(url) + }) + } + + const getAverageColor = useCallback((logoUrl: string, token: string): Promise => { + const override = tokenColorOverrides.find(item => item.token === token) + if (override) { + return Promise.resolve(override.color) + } + + return new Promise(resolve => { + const timeoutDuration = 5000 + const timeoutId = setTimeout(() => { + resolve(getTokenColor(token, undefined, tokenColorOverrides)) + }, timeoutDuration) + + loadImageWithFallback(logoUrl) + .then(img => { + clearTimeout(timeoutId) + + const canvas = document.createElement('canvas') + const ctx = canvas.getContext('2d') + + if (!ctx) { + resolve(getTokenColor(token, undefined, tokenColorOverrides)) + return + } + + canvas.width = img.width + canvas.height = img.height + + try { + ctx.drawImage(img, 0, 0) + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height).data + const averageColor = calculateAverageColor(imageData) + resolve(averageColor) + } catch (error) { + const tempDiv = document.createElement('div') + tempDiv.style.position = 'absolute' + tempDiv.style.visibility = 'hidden' + tempDiv.appendChild(img) + document.body.appendChild(tempDiv) + + const computedColor = window.getComputedStyle(img).backgroundColor + document.body.removeChild(tempDiv) + + if (computedColor && computedColor !== 'rgba(0, 0, 0, 0)') { + resolve(computedColor) + } else { + resolve(getTokenColor(token, undefined, tokenColorOverrides)) + } + } + }) + .catch(() => { + resolve(getTokenColor(token, undefined, tokenColorOverrides)) + }) + }) + }, []) + + return { tokenColorOverrides, getAverageColor, getTokenColor } +} diff --git a/src/store/hooks/userOverview/useLiquidity.ts b/src/store/hooks/userOverview/useLiquidity.ts new file mode 100644 index 000000000..ec006fcfb --- /dev/null +++ b/src/store/hooks/userOverview/useLiquidity.ts @@ -0,0 +1,50 @@ +import { ISinglePositionData } from '@components/OverviewYourPositions/components/Overview/Overview' +import { calculatePriceSqrt } from '@invariant-labs/sdk-eclipse' +import { getX, getY } from '@invariant-labs/sdk-eclipse/lib/math' +import { printBN } from '@utils/utils' +import { useMemo } from 'react' + +export const useLiquidity = (position: ISinglePositionData | undefined) => { + const tokenXLiquidity = useMemo(() => { + if (position) { + try { + return +printBN( + getX( + position.liquidity, + calculatePriceSqrt(position.upperTickIndex), + position.poolData.sqrtPrice, + calculatePriceSqrt(position.lowerTickIndex) + ), + position.tokenX.decimals + ) + } catch (error) { + return 0 + } + } + + return 0 + }, [position]) + + const tokenYLiquidity = useMemo(() => { + if (position) { + try { + return +printBN( + getY( + position.liquidity, + calculatePriceSqrt(position.upperTickIndex), + position.poolData.sqrtPrice, + calculatePriceSqrt(position.lowerTickIndex) + ), + position.tokenY.decimals + ) + } catch (error) { + console.log(error) + return 0 + } + } + + return 0 + }, [position]) + + return { tokenXLiquidity, tokenYLiquidity } +} diff --git a/src/store/hooks/userOverview/usePositionTicks.ts b/src/store/hooks/userOverview/usePositionTicks.ts new file mode 100644 index 000000000..f2e841c72 --- /dev/null +++ b/src/store/hooks/userOverview/usePositionTicks.ts @@ -0,0 +1,94 @@ +import { useCallback, useEffect, useState } from 'react' +import { Tick } from '@invariant-labs/sdk-eclipse/lib/market' +import { IWallet, Pair } from '@invariant-labs/sdk-eclipse' +import { getMarketProgram } from '@utils/web3/programs/amm' +import { PoolWithAddressAndIndex } from '@store/selectors/positions' +import { NetworkType } from '@store/consts/static' + +interface PositionTicks { + lowerTick: Tick | undefined + upperTick: Tick | undefined + loading: boolean + error?: string +} + +interface UsePositionTicksProps { + positionId: string | undefined + poolData: PoolWithAddressAndIndex | undefined + lowerTickIndex: number + upperTickIndex: number + networkType: NetworkType + rpc: string + wallet: IWallet | null + shouldUpdate?: boolean +} + +export const usePositionTicks = ({ + positionId, + poolData, + lowerTickIndex, + upperTickIndex, + networkType, + rpc, + wallet, + shouldUpdate = true +}: UsePositionTicksProps): PositionTicks => { + const [positionTicks, setPositionTicks] = useState({ + lowerTick: undefined, + upperTick: undefined, + loading: false + }) + + const fetchTicksForPosition = useCallback(async () => { + if (!positionId || !poolData || !wallet) { + setPositionTicks(prev => ({ ...prev, loading: false })) + return + } + + setPositionTicks(prev => ({ ...prev, loading: true, error: undefined })) + + try { + const marketProgram = await getMarketProgram(networkType, rpc, wallet) + const pair = new Pair(poolData.tokenX, poolData.tokenY, { + fee: poolData.fee, + tickSpacing: poolData.tickSpacing + }) + + const [lowerTick, upperTick] = await Promise.all([ + marketProgram.getTick(pair, lowerTickIndex), + marketProgram.getTick(pair, upperTickIndex) + ]) + + setPositionTicks({ + lowerTick, + upperTick, + loading: false + }) + } catch (error) { + console.error('Error fetching ticks:', error) + setPositionTicks({ + lowerTick: undefined, + upperTick: undefined, + loading: false, + error: error instanceof Error ? error.message : 'Unknown error occurred' + }) + } + }, [positionId, poolData, networkType, rpc, wallet, lowerTickIndex, upperTickIndex]) + + useEffect(() => { + let mounted = true + + const fetch = async () => { + if (!mounted || !shouldUpdate) return + await fetchTicksForPosition() + } + + fetch() + + return () => { + mounted = false + } + }, [fetchTicksForPosition, shouldUpdate]) + + return positionTicks +} diff --git a/src/store/hooks/userOverview/usePrices.ts b/src/store/hooks/userOverview/usePrices.ts new file mode 100644 index 000000000..f6ba1f20f --- /dev/null +++ b/src/store/hooks/userOverview/usePrices.ts @@ -0,0 +1,56 @@ +import { getTokenPrice, getMockedTokenPrice } from '@utils/utils' +import { useState, useEffect } from 'react' +import { network } from '@store/selectors/solanaConnection' +import { useSelector } from 'react-redux' + +interface TokenPriceData { + price: number + loading: boolean +} + +export const usePrices = ({ + tokenX, + tokenY +}: { + tokenY: { assetsAddress?: string; name?: string } + tokenX: { assetsAddress?: string; name?: string } +}) => { + const networkType = useSelector(network) + + const [tokenXPriceData, setTokenXPriceData] = useState({ + price: 0, + loading: true + }) + const [tokenYPriceData, setTokenYPriceData] = useState({ + price: 0, + loading: true + }) + useEffect(() => { + if (!tokenX || !tokenY) return + + const fetchPrices = async () => { + getTokenPrice(tokenX.assetsAddress ?? '') + .then(price => { + setTokenXPriceData({ price: price ?? 0, loading: false }) + }) + .catch(() => { + setTokenXPriceData({ + price: getMockedTokenPrice(tokenX.name ?? '', networkType).price, + loading: false + }) + }) + + getTokenPrice(tokenY.assetsAddress ?? '') + .then(price => setTokenYPriceData({ price: price ?? 0, loading: false })) + .catch(() => { + setTokenYPriceData({ + price: getMockedTokenPrice(tokenY.name ?? '', networkType).price, + loading: false + }) + }) + } + + fetchPrices() + }, [tokenX.assetsAddress, tokenY.assetsAddress, networkType]) + return { tokenXPriceData, tokenYPriceData } +} diff --git a/src/store/hooks/userOverview/useProcessedToken.ts b/src/store/hooks/userOverview/useProcessedToken.ts new file mode 100644 index 000000000..4f7681b71 --- /dev/null +++ b/src/store/hooks/userOverview/useProcessedToken.ts @@ -0,0 +1,71 @@ +import { BN } from '@coral-xyz/anchor' +import { PublicKey } from '@solana/web3.js' +import { printBN, getTokenPrice } from '@utils/utils' +import { useEffect, useState } from 'react' + +interface Token { + assetAddress: PublicKey + balance: BN + tokenProgram?: PublicKey + symbol: string + address: PublicKey + decimals: number + name: string + logoURI: string + coingeckoId?: string + isUnknown?: boolean +} + +interface ProcessedPool { + id: PublicKey + symbol: string + icon: string + value: number + isUnknown?: boolean + decimal: number + amount: number +} + +export const useProcessedTokens = (tokensList: Token[], isBalanceLoading: boolean) => { + const [processedPools, setProcessedPools] = useState([]) + + useEffect(() => { + const processTokens = async () => { + const nonZeroTokens = tokensList.filter(token => { + const balance = printBN(token.balance, token.decimals) + return parseFloat(balance) > 0 + }) + + const processed = await Promise.all( + nonZeroTokens.map(async token => { + const balance = Number(printBN(token.balance, token.decimals).replace(',', '.')) + + let price = 0 + try { + const priceData = await getTokenPrice(token.assetAddress.toString() ?? '') + price = priceData ?? 0 + } catch (error) { + console.error(`Failed to fetch price for ${token.symbol}:`, error) + } + return { + id: token.address, + symbol: token.symbol, + icon: token.logoURI, + isUnknown: token.isUnknown, + decimal: token.decimals, + amount: balance, + value: balance * price + } + }) + ) + + setProcessedPools(processed) + } + if (isBalanceLoading) return + if (tokensList?.length) { + processTokens() + } + }, [tokensList, isBalanceLoading]) + + return processedPools +} diff --git a/src/store/reducers/pools.ts b/src/store/reducers/pools.ts index f588c7059..4db90cf23 100644 --- a/src/store/reducers/pools.ts +++ b/src/store/reducers/pools.ts @@ -190,13 +190,7 @@ const poolsSlice = createSlice({ state.poolTicks[action.payload.address].splice(action.payload.index, 1) }, updateTickmap(state, action: PayloadAction) { - if (state.tickMaps[action.payload.address]) { - state.tickMaps[action.payload.address].bitmap = action.payload.bitmap - } else { - state.tickMaps[action.payload.address] = { - bitmap: action.payload.bitmap - } - } + state.tickMaps[action.payload.address].bitmap = action.payload.bitmap }, getTicksAndTickMaps(_state, _action: PayloadAction) { return _state diff --git a/src/store/reducers/positions.ts b/src/store/reducers/positions.ts index 1329fab81..088e32cf1 100644 --- a/src/store/reducers/positions.ts +++ b/src/store/reducers/positions.ts @@ -18,6 +18,7 @@ export interface PositionsListStore { lockedList: PositionWithAddress[] head: number bump: number + isAllClaimFeesLoading: boolean initialized: boolean loading: boolean } @@ -54,6 +55,14 @@ export interface IPositionsStore { currentPositionTicks: CurrentPositionTicksStore initPosition: InitPositionStore shouldNotUpdateRange: boolean + unclaimedFees: { + total: number + loading: boolean + lastUpdate: number + } + prices: { + data: Record + } } export interface InitPositionData @@ -101,6 +110,7 @@ export const defaultState: IPositionsStore = { lockedList: [], head: 0, bump: 0, + isAllClaimFeesLoading: false, initialized: false, loading: true }, @@ -114,6 +124,15 @@ export const defaultState: IPositionsStore = { inProgress: false, success: false }, + unclaimedFees: { + total: 0, + loading: false, + lastUpdate: 0 + }, + prices: { + data: {} + }, + shouldNotUpdateRange: false } @@ -157,6 +176,35 @@ const positionsSlice = createSlice({ state.plotTicks.hasError = true return state }, + setAllClaimLoader(state, action: PayloadAction) { + state.positionsList.isAllClaimFeesLoading = action.payload + }, + calculateTotalUnclaimedFees(state) { + state.unclaimedFees.loading = true + return state + }, + setUnclaimedFees(state, action: PayloadAction) { + state.unclaimedFees = { + total: action.payload, + loading: false, + lastUpdate: Date.now() + } + return state + }, + setUnclaimedFeesError(state) { + state.unclaimedFees = { + ...state.unclaimedFees, + loading: false + } + return state + }, + setPrices(state, action: PayloadAction>) { + state.prices = { + data: action.payload + } + return state + }, + getCurrentPlotTicks(state, action: PayloadAction) { state.plotTicks.loading = !action.payload.disableLoading return state @@ -242,6 +290,9 @@ const positionsSlice = createSlice({ claimFee(state, _action: PayloadAction<{ index: number; isLocked: boolean }>) { return state }, + claimAllFee(state) { + return state + }, closePosition(state, _action: PayloadAction) { return state }, diff --git a/src/store/reducers/solanaWallet.ts b/src/store/reducers/solanaWallet.ts index 8ff2dce2c..575340255 100644 --- a/src/store/reducers/solanaWallet.ts +++ b/src/store/reducers/solanaWallet.ts @@ -36,7 +36,8 @@ export interface ISolanaWallet { address: PublicKey balance: BN accounts: { [key in string]: ITokenAccount } - balanceLoading: boolean + ethBalanceLoading: boolean + tokenBalanceLoading: boolean thankYouModalShown: boolean } @@ -45,7 +46,8 @@ export const defaultState: ISolanaWallet = { address: DEFAULT_PUBLICKEY, balance: new BN(0), accounts: {}, - balanceLoading: false, + ethBalanceLoading: false, + tokenBalanceLoading: false, thankYouModalShown: false } @@ -75,11 +77,8 @@ const solanaWalletSlice = createSlice({ getBalance(state) { return state }, - changeWalletInExtension(state) { - return state - }, - setIsBalanceLoading(state, action: PayloadAction) { - action.payload ? (state.balanceLoading = true) : (state.balanceLoading = false) + setIsEthBalanceLoading(state, action: PayloadAction) { + action.payload ? (state.ethBalanceLoading = true) : (state.ethBalanceLoading = false) return state }, addTokenAccount(state, action: PayloadAction) { @@ -107,6 +106,10 @@ const solanaWalletSlice = createSlice({ state.thankYouModalShown = action.payload return state }, + setIsTokenBalanceLoading(state, action: PayloadAction) { + state.tokenBalanceLoading = action.payload + return state + }, // Triggers rescan for tokens that we control rescanTokens() {}, airdrop() {}, diff --git a/src/store/sagas/connection.ts b/src/store/sagas/connection.ts index 6cc72eb6f..417d38024 100644 --- a/src/store/sagas/connection.ts +++ b/src/store/sagas/connection.ts @@ -43,7 +43,7 @@ export function* initConnection(): Generator { yield* put( snackbarsActions.add({ - message: 'Eclipse network connected', + message: 'Eclipse network connected.', variant: 'success', persist: false }) diff --git a/src/store/sagas/creator.ts b/src/store/sagas/creator.ts index 07c9ff4bb..60fef417b 100644 --- a/src/store/sagas/creator.ts +++ b/src/store/sagas/creator.ts @@ -262,7 +262,7 @@ export function* handleCreateToken(action: PayloadAction) { yield put( snackbarsActions.add({ - message: 'Token created successfully', + message: 'Token created successfully.', variant: 'success', persist: false, txid: signatureTx diff --git a/src/store/sagas/locker.ts b/src/store/sagas/locker.ts index 370c652ad..417760de9 100644 --- a/src/store/sagas/locker.ts +++ b/src/store/sagas/locker.ts @@ -90,9 +90,10 @@ export function* handleLockPosition(action: PayloadAction) if (confirmedTx.value.err === null) { yield* put(actions.setLockSuccess(true)) yield* put(positionsActions.getPositionsList()) + yield put( snackbarsActions.add({ - message: 'Position locked successfully', + message: 'Position locked successfully.', variant: 'success', persist: false, txid: signatureTx diff --git a/src/store/sagas/pool.ts b/src/store/sagas/pool.ts index ce8194347..36440de80 100644 --- a/src/store/sagas/pool.ts +++ b/src/store/sagas/pool.ts @@ -16,15 +16,7 @@ import { } from '@store/reducers/pools' import { tokens } from '@store/selectors/pools' import { network, rpcAddress } from '@store/selectors/solanaConnection' -import { - findPairs, - getFullNewTokensData, - getPools, - getPoolsFromAddresses, - getTickmapsFromPools, - getTicksFromAddresses -} from '@utils/utils' -import { parseTick } from '@invariant-labs/sdk-eclipse/lib/market' +import { findPairs, getFullNewTokensData, getPools, getPoolsFromAddresses } from '@utils/utils' export interface iTick { index: Tick[] @@ -180,11 +172,12 @@ export function* fetchTicksAndTickMaps(action: PayloadAction) { const { tokenFrom, tokenTo, allPools } = action.payload - - if (tokenFrom.equals(PublicKey.default) || tokenTo.equals(PublicKey.default)) { - return + enum IsXtoY { + Up = 'up', + Down = 'down' } try { @@ -192,55 +185,35 @@ export function* fetchNearestTicksForPair(action: PayloadAction { - const isXtoY = tokenFrom.equals(pool.tokenX) - const pair = new Pair(tokenFrom, tokenTo, { - fee: pool.fee, - tickSpacing: pool.tickSpacing + const results = yield* all([ + ...pools.map(pool => { + const isXtoY = tokenFrom.equals(pool.tokenX) + return call( + [marketProgram, marketProgram.getClosestTicks], + new Pair(tokenFrom, tokenTo, { fee: pool.fee, tickSpacing: pool.tickSpacing }), + TICK_CROSSES_PER_IX + 3, + undefined, + isXtoY ? IsXtoY.Down : IsXtoY.Up + ) }) + ]) - return marketProgram.findTickAddressesForSwap( - pair, - pool, - tickmaps[pool.tickmap.toBase58()], - isXtoY, - batchSize - ) - }) - - const ticks = yield* call(getTicksFromAddresses, marketProgram, tickAddresses.flat()) - - let offset = 0 - for (let i = 0; i < tickAddresses.length; i++) { - const ticksInPool = tickAddresses[i].length - yield* put( - actions.setNearestTicksForPair({ - index: pools[i].address.toString(), - tickStructure: ticks - .slice(offset, offset + ticksInPool) - .filter(t => !!t) - .map(t => parseTick(t)) - }) - ) - offset += ticksInPool - - const tickmapKey = pools[i].tickmap.toBase58() - if (tickmaps[tickmapKey]) { + if (results.length > 0) { + for (let i = 0; i < pools.length; i++) { yield* put( - actions.updateTickmap({ - address: tickmapKey, - bitmap: tickmaps[tickmapKey].bitmap + actions.setNearestTicksForPair({ + index: pools[i].address.toString(), + tickStructure: results[i] }) ) } } } catch (error) { console.log(error) + yield* call(handleRpcError, (error as Error).message) } } diff --git a/src/store/sagas/positions.ts b/src/store/sagas/positions.ts index e30f7c9dd..4ac7d81e9 100644 --- a/src/store/sagas/positions.ts +++ b/src/store/sagas/positions.ts @@ -34,7 +34,8 @@ import { positionsList, positionsWithPoolsData, singlePositionData, - currentPositionTicks + currentPositionTicks, + prices } from '@store/selectors/positions' import { GuardPredicate } from '@redux-saga/types' import { network, rpcAddress } from '@store/selectors/solanaConnection' @@ -45,14 +46,18 @@ import { createLoaderKey, createPlaceholderLiquidityPlot, getLiquidityTicksByPositionsList, - getPositionsAddressesFromRange + getPositionsAddressesFromRange, + printBN } from '@utils/utils' import { actions as connectionActions } from '@store/reducers/solanaConnection' import { + calculateClaimAmount, createNativeAtaInstructions, createNativeAtaWithTransferInstructions } from '@invariant-labs/sdk-eclipse/lib/utils' import { networkTypetoProgramNetwork } from '@utils/web3/connection' +import { ClaimAllFee, Market, Tick } from '@invariant-labs/sdk-eclipse/lib/market' +import { getEclipseWallet } from '@utils/web3/wallet' function* handleInitPositionAndPoolWithETH(action: PayloadAction): Generator { const data = action.payload @@ -235,7 +240,7 @@ function* handleInitPositionAndPoolWithETH(action: PayloadAction): Ge return yield put( snackbarsActions.add({ - message: 'Position adding failed. Please try again', + message: 'Position adding failed. Please try again.', variant: 'error', persist: false, txid: txId @@ -491,7 +496,7 @@ function* handleInitPositionWithETH(action: PayloadAction): Ge } else { yield put( snackbarsActions.add({ - message: 'Position added successfully', + message: 'Position added successfully.', variant: 'success', persist: false, txid: txId @@ -529,7 +534,7 @@ function* handleInitPositionWithETH(action: PayloadAction): Ge } else { yield put( snackbarsActions.add({ - message: 'Failed to send. Please try again', + message: 'Failed to send. Please try again.', variant: 'error', persist: false }) @@ -704,7 +709,7 @@ export function* handleInitPosition(action: PayloadAction): Ge if (!txid.length) { yield put( snackbarsActions.add({ - message: 'Position adding failed. Please try again', + message: 'Position adding failed. Please try again.', variant: 'error', persist: false, txid @@ -713,7 +718,7 @@ export function* handleInitPosition(action: PayloadAction): Ge } else { yield put( snackbarsActions.add({ - message: 'Position added successfully', + message: 'Position added successfully.', variant: 'success', persist: false, txid @@ -749,7 +754,7 @@ export function* handleInitPosition(action: PayloadAction): Ge } else { yield put( snackbarsActions.add({ - message: 'Failed to send. Please try again', + message: 'Failed to send. Please try again.', variant: 'error', persist: false }) @@ -1038,16 +1043,18 @@ export function* handleClaimFeeWithETH({ index, isLocked }: { index: number; isL if (!txid.length) { yield put( snackbarsActions.add({ - message: 'Failed to claim fee. Please try again', + message: 'Failed to claim fee. Please try again.', variant: 'error', persist: false, txid }) ) } else { + yield put(actions.getPositionsList()) + yield put( snackbarsActions.add({ - message: 'Fee claimed successfully', + message: 'Fee claimed successfully.', variant: 'success', persist: false, txid @@ -1081,7 +1088,7 @@ export function* handleClaimFeeWithETH({ index, isLocked }: { index: number; isL } else { yield put( snackbarsActions.add({ - message: 'Failed to send. Please try again', + message: 'Failed to send. Please try again.', variant: 'error', persist: false }) @@ -1203,16 +1210,18 @@ export function* handleClaimFee(action: PayloadAction<{ index: number; isLocked: if (!txid.length) { yield put( snackbarsActions.add({ - message: 'Failed to claim fee. Please try again', + message: 'Failed to claim fee. Please try again.', variant: 'error', persist: false, txid }) ) } else { + yield put(actions.getPositionsList()) + yield put( snackbarsActions.add({ - message: 'Fee claimed successfully', + message: 'Fee claimed successfully.', variant: 'success', persist: false, txid @@ -1247,7 +1256,7 @@ export function* handleClaimFee(action: PayloadAction<{ index: number; isLocked: } else { yield put( snackbarsActions.add({ - message: 'Failed to send. Please try again', + message: 'Failed to send. Please try again.', variant: 'error', persist: false }) @@ -1258,6 +1267,158 @@ export function* handleClaimFee(action: PayloadAction<{ index: number; isLocked: } } +export function* handleClaimAllFees() { + const loaderClaimAllFees = createLoaderKey() + const loaderSigningTx = createLoaderKey() + + try { + const connection = yield* call(getConnection) + const networkType = yield* select(network) + const rpc = yield* select(rpcAddress) + const wallet = yield* call(getWallet) + const marketProgram = yield* call(getMarketProgram, networkType, rpc, wallet as IWallet) + + const allPositionsData = yield* select(positionsWithPoolsData) + const tokensAccounts = yield* select(accounts) + + if (allPositionsData.length === 0) { + return + } + if (allPositionsData.length === 1) { + const claimFeeAction = actions.claimFee({ index: 0, isLocked: false }) + return yield* call(handleClaimFee, claimFeeAction) + } + + yield* put(actions.setAllClaimLoader(true)) + yield put( + snackbarsActions.add({ + message: 'Claiming all fees', + variant: 'pending', + persist: true, + key: loaderClaimAllFees + }) + ) + + for (const position of allPositionsData) { + const pool = allPositionsData[position.positionIndex].poolData + + if (!tokensAccounts[pool.tokenX.toString()]) { + yield* call(createAccount, pool.tokenX) + } + if (!tokensAccounts[pool.tokenY.toString()]) { + yield* call(createAccount, pool.tokenY) + } + } + + const formattedPositions = allPositionsData.map(position => ({ + pair: new Pair(position.poolData.tokenX, position.poolData.tokenY, { + fee: position.poolData.fee, + tickSpacing: position.poolData.tickSpacing + }), + index: position.positionIndex, + lowerTickIndex: position.lowerTickIndex, + upperTickIndex: position.upperTickIndex + })) + + const txs = yield* call([marketProgram, marketProgram.claimAllFeesTxs], { + owner: wallet.publicKey, + positions: formattedPositions + } as ClaimAllFee) + + yield put(snackbarsActions.add({ ...SIGNING_SNACKBAR_CONFIG, key: loaderSigningTx })) + + for (const { tx, additionalSigner } of txs) { + const blockhash = yield* call([connection, connection.getLatestBlockhash]) + tx.recentBlockhash = blockhash.blockhash + tx.feePayer = wallet.publicKey + + let signedTx: Transaction + if (additionalSigner) { + const partiallySignedTx = yield* call([wallet, wallet.signTransaction], tx) + partiallySignedTx.partialSign(additionalSigner) + signedTx = partiallySignedTx + } else { + signedTx = yield* call([wallet, wallet.signTransaction], tx) + } + + const txid = yield* call(sendAndConfirmRawTransaction, connection, signedTx.serialize(), { + skipPreflight: false + }) + + if (!txid.length) { + yield put( + snackbarsActions.add({ + message: 'Failed to claim some fees. Please try again.', + variant: 'error', + persist: false, + txid + }) + ) + } + } + + yield put( + snackbarsActions.add({ + message: 'All fees claimed successfully.', + variant: 'success', + persist: false + }) + ) + + for (const position of formattedPositions) { + yield put(actions.getSinglePosition({ index: position.index, isLocked: false })) + } + + closeSnackbar(loaderSigningTx) + yield put(snackbarsActions.remove(loaderSigningTx)) + closeSnackbar(loaderClaimAllFees) + yield put(snackbarsActions.remove(loaderClaimAllFees)) + + yield put(actions.getPositionsList()) + yield put(actions.calculateTotalUnclaimedFees()) + + yield* put(actions.setAllClaimLoader(false)) + } catch (error) { + yield* put(actions.setAllClaimLoader(false)) + + console.log(error) + + closeSnackbar(loaderClaimAllFees) + yield put(snackbarsActions.remove(loaderClaimAllFees)) + closeSnackbar(loaderSigningTx) + yield put(snackbarsActions.remove(loaderSigningTx)) + + if (error instanceof TransactionExpiredTimeoutError) { + yield put( + snackbarsActions.add({ + message: TIMEOUT_ERROR_MESSAGE, + variant: 'info', + persist: true, + txid: error.signature + }) + ) + yield put(connectionActions.setTimeoutError(true)) + yield put(RPCAction.setRpcStatus(RpcStatus.Error)) + } else { + yield put( + snackbarsActions.add({ + message: 'Failed to claim fees. Please try again.', + variant: 'error', + persist: false + }) + ) + } + + try { + if (error instanceof Error) { + yield* call(handleRpcError, error.message) + } + } catch (rpcError) { + console.error('RPC error handling failed:', rpcError) + } + } +} + export function* handleClosePositionWithETH(data: ClosePositionData) { const loaderClosePosition = createLoaderKey() const loaderSigningTx = createLoaderKey() @@ -1377,7 +1538,7 @@ export function* handleClosePositionWithETH(data: ClosePositionData) { if (!txid.length) { yield put( snackbarsActions.add({ - message: 'Failed to close position. Please try again', + message: 'Failed to close position. Please try again.', variant: 'error', persist: false, txid @@ -1386,7 +1547,7 @@ export function* handleClosePositionWithETH(data: ClosePositionData) { } else { yield put( snackbarsActions.add({ - message: 'Position closed successfully', + message: 'Position closed successfully.', variant: 'success', persist: false, txid @@ -1423,7 +1584,7 @@ export function* handleClosePositionWithETH(data: ClosePositionData) { } else { yield put( snackbarsActions.add({ - message: 'Failed to send. Please try again', + message: 'Failed to send. Please try again.', variant: 'error', persist: false }) @@ -1551,7 +1712,7 @@ export function* handleClosePosition(action: PayloadAction) { if (!txid.length) { yield put( snackbarsActions.add({ - message: 'Failed to close position. Please try again', + message: 'Failed to close position. Please try again.', variant: 'error', persist: false, txid @@ -1560,7 +1721,7 @@ export function* handleClosePosition(action: PayloadAction) { } else { yield put( snackbarsActions.add({ - message: 'Position closed successfully', + message: 'Position closed successfully.', variant: 'success', persist: false, txid @@ -1597,7 +1758,7 @@ export function* handleClosePosition(action: PayloadAction) { } else { yield put( snackbarsActions.add({ - message: 'Failed to send. Please try again', + message: 'Failed to send. Please try again.', variant: 'error', persist: false }) @@ -1712,7 +1873,6 @@ export function* handleGetCurrentPositionRangeTicks( export function* handleUpdatePositionsRangeTicks( action: PayloadAction<{ positionId: string; fetchTick?: FetchTick }> ) { - //TODO finish after update position list item try { const networkType = yield* select(network) const rpc = yield* select(rpcAddress) @@ -1781,6 +1941,79 @@ export function* handleUpdatePositionsRangeTicks( } } +function* getTickWithCache( + pair: Pair, + tickIndex: number, + ticksCache: Map, + marketProgram: Market +): Generator { + const cacheKey = `${pair.tokenX.toString()}-${pair.tokenY.toString()}-${tickIndex}` + + if (ticksCache.has(cacheKey)) { + return ticksCache.get(cacheKey)! + } + + const tick = yield* call([marketProgram, 'getTick'], pair, tickIndex) + ticksCache.set(cacheKey, tick) + return tick +} + +export function* handleCalculateTotalUnclaimedFees() { + try { + const positionList = yield* select(positionsWithPoolsData) + const pricesData = yield* select(prices) + const networkType = yield* select(network) + const rpc = yield* select(rpcAddress) + + const wallet = getEclipseWallet() as IWallet + const marketProgram: Market = yield* call(getMarketProgram, networkType, rpc, wallet) + + const ticksCache: Map = new Map() + + const ticks: Tick[][] = yield* all( + positionList.map(function* (position) { + const pair = new Pair(position.poolData.tokenX, position.poolData.tokenY, { + fee: position.poolData.fee, + tickSpacing: position.poolData.tickSpacing + }) + + const [lowerTick, upperTick]: Tick[] = yield* all([ + call(getTickWithCache, pair, position.lowerTickIndex, ticksCache, marketProgram), + call(getTickWithCache, pair, position.upperTickIndex, ticksCache, marketProgram) + ]) + + return [lowerTick, upperTick] + }) + ) + + const total = positionList.reduce((acc: number, position, i: number) => { + const [lowerTick, upperTick] = ticks[i] + const [bnX, bnY] = calculateClaimAmount({ + position, + tickLower: lowerTick, + tickUpper: upperTick, + tickCurrent: position.poolData.currentTickIndex, + feeGrowthGlobalX: position.poolData.feeGrowthGlobalX, + feeGrowthGlobalY: position.poolData.feeGrowthGlobalY + }) + + const xValue = + +printBN(bnX, position.tokenX.decimals) * + (pricesData.data[position.tokenX.assetAddress.toString()] ?? 0) + const yValue = + +printBN(bnY, position.tokenY.decimals) * + (pricesData.data[position.tokenY.assetAddress.toString()] ?? 0) + + return acc + xValue + yValue + }, 0) + + yield* put(actions.setUnclaimedFees(isFinite(total) ? total : 0)) + } catch (error) { + console.error('Error calculating unclaimed fees:', error) + yield* put(actions.setUnclaimedFeesError()) + } +} + export function* initPositionHandler(): Generator { yield* takeEvery(actions.initPosition, handleInitPosition) } @@ -1793,6 +2026,15 @@ export function* getPositionsListHandler(): Generator { export function* claimFeeHandler(): Generator { yield* takeEvery(actions.claimFee, handleClaimFee) } + +export function* claimAllFeeHandler(): Generator { + yield* takeEvery(actions.claimAllFee, handleClaimAllFees) +} + +export function* unclaimedFeesHandler(): Generator { + yield* takeEvery(actions.calculateTotalUnclaimedFees, handleCalculateTotalUnclaimedFees) +} + export function* closePositionHandler(): Generator { yield* takeEvery(actions.closePosition, handleClosePosition) } @@ -1814,6 +2056,8 @@ export function* positionsSaga(): Generator { getCurrentPlotTicksHandler, getPositionsListHandler, claimFeeHandler, + unclaimedFeesHandler, + claimAllFeeHandler, closePositionHandler, getSinglePositionHandler, getCurrentPositionRangeTicksHandler, diff --git a/src/store/sagas/swap.ts b/src/store/sagas/swap.ts index 19d9b5377..3ee31bbab 100644 --- a/src/store/sagas/swap.ts +++ b/src/store/sagas/swap.ts @@ -198,7 +198,7 @@ export function* handleSwapWithETH(): Generator { return yield put( snackbarsActions.add({ - message: 'ETH wrapping failed. Please try again', + message: 'ETH wrapping failed. Please try again.', variant: 'error', persist: false, txid: initialTxid @@ -230,7 +230,7 @@ export function* handleSwapWithETH(): Generator { // } else { yield put( snackbarsActions.add({ - message: 'Tokens swapped successfully', + message: 'Tokens swapped successfully.', variant: 'success', persist: false, txid: initialTxid @@ -295,7 +295,7 @@ export function* handleSwapWithETH(): Generator { } else { yield put( snackbarsActions.add({ - message: 'Failed to send. Please try again', + message: 'Failed to send. Please try again.', variant: 'error', persist: false }) @@ -415,7 +415,7 @@ export function* handleSwap(): Generator { if (!txid.length) { yield put( snackbarsActions.add({ - message: 'Tokens swapping failed. Please try again', + message: 'Tokens swapping failed. Please try again.', variant: 'error', persist: false, txid @@ -424,7 +424,7 @@ export function* handleSwap(): Generator { } else { yield put( snackbarsActions.add({ - message: 'Tokens swapped successfully', + message: 'Tokens swapped successfully.', variant: 'success', persist: false, txid @@ -458,7 +458,7 @@ export function* handleSwap(): Generator { } else { yield put( snackbarsActions.add({ - message: 'Failed to send. Please try again', + message: 'Failed to send. Please try again.', variant: 'error', persist: false }) diff --git a/src/store/sagas/wallet.ts b/src/store/sagas/wallet.ts index e5bb60e1b..c076bd14b 100644 --- a/src/store/sagas/wallet.ts +++ b/src/store/sagas/wallet.ts @@ -62,19 +62,18 @@ export function* getWallet(): SagaGenerator { return wallet } export function* getBalance(pubKey: PublicKey): SagaGenerator { + yield* put(actions.setIsEthBalanceLoading(true)) const connection = yield* call(getConnection) const balance = yield* call([connection, connection.getBalance], pubKey) - return new BN(balance) + yield* put(actions.setBalance(new BN(balance))) + yield* put(actions.setIsEthBalanceLoading(false)) } export function* handleBalance(): Generator { const wallet = yield* call(getWallet) yield* put(actions.setAddress(wallet.publicKey)) - yield* put(actions.setIsBalanceLoading(true)) - const balance = yield* call(getBalance, wallet.publicKey) - yield* put(actions.setBalance(balance)) + yield* call(getBalance, wallet.publicKey) yield* call(fetchTokensAccounts) - yield* put(actions.setIsBalanceLoading(false)) } interface IparsedTokenInfo { @@ -94,6 +93,7 @@ interface TokenAccountInfo { export function* fetchTokensAccounts(): Generator { const connection = yield* call(getConnection) const wallet = yield* call(getWallet) + yield* put(actions.setIsTokenBalanceLoading(true)) const { splTokensAccounts, token2022TokensAccounts } = yield* all({ splTokensAccounts: call( @@ -145,6 +145,7 @@ export function* fetchTokensAccounts(): Generator { yield* put(actions.setTokenAccounts(newAccounts)) yield* put(poolsActions.addTokens(unknownTokens)) + yield* put(actions.setIsTokenBalanceLoading(false)) } // export function* getToken(tokenAddress: PublicKey): SagaGenerator { @@ -321,7 +322,7 @@ export function* transferAirdropSOL(): Generator { if (!txid.length) { yield put( snackbarsActions.add({ - message: 'Failed to airdrop testnet ETH. Please try again', + message: 'Failed to airdrop testnet ETH. Please try again.', variant: 'error', persist: false, txid @@ -330,7 +331,7 @@ export function* transferAirdropSOL(): Generator { } else { yield put( snackbarsActions.add({ - message: 'Testnet ETH airdrop successfully', + message: 'Testnet ETH airdrop successfully.', variant: 'success', persist: false, txid @@ -525,7 +526,7 @@ export function* init(isEagerConnect: boolean): Generator { if (isEagerConnect) { yield* put( snackbarsActions.add({ - message: 'Wallet reconnected', + message: 'Wallet reconnected.', variant: 'success', persist: false }) @@ -533,7 +534,7 @@ export function* init(isEagerConnect: boolean): Generator { } else { yield* put( snackbarsActions.add({ - message: 'Wallet connected', + message: 'Wallet connected.', variant: 'success', persist: false }) @@ -571,7 +572,7 @@ export function* handleConnect(action: PayloadAction): Generator { if (walletStatus === Status.Initialized && wallet.connected) { yield* put( snackbarsActions.add({ - message: 'Wallet already connected', + message: 'Wallet already connected.', variant: 'info', persist: false }) @@ -584,14 +585,6 @@ export function* handleConnect(action: PayloadAction): Generator { } } -export function* handleChangeWalletInExtenstion(): Generator { - try { - yield* call(init, false) - } catch (error) { - yield* call(handleRpcError, (error as Error).message) - } -} - export function* handleDisconnect(): Generator { try { yield* call(disconnectWallet) @@ -675,7 +668,7 @@ export function* handleUnwrapWETH(): Generator { if (!unwrapTxid.length) { yield put( snackbarsActions.add({ - message: 'Wrapped ETH unwrap failed. Try to unwrap it in your wallet', + message: 'Wrapped ETH unwrap failed. Try to unwrap it in your wallet.', variant: 'warning', persist: false, txid: unwrapTxid @@ -684,7 +677,7 @@ export function* handleUnwrapWETH(): Generator { } else { yield put( snackbarsActions.add({ - message: 'ETH unwrapped successfully', + message: 'ETH unwrapped successfully.', variant: 'success', persist: false, txid: unwrapTxid @@ -703,9 +696,6 @@ export function* handleUnwrapWETH(): Generator { yield put(snackbarsActions.remove(loaderUnwrapWETH)) } -export function* changeWalletInExtenstionHandler(): Generator { - yield takeLatest(actions.changeWalletInExtension, handleChangeWalletInExtenstion) -} export function* connectHandler(): Generator { yield takeLatest(actions.connect, handleConnect) } @@ -728,13 +718,8 @@ export function* unwrapWETHHandler(): Generator { export function* walletSaga(): Generator { yield all( - [ - airdropSaga, - connectHandler, - disconnectHandler, - handleBalanceSaga, - unwrapWETHHandler, - changeWalletInExtenstionHandler - ].map(spawn) + [airdropSaga, connectHandler, disconnectHandler, handleBalanceSaga, unwrapWETHHandler].map( + spawn + ) ) } diff --git a/src/store/selectors/positions.ts b/src/store/selectors/positions.ts index 47abfee94..0fdcf0548 100644 --- a/src/store/selectors/positions.ts +++ b/src/store/selectors/positions.ts @@ -10,7 +10,9 @@ const store = (s: AnyProps) => s[positionsSliceName] as IPositionsStore export const { lastPage, positionsList, + unclaimedFees, plotTicks, + prices, currentPositionId, currentPositionTicks, initPosition, @@ -18,7 +20,9 @@ export const { } = keySelectors(store, [ 'lastPage', 'positionsList', + 'unclaimedFees', 'plotTicks', + 'prices', 'currentPositionId', 'currentPositionTicks', 'initPosition', diff --git a/src/store/selectors/solanaWallet.ts b/src/store/selectors/solanaWallet.ts index 38674f2cc..ed81bcd8a 100644 --- a/src/store/selectors/solanaWallet.ts +++ b/src/store/selectors/solanaWallet.ts @@ -8,15 +8,29 @@ import { WRAPPED_ETH_ADDRESS } from '@store/consts/static' const store = (s: AnyProps) => s[solanaWalletSliceName] as ISolanaWallet -export const { address, balance, accounts, status, balanceLoading, thankYouModalShown } = - keySelectors(store, [ - 'address', - 'balance', - 'accounts', - 'status', - 'balanceLoading', - 'thankYouModalShown' - ]) +export const { + address, + balance, + accounts, + status, + ethBalanceLoading, + tokenBalanceLoading, + thankYouModalShown +} = keySelectors(store, [ + 'address', + 'balance', + 'accounts', + 'status', + 'ethBalanceLoading', + 'tokenBalanceLoading', + 'thankYouModalShown' +]) + +export const balanceLoading = createSelector( + ethBalanceLoading, + tokenBalanceLoading, + (a, b) => a || b +) export const tokenBalance = (tokenAddress: PublicKey) => createSelector(accounts, tokensAccounts => { @@ -64,7 +78,7 @@ export const swapTokens = createSelector( balance: token.address.toString() === WRAPPED_ETH_ADDRESS ? ethBalance - : (allAccounts[token.address.toString()]?.balance ?? new BN(0)) + : allAccounts[token.address.toString()]?.balance ?? new BN(0) })) } ) @@ -80,7 +94,7 @@ export const poolTokens = createSelector( balance: token.address.toString() === WRAPPED_ETH_ADDRESS ? ethBalance - : (allAccounts[token.address.toString()]?.balance ?? new BN(0)) + : allAccounts[token.address.toString()]?.balance ?? new BN(0) })) } ) @@ -99,7 +113,7 @@ export const swapTokensDict = createSelector( balance: val.address.toString() === WRAPPED_ETH_ADDRESS ? ethBalance - : (allAccounts[val.address.toString()]?.balance ?? new BN(0)) + : allAccounts[val.address.toString()]?.balance ?? new BN(0) } }) @@ -146,7 +160,8 @@ export const solanaWalletSelectors = { accounts, status, tokenAccount, - balanceLoading, + ethBalanceLoading, + tokenBalanceLoading, thankYouModalShown } export default solanaWalletSelectors diff --git a/src/store/types/userOverview.ts b/src/store/types/userOverview.ts new file mode 100644 index 000000000..c7ba8df49 --- /dev/null +++ b/src/store/types/userOverview.ts @@ -0,0 +1,61 @@ +import { BN } from '@coral-xyz/anchor' +import { PublicKey } from '@solana/web3.js' +import { PoolWithAddressAndIndex } from '@store/selectors/positions' + +export interface StrategyConfig { + tokenAddressA: string + tokenAddressB?: string + feeTier: string +} + +export interface Token { + name: string + decimal: number + balance: BN + assetsAddress: string + coingeckoId?: string + icon: string +} + +export interface UnclaimedFee { + id: number + tokenX: Token + tokenY: Token + fee: number + value: number + unclaimedFee: number +} + +export interface PoolAsset { + id: number + name: string + fee: number + value: number + unclaimedFee: number +} + +export interface TokenPool { + id: PublicKey + symbol: string + icon: string + isUnknown?: boolean + value: number + amount: number +} + +export interface ProcessedPool { + id: string + fee: number + lowerTickIndex: number + upperTickIndex: number + poolData: PoolWithAddressAndIndex + tokenX: Token + tokenY: Token +} + +export interface TokenPositionEntry { + token: string + value: number + positionId: string + logo?: string +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 6080bcb80..506af7544 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -26,8 +26,7 @@ import { parsePool, RawPoolStructure, parsePosition, - parseTick, - RawTick + parseTick } from '@invariant-labs/sdk-eclipse/lib/market' import axios from 'axios' import { @@ -90,7 +89,8 @@ import { WETH_MAIN, KYSOL_MAIN, EZSOL_MAIN, - LEADERBOARD_DECIMAL + LEADERBOARD_DECIMAL, + POSITIONS_PER_PAGE } from '@store/consts/static' import { PoolWithAddress } from '@store/reducers/pools' import { bs58 } from '@coral-xyz/anchor/dist/cjs/utils/bytes' @@ -818,13 +818,27 @@ function trimEndingZeros(num) { return num.toString().replace(/0+$/, '') } -export const formatNumberWithoutSuffix = (number: number | bigint | string): string => { +export const formatNumberWithoutSuffix = ( + number: number | bigint | string, + options?: { twoDecimals?: boolean } +): string => { const numberAsNumber = Number(number) const isNegative = numberAsNumber < 0 const absNumberAsNumber = Math.abs(numberAsNumber) - const absNumberAsString = numberToString(absNumberAsNumber) + if (options?.twoDecimals) { + if (absNumberAsNumber === 0) { + return '0' + } + if (absNumberAsNumber > 0 && absNumberAsNumber < 0.01) { + return isNegative ? '-<0.01' : '<0.01' + } + return isNegative + ? '-' + absNumberAsNumber.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',') + : absNumberAsNumber.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',') + } + const absNumberAsString = numberToString(absNumberAsNumber) const [beforeDot, afterDot] = absNumberAsString.split('.') const leadingZeros = afterDot ? countLeadingZeros(afterDot) : 0 @@ -839,7 +853,6 @@ export const formatNumberWithoutSuffix = (number: number | bigint | string): str return isNegative ? '-' + formattedNumber : formattedNumber } - export const formatBalance = (number: number | bigint | string): string => { const numberAsString = numberToString(number) @@ -1262,39 +1275,6 @@ export const getPoolsFromAddresses = async ( } } -export const getTickmapsFromPools = async ( - pools: PoolWithAddress[], - marketProgram: Market -): Promise> => { - { - try { - const addresses = pools.map(pool => pool.tickmap) - const tickmaps = (await marketProgram.program.account.tickmap.fetchMultiple( - addresses - )) as Array - - return tickmaps.reduce((acc, cur, idx) => { - if (cur) { - acc[addresses[idx].toBase58()] = cur - } - return acc - }, {}) - } catch (error) { - console.log(error) - return {} - } - } -} - -export const getTicksFromAddresses = async (market: Market, addresses: PublicKey[]) => { - try { - return (await market.program.account.tick.fetchMultiple(addresses)) as Array - } catch (e) { - console.log(e) - return [] - } -} - export const getPools = async ( pairs: Pair[], marketProgram: Market @@ -1981,6 +1961,37 @@ export const formatNumberWithSpaces = (number: string) => { return trimmedNumber.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') } +export const generatePositionTableLoadingData = () => { + const getRandomNumber = (min: number, max: number) => + Math.floor(Math.random() * (max - min + 1)) + min + + return Array(POSITIONS_PER_PAGE) + .fill(null) + .map((_, index) => { + const currentPrice = Math.random() * 10000 + + return { + id: `loading-${index}`, + poolAddress: `pool-${index}`, + tokenXName: 'FOO', + tokenYName: 'BAR', + tokenXIcon: undefined, + tokenYIcon: undefined, + currentPrice, + fee: getRandomNumber(1, 10) / 10, + min: currentPrice * 0.8, + max: currentPrice * 1.2, + position: getRandomNumber(1000, 10000), + valueX: getRandomNumber(1000, 10000), + valueY: getRandomNumber(1000, 10000), + poolData: {}, + isActive: Math.random() > 0.5, + tokenXLiq: getRandomNumber(100, 1000), + tokenYLiq: getRandomNumber(10000, 100000), + network: 'mainnet' + } + }) +} export const sciToString = (sciStr: string | number) => { const number = Number(sciStr) if (!Number.isFinite(number)) throw new Error('Invalid number')