diff --git a/next-env.d.ts b/next-env.d.ts index 4f11a03..fd36f94 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/package.json b/package.json index 0da725e..718f79e 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-toggle": "^1.0.3", "@radix-ui/react-tooltip": "^1.0.7", - "@rainbow-me/rainbowkit": "2.1.1", + "@rainbow-me/rainbowkit": "2.1.6", "@repeaterjs/react-hooks": "^0.1.1", "@t3-oss/env-nextjs": "^0.10.1", "@tanstack/query-core": "^5.40.0", @@ -117,6 +117,7 @@ "@types/d3-scale": "^3", "@types/eslint": "^8.56.10", "@types/leaflet": "^1.9.12", + "@types/lodash": "^4.17.7", "@types/logrocket-react": "^3.0.3", "@types/node": "^20.12.12", "@types/pg": "^8.11.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 070f132..1cb9c7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -81,8 +81,8 @@ importers: specifier: ^1.0.7 version: 1.0.7(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@rainbow-me/rainbowkit': - specifier: 2.1.1 - version: 2.1.1(@tanstack/react-query@5.40.0(react@18.2.0))(@types/react@18.3.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(viem@2.13.1(bufferutil@4.0.8)(typescript@5.4.5)(zod@3.23.8))(wagmi@2.9.7(@tanstack/query-core@5.40.0)(@tanstack/react-query@5.40.0(react@18.2.0))(@types/react@18.3.3)(bufferutil@4.0.8)(react-dom@18.2.0(react@18.2.0))(react-i18next@13.5.0(i18next@22.5.1)(react-dom@18.2.0(react@18.2.0))(react-native@0.73.2(@babel/core@7.24.6)(@babel/preset-env@7.23.8(@babel/core@7.24.6))(bufferutil@4.0.8)(react@18.2.0))(react@18.2.0))(react-native@0.73.2(@babel/core@7.24.6)(@babel/preset-env@7.23.8(@babel/core@7.24.6))(bufferutil@4.0.8)(react@18.2.0))(react@18.2.0)(rollup@4.9.5)(typescript@5.4.5)(viem@2.13.1(bufferutil@4.0.8)(typescript@5.4.5)(zod@3.23.8))(zod@3.23.8)) + specifier: 2.1.6 + version: 2.1.6(@tanstack/react-query@5.40.0(react@18.2.0))(@types/react@18.3.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(viem@2.13.1(bufferutil@4.0.8)(typescript@5.4.5)(zod@3.23.8))(wagmi@2.9.7(@tanstack/query-core@5.40.0)(@tanstack/react-query@5.40.0(react@18.2.0))(@types/react@18.3.3)(bufferutil@4.0.8)(react-dom@18.2.0(react@18.2.0))(react-i18next@13.5.0(i18next@22.5.1)(react-dom@18.2.0(react@18.2.0))(react-native@0.73.2(@babel/core@7.24.6)(@babel/preset-env@7.23.8(@babel/core@7.24.6))(bufferutil@4.0.8)(react@18.2.0))(react@18.2.0))(react-native@0.73.2(@babel/core@7.24.6)(@babel/preset-env@7.23.8(@babel/core@7.24.6))(bufferutil@4.0.8)(react@18.2.0))(react@18.2.0)(rollup@4.9.5)(typescript@5.4.5)(viem@2.13.1(bufferutil@4.0.8)(typescript@5.4.5)(zod@3.23.8))(zod@3.23.8)) '@repeaterjs/react-hooks': specifier: ^0.1.1 version: 0.1.1(react@18.2.0) @@ -297,6 +297,9 @@ importers: '@types/leaflet': specifier: ^1.9.12 version: 1.9.12 + '@types/lodash': + specifier: ^4.17.7 + version: 4.17.7 '@types/logrocket-react': specifier: ^3.0.3 version: 3.0.3 @@ -2456,8 +2459,8 @@ packages: '@radix-ui/rect@1.0.1': resolution: {integrity: sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ==} - '@rainbow-me/rainbowkit@2.1.1': - resolution: {integrity: sha512-iOn4m1CTv+ezzYpiA4QU+v6JFB94NY6h5ohpQi3Qt/qZXdHzp3NAKUrESQ10kFx2Xc4px80PKdggeES0UPZw0A==} + '@rainbow-me/rainbowkit@2.1.6': + resolution: {integrity: sha512-DCt6VYuPPxcPY6veuSOa784mHHHN0uSdDBTivdUBssmjTwHMmOrEs6kuKSYTPRu8EAwA1AvIc+ulSVnS022nbg==} engines: {node: '>=12.4'} peerDependencies: '@tanstack/react-query': '>=5.0.0' @@ -2976,6 +2979,9 @@ packages: '@types/leaflet@1.9.12': resolution: {integrity: sha512-BK7XS+NyRI291HIo0HCfE18Lp8oA30H1gpi1tf0mF3TgiCEzanQjOqNZ4x126SXzzi2oNSZhZ5axJp1k0iM6jg==} + '@types/lodash@4.17.7': + resolution: {integrity: sha512-8wTvZawATi/lsmNu10/j2hk1KEP0IvjubqPE3cu1Xz7xfXXt5oCq3SNUz4fMIP4XGF9Ky+Ue2tBA3hcS7LSBlA==} + '@types/logrocket-react@3.0.3': resolution: {integrity: sha512-fGpJV4ccwfWAL0293EQx5HrS4PGMvXBpTQOXpA/SLXSCm7fC2O19w7Y3CcgWDmyawQnqbzd8faKAL12VhPdZKA==} @@ -3113,17 +3119,17 @@ packages: '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} - '@vanilla-extract/css@1.14.0': - resolution: {integrity: sha512-rYfm7JciWZ8PFzBM/HDiE2GLnKI3xJ6/vdmVJ5BSgcCZ5CxRlM9Cjqclni9lGzF3eMOijnUhCd/KV8TOzyzbMA==} + '@vanilla-extract/css@1.15.5': + resolution: {integrity: sha512-N1nQebRWnXvlcmu9fXKVUs145EVwmWtMD95bpiEKtvehHDpUhmO1l2bauS7FGYKbi3dU1IurJbGpQhBclTr1ng==} - '@vanilla-extract/dynamic@2.1.0': - resolution: {integrity: sha512-8zl0IgBYRtgD1h+56Zu13wHTiMTJSVEa4F7RWX9vTB/5Xe2KtjoiqApy/szHPVFA56c+ex6A4GpCQjT1bKXbYw==} + '@vanilla-extract/dynamic@2.1.2': + resolution: {integrity: sha512-9BGMciD8rO1hdSPIAh1ntsG4LPD3IYKhywR7VOmmz9OO4Lx1hlwkSg3E6X07ujFx7YuBfx0GDQnApG9ESHvB2A==} - '@vanilla-extract/private@1.0.3': - resolution: {integrity: sha512-17kVyLq3ePTKOkveHxXuIJZtGYs+cSoev7BlP+Lf4916qfDhk/HBjvlYDe8egrea7LNPHKwSZJK/bzZC+Q6AwQ==} + '@vanilla-extract/private@1.0.6': + resolution: {integrity: sha512-ytsG/JLweEjw7DBuZ/0JCN4WAQgM9erfSTdS1NQY778hFQSZ6cfCDEZZ0sgVm4k54uNz6ImKB33AYvSR//fjxw==} - '@vanilla-extract/sprinkles@1.6.1': - resolution: {integrity: sha512-N/RGKwGAAidBupZ436RpuweRQHEFGU+mvAqBo8PRMAjJEmHoPDttV8RObaMLrJHWLqvX+XUMinHUnD0hFRQISw==} + '@vanilla-extract/sprinkles@1.6.3': + resolution: {integrity: sha512-oCHlQeYOBIJIA2yWy2GnY5wE2A7hGHDyJplJo4lb+KEIBcJWRnDJDg8ywDwQS5VfWJrBBO3drzYZPFpWQjAMiQ==} peerDependencies: '@vanilla-extract/css': ^1.0.0 @@ -3750,10 +3756,6 @@ packages: resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} engines: {node: '>=6'} - clsx@2.1.0: - resolution: {integrity: sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==} - engines: {node: '>=6'} - clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -4036,6 +4038,14 @@ packages: dedent@0.7.0: resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==} + dedent@1.5.3: + resolution: {integrity: sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + deep-eql@4.1.3: resolution: {integrity: sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==} engines: {node: '>=6'} @@ -5387,6 +5397,9 @@ packages: resolution: {integrity: sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==} engines: {node: 14 || >=16.14} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -5829,9 +5842,6 @@ packages: resolution: {integrity: sha512-ERAyNnZOfqM+Ao3RAvIXkYh5joP220yf59gVe2X/cI6SiCxIdi4c9HZKZD8R6q/RDXEje1THBju6iExiSsgJaQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - outdent@0.8.0: - resolution: {integrity: sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A==} - p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -6178,6 +6188,11 @@ packages: engines: {node: '>=10.13.0'} hasBin: true + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + query-string@7.1.3: resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} engines: {node: '>=6'} @@ -6306,6 +6321,16 @@ packages: '@types/react': optional: true + react-remove-scroll-bar@2.3.6: + resolution: {integrity: sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + react-remove-scroll@2.5.4: resolution: {integrity: sha512-xGVKJJr0SJGQVirVFAUZ2k1QLyO6m+2fy0l8Qawbp5Jgrv3DeLalrfMNBFSlmz5kriGGzsVBtGVnf4pTKIhhWA==} engines: {node: '>=10'} @@ -6326,8 +6351,8 @@ packages: '@types/react': optional: true - react-remove-scroll@2.5.7: - resolution: {integrity: sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==} + react-remove-scroll@2.6.0: + resolution: {integrity: sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==} engines: {node: '>=10'} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -9931,22 +9956,23 @@ snapshots: dependencies: '@babel/runtime': 7.24.4 - '@rainbow-me/rainbowkit@2.1.1(@tanstack/react-query@5.40.0(react@18.2.0))(@types/react@18.3.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(viem@2.13.1(bufferutil@4.0.8)(typescript@5.4.5)(zod@3.23.8))(wagmi@2.9.7(@tanstack/query-core@5.40.0)(@tanstack/react-query@5.40.0(react@18.2.0))(@types/react@18.3.3)(bufferutil@4.0.8)(react-dom@18.2.0(react@18.2.0))(react-i18next@13.5.0(i18next@22.5.1)(react-dom@18.2.0(react@18.2.0))(react-native@0.73.2(@babel/core@7.24.6)(@babel/preset-env@7.23.8(@babel/core@7.24.6))(bufferutil@4.0.8)(react@18.2.0))(react@18.2.0))(react-native@0.73.2(@babel/core@7.24.6)(@babel/preset-env@7.23.8(@babel/core@7.24.6))(bufferutil@4.0.8)(react@18.2.0))(react@18.2.0)(rollup@4.9.5)(typescript@5.4.5)(viem@2.13.1(bufferutil@4.0.8)(typescript@5.4.5)(zod@3.23.8))(zod@3.23.8))': + '@rainbow-me/rainbowkit@2.1.6(@tanstack/react-query@5.40.0(react@18.2.0))(@types/react@18.3.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(viem@2.13.1(bufferutil@4.0.8)(typescript@5.4.5)(zod@3.23.8))(wagmi@2.9.7(@tanstack/query-core@5.40.0)(@tanstack/react-query@5.40.0(react@18.2.0))(@types/react@18.3.3)(bufferutil@4.0.8)(react-dom@18.2.0(react@18.2.0))(react-i18next@13.5.0(i18next@22.5.1)(react-dom@18.2.0(react@18.2.0))(react-native@0.73.2(@babel/core@7.24.6)(@babel/preset-env@7.23.8(@babel/core@7.24.6))(bufferutil@4.0.8)(react@18.2.0))(react@18.2.0))(react-native@0.73.2(@babel/core@7.24.6)(@babel/preset-env@7.23.8(@babel/core@7.24.6))(bufferutil@4.0.8)(react@18.2.0))(react@18.2.0)(rollup@4.9.5)(typescript@5.4.5)(viem@2.13.1(bufferutil@4.0.8)(typescript@5.4.5)(zod@3.23.8))(zod@3.23.8))': dependencies: '@tanstack/react-query': 5.40.0(react@18.2.0) - '@vanilla-extract/css': 1.14.0 - '@vanilla-extract/dynamic': 2.1.0 - '@vanilla-extract/sprinkles': 1.6.1(@vanilla-extract/css@1.14.0) - clsx: 2.1.0 - qrcode: 1.5.3 + '@vanilla-extract/css': 1.15.5 + '@vanilla-extract/dynamic': 2.1.2 + '@vanilla-extract/sprinkles': 1.6.3(@vanilla-extract/css@1.15.5) + clsx: 2.1.1 + qrcode: 1.5.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-remove-scroll: 2.5.7(@types/react@18.3.3)(react@18.2.0) + react-remove-scroll: 2.6.0(@types/react@18.3.3)(react@18.2.0) ua-parser-js: 1.0.37 viem: 2.13.1(bufferutil@4.0.8)(typescript@5.4.5)(zod@3.23.8) wagmi: 2.9.7(@tanstack/query-core@5.40.0)(@tanstack/react-query@5.40.0(react@18.2.0))(@types/react@18.3.3)(bufferutil@4.0.8)(react-dom@18.2.0(react@18.2.0))(react-i18next@13.5.0(i18next@22.5.1)(react-dom@18.2.0(react@18.2.0))(react-native@0.73.2(@babel/core@7.24.6)(@babel/preset-env@7.23.8(@babel/core@7.24.6))(bufferutil@4.0.8)(react@18.2.0))(react@18.2.0))(react-native@0.73.2(@babel/core@7.24.6)(@babel/preset-env@7.23.8(@babel/core@7.24.6))(bufferutil@4.0.8)(react@18.2.0))(react@18.2.0)(rollup@4.9.5)(typescript@5.4.5)(viem@2.13.1(bufferutil@4.0.8)(typescript@5.4.5)(zod@3.23.8))(zod@3.23.8) transitivePeerDependencies: - '@types/react' + - babel-plugin-macros '@react-leaflet/core@2.1.0(leaflet@1.9.4)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: @@ -10645,6 +10671,8 @@ snapshots: dependencies: '@types/geojson': 7946.0.10 + '@types/lodash@4.17.7': {} + '@types/logrocket-react@3.0.3': dependencies: logrocket: 1.0.1 @@ -10821,29 +10849,32 @@ snapshots: '@ungap/structured-clone@1.2.0': {} - '@vanilla-extract/css@1.14.0': + '@vanilla-extract/css@1.15.5': dependencies: '@emotion/hash': 0.9.1 - '@vanilla-extract/private': 1.0.3 - chalk: 4.1.2 + '@vanilla-extract/private': 1.0.6 css-what: 6.1.0 cssesc: 3.0.0 csstype: 3.1.2 + dedent: 1.5.3 deep-object-diff: 1.1.9 deepmerge: 4.3.1 + lru-cache: 10.4.3 media-query-parser: 2.0.2 modern-ahocorasick: 1.0.1 - outdent: 0.8.0 + picocolors: 1.0.0 + transitivePeerDependencies: + - babel-plugin-macros - '@vanilla-extract/dynamic@2.1.0': + '@vanilla-extract/dynamic@2.1.2': dependencies: - '@vanilla-extract/private': 1.0.3 + '@vanilla-extract/private': 1.0.6 - '@vanilla-extract/private@1.0.3': {} + '@vanilla-extract/private@1.0.6': {} - '@vanilla-extract/sprinkles@1.6.1(@vanilla-extract/css@1.14.0)': + '@vanilla-extract/sprinkles@1.6.3(@vanilla-extract/css@1.15.5)': dependencies: - '@vanilla-extract/css': 1.14.0 + '@vanilla-extract/css': 1.15.5 '@vitejs/plugin-react@4.3.0(vite@5.0.11(@types/node@20.12.12)(terser@5.26.0))': dependencies: @@ -11795,8 +11826,6 @@ snapshots: clsx@2.0.0: {} - clsx@2.1.0: {} - clsx@2.1.1: {} cluster-key-slot@1.1.2: {} @@ -12060,6 +12089,8 @@ snapshots: dedent@0.7.0: {} + dedent@1.5.3: {} + deep-eql@4.1.3: dependencies: type-detect: 4.0.8 @@ -13682,6 +13713,8 @@ snapshots: lru-cache@10.1.0: {} + lru-cache@10.4.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -14221,8 +14254,6 @@ snapshots: strip-ansi: 7.1.0 wcwidth: 1.0.1 - outdent@0.8.0: {} - p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -14546,6 +14577,12 @@ snapshots: pngjs: 5.0.0 yargs: 15.4.1 + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + query-string@7.1.3: dependencies: decode-uri-component: 0.2.2 @@ -14706,6 +14743,14 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 + react-remove-scroll-bar@2.3.6(@types/react@18.3.3)(react@18.2.0): + dependencies: + react: 18.2.0 + react-style-singleton: 2.2.1(@types/react@18.3.3)(react@18.2.0) + tslib: 2.6.2 + optionalDependencies: + '@types/react': 18.3.3 + react-remove-scroll@2.5.4(@types/react@18.3.3)(react@18.2.0): dependencies: react: 18.2.0 @@ -14728,10 +14773,10 @@ snapshots: optionalDependencies: '@types/react': 18.3.3 - react-remove-scroll@2.5.7(@types/react@18.3.3)(react@18.2.0): + react-remove-scroll@2.6.0(@types/react@18.3.3)(react@18.2.0): dependencies: react: 18.2.0 - react-remove-scroll-bar: 2.3.4(@types/react@18.3.3)(react@18.2.0) + react-remove-scroll-bar: 2.3.6(@types/react@18.3.3)(react@18.2.0) react-style-singleton: 2.2.1(@types/react@18.3.3)(react@18.2.0) tslib: 2.6.2 use-callback-ref: 1.3.0(@types/react@18.3.3)(react@18.2.0) diff --git a/src/components/alert.tsx b/src/components/alert.tsx index 2c39849..e795d7d 100644 --- a/src/components/alert.tsx +++ b/src/components/alert.tsx @@ -1,11 +1,10 @@ import { CaretDownIcon, - CaretUpIcon, ExclamationTriangleIcon, InfoCircledIcon, } from "@radix-ui/react-icons"; import { cva } from "class-variance-authority"; -import { useState } from "react"; +import { useState, useRef, useEffect } from "react"; import { cn } from "~/lib/utils"; import { AlertDescription, AlertTitle, Alert as ShadAlert } from "./ui/alert"; @@ -31,11 +30,11 @@ export const Alert = (props: AlertProps) => { ); }; -const collapsibleAlertVariants = cva("p-2", { +const collapsibleAlertVariants = cva("p-4", { variants: { variant: { - info: "bg-blue-500/10 border-blue-500 [&>*:first-child]:text-blue-500", - warning: "border-warning bg-warning/10 [&>*:first-child]:text-warning", + info: "bg-blue-50 border-blue-200 text-blue-700 [&>*:first-child]:text-blue-500", + warning: "bg-yellow-50 border-yellow-200 text-yellow-700 [&>*:first-child]:text-yellow-500", }, }, defaultVariants: { @@ -45,27 +44,41 @@ const collapsibleAlertVariants = cva("p-2", { export const CollapsibleAlert = (props: AlertProps) => { const [open, setOpen] = useState(false); const Icon = AlertIcons[props.variant ?? "info"]; + const contentRef = useRef(null); + + useEffect(() => { + if (contentRef.current) { + contentRef.current.style.maxHeight = open ? `${contentRef.current.scrollHeight}px` : '0'; + } + }, [open]); return (
-
setOpen((s) => !s)} + aria-expanded={open} + > + + + {props.title} + + + + + +
- - {props.title} - {open ? ( - - ) : ( - - )} +
{props.message}
- {open &&
{props.message}
}
); }; diff --git a/src/components/balance.tsx b/src/components/balance.tsx index fec8d0d..5e41dac 100644 --- a/src/components/balance.tsx +++ b/src/components/balance.tsx @@ -3,8 +3,8 @@ import { useBalance } from "wagmi"; import { toUserUnitsString } from "~/utils/units"; interface IBalanceProps { - tokenAddress: string; - address: string; + tokenAddress?: string; + address?: string; } function Balance(props: IBalanceProps) { @@ -12,7 +12,12 @@ function Balance(props: IBalanceProps) { token: props.tokenAddress as `0x${string}`, address: props.address as `0x${string}`, query: { - enabled: isAddress(props.tokenAddress) && isAddress(props.address), + enabled: Boolean( + props.tokenAddress && + props.address && + isAddress(props.tokenAddress) && + isAddress(props.address) + ), }, }); return <>{toUserUnitsString(balance?.value, balance?.decimals)}; diff --git a/src/components/breadcrumbs.tsx b/src/components/breadcrumbs.tsx index 7876487..b7a0990 100644 --- a/src/components/breadcrumbs.tsx +++ b/src/components/breadcrumbs.tsx @@ -20,7 +20,7 @@ export function BreadcrumbResponsive({ items: { href?: string; label: string }[]; }) { return ( - + {items.slice(-ITEMS_TO_DISPLAY + 1).map((item, index) => item.href ? ( diff --git a/src/components/buttons/connect-button.tsx b/src/components/buttons/connect-button.tsx index 971c774..b0260e8 100644 --- a/src/components/buttons/connect-button.tsx +++ b/src/components/buttons/connect-button.tsx @@ -1,18 +1,36 @@ import { useConnectModal } from "@rainbow-me/rainbowkit"; +import { useRouter } from "next/router"; +import { useAuth } from "~/hooks/useAuth"; import { useIsMounted } from "~/hooks/useIsMounted"; import { Loading } from "../loading"; import { Button } from "../ui/button"; +import { cn } from "~/lib/utils"; -export const ConnectButton = () => { - const { openConnectModal } = useConnectModal(); +export const ConnectButton = ({className}: {className?: string}) => { + const { openConnectModal, connectModalOpen } = useConnectModal(); + const user = useAuth(); const isMounted = useIsMounted(); + const router = useRouter(); + + const isDisabled = !openConnectModal || !isMounted; + const buttonText = user ? "Open Wallet" : "Connect Wallet"; + + const handleClick = () => { + if (user) { + // Redirect to the user's wallet page using Next.js router + void router.push("/wallet"); + } else { + void openConnectModal?.(); + } + }; + return ( ); }; diff --git a/src/components/contract/ContractFunctions.tsx b/src/components/contract/ContractFunctions.tsx new file mode 100644 index 0000000..c5e8a88 --- /dev/null +++ b/src/components/contract/ContractFunctions.tsx @@ -0,0 +1,347 @@ +import { useState } from "react"; +import { toast } from "sonner"; +import { + type Abi, + type AbiFunction, + formatEther, + type TransactionReceipt as ViemTransactionReceipt, +} from "viem"; +import { + useReadContract, + useSimulateContract, + useWaitForTransactionReceipt, + useWriteContract, +} from "wagmi"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "../ui/accordion"; +import { Label } from "../ui/label"; +import { SearchInput } from "../ui/search-input"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"; + +interface ContractFunctionsProps { + abi: Abi; + address: `0x${string}`; +} + +export function ContractFunctions({ abi, address }: ContractFunctionsProps) { + const [searchTerm, setSearchTerm] = useState(""); + + const filteredReadFunctions = abi.filter( + (item): item is AbiFunction => + item.type === "function" && + item.stateMutability === "view" && + item.name?.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + const filteredWriteFunctions = abi.filter( + (item): item is AbiFunction => + item.type === "function" && + (item.stateMutability === "payable" || + item.stateMutability === "nonpayable") && + item.name?.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + const groupedWriteFunctions = filteredWriteFunctions.reduce( + (acc, func) => { + if (!func.name) return acc; + const key = `${func.name}(${func?.inputs?.map((input) => input.type).join(",")})`; + if (!acc[key]) { + acc[key] = []; + } + (acc[key] as AbiFunction[]).push(func); + return acc; + }, + {} as { [key: string]: AbiFunction[] } + ); + + return ( +
+ setSearchTerm(e.target.value)} + /> + + + + Read ({filteredReadFunctions.length}) + + + Write ({Object.keys(groupedWriteFunctions).length}) + + + + {filteredReadFunctions.length > 0 ? ( +
+ {filteredReadFunctions.map((func) => ( + + ))} +
+ ) : ( +

+ No read functions found. +

+ )} +
+ + {Object.keys(groupedWriteFunctions).length > 0 ? ( +
+ {Object.values(groupedWriteFunctions) + .flat() + .map((func, index) => ( + + ))} +
+ ) : ( +

+ No write functions found. +

+ )} +
+
+
+ ); +} + +interface ReadFunctionProps { + address: `0x${string}`; + functionAbi: AbiFunction; +} + +function ReadFunction({ address, functionAbi }: ReadFunctionProps) { + const [args, setArgs] = useState(functionAbi.inputs.map(() => "")); + const [result, setResult] = useState(null); + + const { refetch, isLoading } = useReadContract({ + address, + abi: [functionAbi], + functionName: functionAbi.name, + args, + }); + + const handleChange = (value: string, index: number) => { + const newArgs = [...args]; + newArgs[index] = value; + setArgs(newArgs); + }; + + const handleRead = async () => { + try { + const { data } = await refetch(); + setResult(data); + toast.success("Read Successful"); + } catch (error) { + toast.error("Read Failed", { + description: (error as Error).message, + }); + } + }; + + return ( + + + {functionAbi.name} + +
+
+ {functionAbi.inputs.map((input, index) => ( +
+ + handleChange(e.target.value, index)} + placeholder={`Enter ${input.type}`} + className="flex-1" + /> +
+ ))} +
+
+ + {result !== null && ( + + )} +
+
+
+
+
+ ); +} + +function serializeResult(result: unknown): string { + if (result === null || result === undefined) return ""; + if (typeof result === "bigint") return result.toString(); + if (Array.isArray(result)) { + return "[" + result.map(serializeResult).join(", ") + "]"; + } + if (typeof result === "object") { + return JSON.stringify(result, (_, v) => + typeof v === "bigint" ? v.toString() : (v as unknown) + ); + } + return String(result); +} + +interface WriteFunctionProps { + address: `0x${string}`; + functionAbi: AbiFunction; +} + +function WriteFunction({ address, functionAbi }: WriteFunctionProps) { + const [args, setArgs] = useState(functionAbi.inputs.map(() => "")); + const [transactionHash, setTransactionHash] = useState<`0x${string}` | null>( + null + ); + + const { data: simulateData, refetch: simulateRefetch } = useSimulateContract({ + address, + abi: [functionAbi], + functionName: functionAbi.name, + args, + }); + + const { writeContractAsync, isPending: isWriting } = useWriteContract(); + + const { isLoading: isWaiting, data: receipt } = useWaitForTransactionReceipt({ + hash: transactionHash as `0x${string}`, + query: { + enabled: !!transactionHash, + }, + }); + + const handleChange = (value: string, index: number) => { + const newArgs = [...args]; + newArgs[index] = value; + setArgs(newArgs); + }; + + const handleWrite = async () => { + try { + await simulateRefetch(); + if (simulateData?.request) { + const hash = await writeContractAsync(simulateData.request); + setTransactionHash(hash); + toast.success("Transaction Sent"); + } + } catch (error) { + toast.error("Write Failed", { + description: (error as Error).message, + }); + } + }; + + return ( + + input.type).join(",")})`} + > + + {functionAbi.name}( + {functionAbi.inputs.map((input) => input.type).join(", ")}) + + +
+
+ {functionAbi.inputs.map((input, index) => ( +
+ + handleChange(e.target.value, index)} + placeholder={`Enter ${input.type}`} + className="flex-1" + /> +
+ ))} +
+ + {transactionHash && ( +
+

Transaction Hash:

+

{transactionHash}

+ +
+ )} +
+
+
+
+ ); +} + +interface TransactionReceiptProps { + receipt: ViemTransactionReceipt | undefined; + isLoading: boolean; +} + +function TransactionReceipt({ receipt, isLoading }: TransactionReceiptProps) { + if (isLoading) { + return

Waiting for receipt...

; + } + + if (!receipt) { + return null; + } + + return ( +
+

+ Status:{" "} + {receipt.status === "success" ? "Success" : "Failed"} +

+

+ Block Number: {receipt.blockNumber} +

+

+ Gas Used: {receipt.gasUsed.toString()} +

+

+ Effective Gas Price:{" "} + {formatEther(receipt.effectiveGasPrice)} ETH +

+

+ Transaction Index: {receipt.transactionIndex} +

+
+ ); +} diff --git a/src/components/dialogs/change-sink-dialog.tsx b/src/components/dialogs/change-sink-dialog.tsx index c6a5eb1..c794c05 100644 --- a/src/components/dialogs/change-sink-dialog.tsx +++ b/src/components/dialogs/change-sink-dialog.tsx @@ -27,13 +27,10 @@ const FormSchema = z.object({ }); const ChangeSinkAddressDialog = ({ - voucher, + voucher_address, button, }: { - voucher: { - id: number; - voucher_address: string; - }; + voucher_address: string; button?: React.ReactNode; }) => { const [isDialogOpen, setIsDialogOpen] = React.useState(false); // State to control dialog visibility @@ -66,7 +63,7 @@ const ChangeSinkAddressDialog = ({ }); const handleSubmit = (data: z.infer) => { changeSink.writeContract({ - address: voucher.voucher_address as `0x${string}`, + address: voucher_address as `0x${string}`, abi: abi, functionName: "setSinkAddress", diff --git a/src/components/dialogs/create-paper-wallet.tsx b/src/components/dialogs/create-paper-wallet.tsx index d1fee62..f5970cc 100644 --- a/src/components/dialogs/create-paper-wallet.tsx +++ b/src/components/dialogs/create-paper-wallet.tsx @@ -4,13 +4,7 @@ import { useState } from "react"; import React from "react"; import { CreatePaperWallet } from "../paper"; import { Button } from "../ui/button"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "../ui/dialog"; +import { Dialog, DialogContent, DialogTrigger } from "../ui/dialog"; interface SendDialogProps { voucherAddress?: `0x${string}`; @@ -35,9 +29,6 @@ export const CreatePaperDialog = (props: SendDialogProps) => { )} - - Create Paper Wallet - diff --git a/src/components/dialogs/mint-to-dialog.tsx b/src/components/dialogs/mint-to-dialog.tsx index ccef136..df40924 100644 --- a/src/components/dialogs/mint-to-dialog.tsx +++ b/src/components/dialogs/mint-to-dialog.tsx @@ -28,22 +28,19 @@ const FormSchema = z.object({ }); const MintToDialog = ({ - voucher, + voucher_address, button, }: { - voucher: { - id: number; - voucher_address: string; - }; + voucher_address: string; button?: React.ReactNode; }) => { const [open, setOpen] = useState(false); const account = useAccount(); const balance = useBalance({ address: account.address, - token: voucher.voucher_address as `0x${string}`, + token: voucher_address as `0x${string}`, query: { - enabled: !!account.address && !!voucher.voucher_address, + enabled: !!account.address && !!voucher_address, }, }); const form = useForm>({ @@ -67,7 +64,7 @@ const MintToDialog = ({ }); const handleSubmit = (data: z.infer) => { mintTo.writeContract({ - address: voucher.voucher_address as `0x${string}`, + address: voucher_address as `0x${string}`, abi: abi, functionName: "mintTo", gas: 350_000n, diff --git a/src/components/dialogs/receive-dialog.tsx b/src/components/dialogs/receive-dialog.tsx index 94a6a61..ddfad1f 100644 --- a/src/components/dialogs/receive-dialog.tsx +++ b/src/components/dialogs/receive-dialog.tsx @@ -69,7 +69,7 @@ export const ReceiveDialog = (props: ReceiveDialogProps) => { id="addressQRCodeId" size={256} className="mx-auto" - address={address!} + address={address ?? ""} />
diff --git a/src/components/force-graph/index.tsx b/src/components/force-graph/index.tsx index a81a4e3..3543280 100644 --- a/src/components/force-graph/index.tsx +++ b/src/components/force-graph/index.tsx @@ -10,7 +10,7 @@ import { Button } from "../ui/button"; import { NodeLabelComponent } from "./components/node-label"; import { useGraphData } from "./hooks/useGraphData"; import { type Link, type Node } from "./types"; - +import { toast } from "sonner"; // Component for rendering the Force Graph export function VoucherForceGraph({ voucherAddress, @@ -23,7 +23,15 @@ export function VoucherForceGraph({ const handleNodeHover = (node: NodeObject | null) => { setHoveredNode(node); }; - + const handleNodeClick = (node: NodeObject) => { + console.log("Clicked node address:", node.id); + void navigator.clipboard.writeText(node.id).then(() => { + toast.success("Copied to clipboard"); + }) + .catch(() => { + toast.error("Failed to copy to clipboard"); + }); + }; const [size, setSize] = useState({ width: 600, height: 350 }); const fgRef = useRef>(); @@ -104,6 +112,7 @@ export function VoucherForceGraph({ linkWidth={0.5} linkCurvature={0.25} onNodeHover={handleNodeHover} + onNodeClick={handleNodeClick} linkColor={(link) => { if ( hoveredNode && diff --git a/src/components/force-graph/utils.ts b/src/components/force-graph/utils.ts index 6d413a1..a82abad 100644 --- a/src/components/force-graph/utils.ts +++ b/src/components/force-graph/utils.ts @@ -89,7 +89,7 @@ export async function processGraphData( target: scaledNodes.find( (node) => node.id === tx.recipient_address ) as Node, - voucher_address: tx.voucher_address as `0x${string}`, + voucher_address: tx.contract_address as `0x${string}`, })); return { nodes: scaledNodes, diff --git a/src/components/forms/fields/address-field.tsx b/src/components/forms/fields/address-field.tsx index e790e47..6f41e9c 100644 --- a/src/components/forms/fields/address-field.tsx +++ b/src/components/forms/fields/address-field.tsx @@ -27,7 +27,7 @@ interface AddressFieldProps
> { export function AddressField>( props: AddressFieldProps ) { - const [inputValue, setInputValue] = useState(""); + const [inputValue, setInputValue] = useState(props.form.getValues(props.name)); const { refetch, isFetching } = api.user.getAddressBySearchTerm.useQuery( { searchTerm: inputValue, diff --git a/src/components/icons.tsx b/src/components/icons.tsx index b88f100..680c4a7 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -12,7 +12,7 @@ export const Icons = { xmlns="http://www.w3.org/2000/svg" {...props} > - + -
-
-
-
-
-
+ -
{children}
+
{children}
+
+ ); +} + +function Background({ animate = true }: { animate?: boolean }) { + const filterId = useId(); + return ( +
+ + + + + + + + + + {[ + { cx: "10%", cy: "10%", r: "20vw", fill: "#eef8f3", dur: "40s" }, + { cx: "20%", cy: "50%", r: "100", fill: "#f8f6ee", dur: "50s" }, + { cx: "50%", cy: "40%", r: "40", fill: "#f8eef1", dur: "60s" }, + { cx: "80%", cy: "80%", r: "400", fill: "#eef3f8", dur: "70s" }, + ].map((circle, index) => ( + + {animate && ( + + )} + + ))} + +
); } diff --git a/src/components/layout/menu-list.ts b/src/components/layout/menu-list.ts index bd584f5..040052e 100644 --- a/src/components/layout/menu-list.ts +++ b/src/components/layout/menu-list.ts @@ -1,6 +1,6 @@ import { type IconProps } from "@radix-ui/react-icons/dist/types"; import { LayoutGrid, Settings, Wallet, type LucideIcon } from "lucide-react"; -import { AuthContextType } from "~/hooks/useAuth"; +import { type AuthContextType } from "~/hooks/useAuth"; import { Icons } from "../icons"; type Submenu = { @@ -23,7 +23,7 @@ type Group = { }; export function getMenuList( - pathname: string, + pathname: string | null, auth: AuthContextType | null ): Group[] { return [ @@ -33,7 +33,7 @@ export function getMenuList( { href: "/dashboard", label: "Dashboard", - active: pathname?.includes("/dashboard"), + active: pathname?.includes("/dashboard") ?? false, icon: LayoutGrid, submenus: [], }, @@ -45,7 +45,7 @@ export function getMenuList( { href: "", label: "Vouchers", - active: pathname?.includes("/vouchers"), + active: pathname?.includes("/vouchers") ?? false, icon: Icons.vouchers, submenus: [ { @@ -63,7 +63,7 @@ export function getMenuList( { href: "/pools", label: "Pools", - active: pathname?.includes("/pools"), + active: pathname?.includes("/pools") ?? false, icon: Icons.pools, submenus: [ { @@ -94,7 +94,7 @@ export function getMenuList( { href: "/wallet/profile", label: "Profile", - active: pathname?.includes("//wallet/profile"), + active: pathname?.includes("/wallet/profile") ?? false, icon: Settings, submenus: [], }, diff --git a/src/components/layout/mobile-wallet-bar.tsx b/src/components/layout/mobile-wallet-bar.tsx index 9d42c26..a6d714f 100644 --- a/src/components/layout/mobile-wallet-bar.tsx +++ b/src/components/layout/mobile-wallet-bar.tsx @@ -18,37 +18,38 @@ const NavButton = ({ ); }; + export const WalletNavBar = () => { const router = useRouter(); return ( -
- - - Wallet +
+ ); }; diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index ceff17b..43b3aaa 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -28,7 +28,7 @@ export function Sidebar() { variant="link" asChild > - +

extends MapContainerProps { + +import "leaflet/dist/leaflet.css"; + +export interface MapProps extends MapContainerProps { items?: T[]; onItemClicked?: (item: T) => void; getTooltip: (item: T) => string; - getLatLng: (item: T) => LatLngExpression; - + getLatLng: (item: T) => LatLngExpression | undefined; mapEvents?: LeafletEventHandlerFnMap; + onZoomChange?: (zoom: number) => void; + onBoundsChange?: (bounds: LatLngBounds) => void; } -function Map({ +function Map({ onItemClicked, mapEvents, getTooltip, getLatLng, items, + onZoomChange, + onBoundsChange, ...props }: MapProps) { const MapEvents = () => { - useMapEvents(mapEvents || {}); + const map = useMapEvents({ + ...mapEvents, + zoomend: () => { + if (onZoomChange) { + onZoomChange(map.getZoom()); + } + }, + moveend: () => { + if (onBoundsChange) { + onBoundsChange(map.getBounds()); + } + }, + }); return null; }; + const RemoveWaterMark = () => { const map = useMap(); map.attributionControl.setPrefix(""); - return null; }; + return ( ({ url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution='© OpenStreetMap contributors' /> - {items?.map((item, idx) => ( + {getLatLng && items?.map((item, idx) => ( { @@ -65,7 +84,7 @@ function Map({ }, }} key={idx} - position={getLatLng(item)} + position={getLatLng(item) ?? [0, 0]} icon={markerIcon} > {getTooltip && {getTooltip(item)}} diff --git a/src/components/map/location-map.tsx b/src/components/map/location-map.tsx index 88cd9fa..c3b18a9 100644 --- a/src/components/map/location-map.tsx +++ b/src/components/map/location-map.tsx @@ -53,7 +53,7 @@ const SearchControl = (props: { }); React.useEffect(() => { map.addControl(searchControl); - map.attributionControl.setPrefix('') + map.attributionControl.setPrefix(""); return () => { map.removeControl(searchControl); }; @@ -91,8 +91,8 @@ const LocationMap: React.FC = ({ scrollWheelZoom: false, } : { - trackResize: true, - }; + trackResize: true, + }; return ( { }; if (!type) return ( -
- - +
+

Create Paper Wallet

+

+ Choose a wallet type to get started. Encrypted wallets offer additional security. +

+
+ + +
- - Batch + + Generate Batch
); + return ( -
+
{!data && ( - handleGenerateClick(data.password)} - /> + <> +

+ {type === "encrypted" ? "Create Encrypted Wallet" : "Generating Unencrypted Wallet"} +

+ handleGenerateClick(data.password)} + /> + )} {data && ( -
-

- Do not share your private key with anyone. If you lose your private - key, you will lose access to your funds. +

+

+ Warning: Do not share your private key. Loss of the private key means loss of funds.

-
- +
+
-
-

- Print or download your paper wallet. +

+

+ Secure your wallet by printing or downloading it.

-
- + {auth?.user ? ( + + ) : ( + + )} ) : ( diff --git a/src/components/pools/forms/fees-form.tsx b/src/components/pools/forms/fees-form.tsx new file mode 100644 index 0000000..b694ba9 --- /dev/null +++ b/src/components/pools/forms/fees-form.tsx @@ -0,0 +1,191 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { waitForTransactionReceipt } from "@wagmi/core"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { isAddress, parseUnits } from "viem"; +import { useWriteContract } from "wagmi"; +import { z } from "zod"; +import { AddressField } from "~/components/forms/fields/address-field"; +import { InputField } from "~/components/forms/fields/input-field"; +import { swapPoolAbi } from "~/contracts/swap-pool/contract"; +import { config } from "~/lib/web3"; +import { celoscanUrl } from "~/utils/celo"; +import { Loading } from "../../loading"; +import { Button, buttonVariants } from "../../ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../../ui/dialog"; +import { Form } from "../../ui/form"; +import { type SwapPool } from "../types"; + +const FormSchema = z.object({ + poolAddress: z.string().refine(isAddress, "Invalid pool address"), + feeAddress: z.string().refine(isAddress, "Invalid fee address"), + feePercentage: z.coerce.number().min(0).max(100), +}); + +interface PoolFeesProps { + pool: SwapPool; + button?: React.ReactNode; +} + +export const PoolFeesDialog = (props: PoolFeesProps) => { + const [open, setOpen] = useState(false); + return ( + + + Set Fees + + + + + Set Fees + + setOpen(false)} pool={props.pool} /> + + + ); +}; + +export const PoolFeesForm = ({ + pool, + onSuccess, +}: { + pool: SwapPool; + onSuccess: () => void; +}) => { + const form = useForm>({ + resolver: zodResolver(FormSchema), + mode: "all", + reValidateMode: "onChange", + defaultValues: { + poolAddress: pool?.address, + feeAddress: pool.feeAddress, + feePercentage: pool.feePercentage, + }, + }); + + const contract = useWriteContract({ + config: config, + }); + + const { handleSubmit, formState } = form; + + async function onSubmit(data: z.infer) { + try { + // Update Fee Address + if (data.feeAddress !== pool.feeAddress) { + const feeAddressToastId = "feeAddressUpdate"; + toast.info("Updating the fee address", { + id: feeAddressToastId, + description: "Please confirm the transaction in your wallet.", + duration: 15000, + }); + + const feeAddressHash = await contract.writeContractAsync({ + abi: swapPoolAbi, + address: data.poolAddress, + functionName: "setFeeAddress", + args: [data.feeAddress], + }); + + toast.loading("Waiting for Fee Address Confirmation", { + id: feeAddressToastId, + description: "", + duration: 15000, + }); + + await waitForTransactionReceipt(config, { hash: feeAddressHash }); + + toast.success("Fee Address Updated Successfully", { + id: feeAddressToastId, + duration: undefined, + action: { + label: "View Transaction", + onClick: () => window.open(celoscanUrl.tx(feeAddressHash), "_blank"), + }, + description: `You have successfully updated the fee address to ${data.feeAddress}.`, + }); + } + + // Update Fee Percentage + if (data.feePercentage !== pool.feePercentage) { + const feePercentageToastId = "feePercentageUpdate"; + toast.info("Updating the fee percentage", { + id: feePercentageToastId, + description: "Please confirm the transaction in your wallet.", + duration: 15000, + }); + + const feePercentage = parseUnits(data.feePercentage.toString(), 4); + const feePercentageHash = await contract.writeContractAsync({ + abi: swapPoolAbi, + address: data.poolAddress, + functionName: "setFee", + args: [feePercentage], + }); + + console.log(feePercentageHash); + + toast.loading("Waiting for Fee Percentage Confirmation", { + id: feePercentageToastId, + description: "", + duration: 15000, + }); + + await waitForTransactionReceipt(config, { hash: feePercentageHash }); + + toast.success("Fee Percentage Updated Successfully", { + id: feePercentageToastId, + duration: undefined, + action: { + label: "View Transaction", + onClick: () => window.open(celoscanUrl.tx(feePercentageHash), "_blank"), + }, + description: `You have successfully updated the fee percentage to ${data.feePercentage}%.`, + }); + } + + onSuccess(); + } catch (error) { + toast.error((error as Error).name, { + id: "feesUpdateError", + description: (error as Error).cause as string || "An error occurred.", + duration: undefined, + }); + } + } + + return ( +
+ + + + + + + ); +}; diff --git a/src/components/pools/forms/update-pool-form.tsx b/src/components/pools/forms/update-pool-form.tsx index c274177..6b330e2 100644 --- a/src/components/pools/forms/update-pool-form.tsx +++ b/src/components/pools/forms/update-pool-form.tsx @@ -2,7 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { useRouter } from "next/router"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; -import { Address } from "viem"; +import { type Address } from "viem"; import { z } from "zod"; import AreYouSureDialog from "~/components/dialogs/are-you-sure"; import { ComboBoxField } from "~/components/forms/fields/combo-box-field"; diff --git a/src/components/pools/hooks.tsx b/src/components/pools/hooks.tsx index 6c97f00..3015c0f 100644 --- a/src/components/pools/hooks.tsx +++ b/src/components/pools/hooks.tsx @@ -7,6 +7,7 @@ import { getMultipleSwapDetails, getPriceIndex, getSwapPool, + getVoucherDetails, removePoolVoucher, updatePoolVoucher, } from "./contract-functions"; @@ -161,3 +162,11 @@ export const useUpdatePoolVoucher = () => { updatePoolVoucher(voucherAddress, swapPoolAddress, limit, exchangeRate), }); }; + +export const useVoucherDetails = (voucherAddress: `0x${string}`) => { + return useQuery({ + queryKey: ["voucherDetails", voucherAddress], + queryFn: () => getVoucherDetails(voucherAddress), + enabled: !!voucherAddress, + }); +}; diff --git a/src/components/pools/pool-charts.tsx b/src/components/pools/pool-charts.tsx new file mode 100644 index 0000000..759ed51 --- /dev/null +++ b/src/components/pools/pool-charts.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import { type DateRange } from "react-day-picker"; +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { type Payload } from "recharts/types/component/DefaultLegendContent"; +import { api } from "~/utils/api"; +import { type SwapPool } from "./types"; + +export const PoolCharts = ({ + pool, + dateRange, +}: { + pool: SwapPool | undefined; + dateRange: DateRange; +}) => { + const { data: distributionData } = api.pool.tokenDistribution.useQuery( + { + address: pool!.address, + from: dateRange.from!, + to: dateRange.to!, + }, + { + enabled: !!pool?.address && !!dateRange.from && !!dateRange.to, + } + ); + + const [hiddenSeries, setHiddenSeries] = useState([]); + const handleLegendClick = (entry: Payload) => { + setHiddenSeries((prev) => + prev.includes(entry.dataKey as string) + ? prev.filter((key) => key !== entry.dataKey) + : [...prev, entry.dataKey as string] + ); + }; + + return ( +
+ + {/* Token Distribution */} +
+

Token Distribution

+ + ({ + ...d, + name: d.symbol, + }))} + > + + + + + + + + + + +
+
+ ); +}; diff --git a/src/components/pools/pool-details.tsx b/src/components/pools/pool-details.tsx index 8015c0d..ea46d55 100644 --- a/src/components/pools/pool-details.tsx +++ b/src/components/pools/pool-details.tsx @@ -1,4 +1,3 @@ -import { InfoCircledIcon } from "@radix-ui/react-icons"; import Address from "../address"; import { Collapsible, @@ -11,63 +10,51 @@ import { useSwapPool } from "./hooks"; export const PoolDetails = ({ address }: { address: `0x${string}` }) => { const { data: pool } = useSwapPool(address); return ( -
-

-
- - Details -
-

-
- - - - : ""} - /> - - - : ""} - /> - : ""} - /> - - ) : ( - "" - ) - } - /> - - ) : ( - "" - ) - } - /> - : "" - } - /> - - - Show More - - -
+
+ + + + : ""} + /> + + + : ""} + /> + : ""} + /> + + ) : ( + "" + ) + } + /> + : "" + } + /> + : "" + } + /> + + + Show More + +
); }; diff --git a/src/components/pools/pool-list-item.tsx b/src/components/pools/pool-list-item.tsx index fc6916d..3f5b6e5 100644 --- a/src/components/pools/pool-list-item.tsx +++ b/src/components/pools/pool-list-item.tsx @@ -2,48 +2,77 @@ import { TagIcon } from "lucide-react"; import Image from "next/image"; import Link from "next/link"; import { api } from "~/utils/api"; -import { truncateString } from "~/utils/string"; -import { AspectRatio } from "../ui/aspect-ratio"; +import { Badge } from "../ui/badge"; import { Skeleton } from "../ui/skeleton"; import { useSwapPool } from "./hooks"; -export const PoolListItem = ({ address }: { address: `0x${string}` }) => { +interface PoolListItemProps { + address: `0x${string}`; + searchTerm: string; + searchTags: string[]; +} + +export const PoolListItem: React.FC = ({ address, searchTerm, searchTags }) => { const { data: pool } = useSwapPool(address); const { data: poolData } = api.pool.get.useQuery(address); + // Conditionally render based on searchTerm matching name or description + if ( + searchTerm && + !( + (pool?.name && pool.name.toLowerCase().includes(searchTerm.toLowerCase())) || + (poolData?.swap_pool_description && + poolData.swap_pool_description.toLowerCase().includes(searchTerm.toLowerCase())) + ) || + (searchTags && searchTags.length > 0 && !poolData?.tags.some(tag => searchTags.includes(tag))) + ) { + return null; + } + return ( -
- - {poolData?.banner_url ? ( +
+ {poolData?.banner_url ? ( +
banner - ) : ( -
- )} - -
- {pool?.tokenIndex.entryCount.toString() ?? 0} -
-
-
-

- {pool?.name ?? } + + {pool?.tokenIndex.entryCount.toString() ?? 0} tokens + +

+ ) : ( +
+ No image +
+ )} +

+ {pool?.name ?? }

-

- - {poolData?.tags?.map((tag) => tag).join(", ")} -

-

+ {poolData?.tags && poolData.tags.length > 0 && ( +

+ +
+ {poolData.tags.slice(0, 3).map((tag, index) => ( + + {tag} + + ))} + {poolData.tags.length > 3 && ( + +{poolData.tags.length - 3} + )} +
+
+ )} +

{poolData?.swap_pool_description - ? truncateString(poolData?.swap_pool_description, 200) + ? poolData.swap_pool_description : "No description available"}

diff --git a/src/components/pools/pool-list.tsx b/src/components/pools/pool-list.tsx index 1baaa49..e37a3de 100644 --- a/src/components/pools/pool-list.tsx +++ b/src/components/pools/pool-list.tsx @@ -1,15 +1,46 @@ import { env } from "~/env"; +import { Skeleton } from "../ui/skeleton"; import { useContractIndex } from "./hooks"; import { PoolListItem } from "./pool-list-item"; -export const PoolList = () => { - const { data: pools } = useContractIndex( +interface PoolListProps { + searchTerm: string; + searchTags: string[]; +} + +export const PoolList: React.FC = ({ searchTerm, searchTags }) => { + const { data: pools, isLoading } = useContractIndex( env.NEXT_PUBLIC_SWAP_POOL_INDEX_ADDRESS ); + + if (isLoading) { + return ( +
+ {Array.from({ length: 6 }).map((_, idx) => ( + + ))} +
+ ); + } + + if (!pools?.contractAddresses || pools.contractAddresses.length === 0) { + return ( +
+

+ No pools available at the moment. +

+
+ ); + } + + // Remove address-based filtering + const filteredPools = pools.contractAddresses; + return ( -
- {pools?.contractAddresses?.map((pool, idx) => ( - +
+ {filteredPools.map((pool, idx) => ( + // Pass searchTerm to PoolListItem + ))}
); diff --git a/src/components/pools/tables/pool-transactions-table.tsx b/src/components/pools/tables/pool-transactions-table.tsx index e2ef3ad..c61847b 100644 --- a/src/components/pools/tables/pool-transactions-table.tsx +++ b/src/components/pools/tables/pool-transactions-table.tsx @@ -1,6 +1,16 @@ import { keepPreviousData } from "@tanstack/query-core"; -import React from "react"; +import { CheckCircleIcon, XCircleIcon } from "lucide-react"; +import { useMemo, useState } from "react"; import { getAddress } from "viem"; +import { Button } from "~/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; import { api } from "~/utils/api"; import { celoscanUrl } from "~/utils/celo"; import Address from "../../address"; @@ -13,9 +23,18 @@ export const PoolTransactionsTable = ({ }: { pool: SwapPool | undefined; }) => { - const swaps = api.pool.swaps.useInfiniteQuery( + const [typeFilter, setTypeFilter] = useState<"swap" | "deposit" | "all">( + "all" + ); + const [inTokenFilter, setInTokenFilter] = useState(null); + const [outTokenFilter, setOutTokenFilter] = useState(null); + + const transactions = api.pool.transactions.useInfiniteQuery( { address: getAddress(pool!.address), + type: typeFilter, + inToken: inTokenFilter, + outToken: outTokenFilter, }, { enabled: !!pool?.address, @@ -24,104 +43,224 @@ export const PoolTransactionsTable = ({ refetchOnWindowFocus: false, } ); - const flatData = React.useMemo( - () => swaps.data?.pages?.flatMap((page) => page.swaps) ?? [], - [swaps.data] + + const flatData = useMemo( + () => transactions.data?.pages?.flatMap((page) => page.transactions) ?? [], + [transactions.data] ); + + const uniqueTokens = useMemo(() => { + const tokens = new Set(); + flatData.forEach((transaction) => { + if (transaction.token_in_address) + tokens.add(transaction.token_in_address); + if (transaction.token_out_address) + tokens.add(transaction.token_out_address); + }); + return Array.from(tokens); + }, [flatData]); + + const clearFilters = () => { + setTypeFilter("all"); + setInTokenFilter(null); + setOutTokenFilter(null); + }; + return ( -
- { - window.open(celoscanUrl.tx(row.tx_hash ?? ""), "_blank", "noopener"); - }} - columns={[ - { - accessorKey: "date_block", - header: "Date", - cell: ({ row }) => { - return ( - row.original?.date_block?.toLocaleTimeString([], { - day: "numeric", - month: "short", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }) ?? "N/A" - ); + + + + Transaction History + + + + +
+
+ +
+
+ +
+
+ +
+
+ { + window.open( + celoscanUrl.tx(row.tx_hash ?? ""), + "_blank", + "noopener" + ); + }} + columns={[ + { + accessorKey: "success", + header: "Success", + cell: ({ row }) => { + return row.original.success ? ( + + ) : ( + + ); + }, + }, + { + accessorKey: "date_block", + header: "Date", + cell: ({ row }) => { + return ( + row.original?.date_block?.toLocaleTimeString([], { + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }) ?? "N/A" + ); + }, + }, + + { + accessorKey: "type", + header: "Type", + cell: ({ row }) => { + return row.original.type === "swap" ? "Swap" : "Deposit"; + }, }, - }, - { - accessorKey: "initiator_address", - header: "Initiator", - cell: ({ row }) => { - return ( -
- ); + { + accessorKey: "initiator_address", + header: "Initiator", + cell: ({ row }) => { + return ( +
+ ); + }, }, - }, - { - accessorKey: "token_in_address", - header: "From", - cell: ({ row }) => { - return ; + { + accessorKey: "token_in_address", + header: "From", + cell: ({ row }) => { + return ; + }, }, - }, - { - accessorKey: "token_out_address", - header: "To", - cell: ({ row }) => { - return ; + { + accessorKey: "token_out_address", + header: "To", + cell: ({ row }) => { + return row.original.type === "swap" ? ( + + ) : ( + "-" + ); + }, }, - }, - { - accessorKey: "in_value", - header: "Amount In", - cell: ({ row }) => { - return ( - - ); + { + accessorKey: "in_value", + header: "Amount In", + cell: ({ row }) => { + return ( + + ); + }, }, - }, - { - accessorKey: "out_value", - header: "Amount Out", - cell: ({ row }) => { - return ( - - ); + { + accessorKey: "out_value", + header: "Amount Out", + cell: ({ row }) => { + return row.original.type === "swap" ? ( + + ) : ( + "-" + ); + }, }, - }, - { - accessorKey: "fee", - header: "Fee", - cell: ({ row }) => { - return ( - - ); + { + accessorKey: "fee", + header: "Fee", + cell: ({ row }) => { + return row.original.type === "swap" ? ( + + ) : ( + "-" + ); + }, }, - }, - ]} - hasNextPage={swaps.hasNextPage} - isLoading={swaps.isFetching || swaps.isFetchingNextPage} - fetchNextPage={() => { - void swaps.fetchNextPage(); - }} - /> -
+ ]} + hasNextPage={transactions.hasNextPage} + isLoading={transactions.isFetching || transactions.isFetchingNextPage} + fetchNextPage={() => { + void transactions.fetchNextPage(); + }} + /> + + ); }; diff --git a/src/components/pools/tables/pool-voucher-table.tsx b/src/components/pools/tables/pool-voucher-table.tsx index 985c597..77d52bf 100644 --- a/src/components/pools/tables/pool-voucher-table.tsx +++ b/src/components/pools/tables/pool-voucher-table.tsx @@ -10,6 +10,8 @@ import { BasicTable } from "../../tables/table"; import { Button } from "../../ui/button"; import { PoolVoucherForm } from "../forms/pool-voucher-form"; import { type SwapPool, type SwapPoolVoucher } from "../types"; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { Progress } from "~/components/ui/progress"; export const PoolVoucherTable = ({ pool, @@ -22,16 +24,18 @@ export const PoolVoucherTable = ({ const data = pool?.voucherDetails ?? []; const [isModalOpen, setIsModalOpen] = useState(false); const [voucher, setVoucher] = useState(null); + const client = useQueryClient(); + const isFetchingPool = useIsFetching({ queryKey: ["swapPool"] }); + function handleEdit(original: SwapPoolVoucher): void { setVoucher(original); setIsModalOpen(true); } + const handleSuccess = () => { setVoucher(null); setIsModalOpen(false); }; - const client = useQueryClient(); - const isFetchingPool = useIsFetching({ queryKey: ["swapPool"] }); const columns: ColumnDef[] = [ { header: "Symbol", accessorKey: "symbol" }, @@ -42,30 +46,19 @@ export const PoolVoucherTable = ({ accessorFn: (row) => truncateByDecimalPlace(Number(row.priceIndex) / 10000, 3), }, - // { - // header: "Holding", - // accessorFn: (row: SwapPoolVoucher) => - // row.poolBalance?.formatted, - // }, { header: "Holding", accessorFn: (row) => row.poolBalance?.formattedNumber, sortingFn: (a, b) => { const aBalance = a.original.poolBalance?.formattedNumber ?? 0; const aLimit = a.original.limitOf?.formattedNumber ?? 0; - const aFill = aBalance / aLimit; const bBalance = b.original.poolBalance?.formattedNumber ?? 0; const bLimit = b.original.limitOf?.formattedNumber ?? 0; - const bFill = bBalance / bLimit; - if (isNaN(aFill)) { - return -1; - } - if (isNaN(bFill)) { - return 1; - } + if (isNaN(aFill)) return -1; + if (isNaN(bFill)) return 1; return aFill - bFill; }, cell: ({ row }: { row: { original: SwapPoolVoucher } }) => { @@ -77,18 +70,11 @@ export const PoolVoucherTable = ({ row.original.limitOf?.formattedNumber ?? 0, 2 ); + const percentage = cap === 0 ? 0 : (fill / cap) * 100; return ( -
-
-
-
-
+
+ +
{fill} / {cap}
@@ -96,6 +82,7 @@ export const PoolVoucherTable = ({ }, }, ]; + if (isWriter) { columns.push({ header: "Edit", @@ -106,63 +93,58 @@ export const PoolVoucherTable = ({ e.stopPropagation(); handleEdit(row.original); }} - size="xs" - variant={"ghost"} + size="sm" + variant="ghost" > - + ); }, }); } + return ( -
-
- {isWriter && ( - { - if (!open) { - setVoucher(null); - } - setIsModalOpen(open); - }} - title={voucher ? "Edit Voucher" : "Add Voucher"} - button={ - - } + + + Pool Vouchers +
+
- -
- - client.refetchQueries({ - queryKey: ["swapPool"], - }) + + + {isWriter && ( + { + if (!open) setVoucher(null); + setIsModalOpen(open); + }} + title={voucher ? "Edit Voucher" : "Add Voucher"} + button={ + } > - - - } + {pool && ( + + )} + + )} +
+
+ + -
-
+ + ); }; diff --git a/src/components/products/product-list.tsx b/src/components/products/product-list.tsx index 8e8a4b0..467bb91 100644 --- a/src/components/products/product-list.tsx +++ b/src/components/products/product-list.tsx @@ -1,6 +1,7 @@ -import { PlusIcon } from "lucide-react"; +import { PackageIcon, PlusIcon } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; +import { Authorization } from "~/hooks/useAuth"; import { cn } from "~/lib/utils"; import { type RouterOutput } from "~/server/api/root"; import { api } from "~/utils/api"; @@ -13,14 +14,15 @@ import { type InsertProductListingInput, type UpdateProductListingInput, } from "./schema"; -import { Authorization } from "~/hooks/useAuth"; export const ProductList = ({ voucher_id, + voucherSymbol, className, isOwner, }: { voucher_id: number; + voucherSymbol: string; className?: string; isOwner: boolean; }) => { @@ -40,7 +42,6 @@ export const ProductList = ({ const deleteMutation = api.products.remove.useMutation(); const utils = api.useUtils(); - const handleDelete = async (id: number) => { try { await deleteMutation.mutateAsync({ id }); @@ -75,22 +76,25 @@ export const ProductList = ({ return (
-
-

- Products -

+
+

Products

- + } title="Product" open={selectedProduct !== null} onOpenChange={(open) => setSelectedProduct( - open ? ({voucher_id: voucher_id} as RouterOutput["voucher"]["commodities"][0]) : null + open + ? ({ + voucher_id: voucher_id, + } as RouterOutput["voucher"]["commodities"][0]) + : null ) } > @@ -111,16 +115,23 @@ export const ProductList = ({
{products && products.length === 0 ? ( -
No Products Listed
+
+ +

No Products Listed

+
) : ( - - {products?.map((product) => ( - setSelectedProduct(product)} - /> - ))} + +
+ {products?.map((product) => ( + setSelectedProduct(product)} + /> + ))} +
)}
diff --git a/src/components/products/products-list-item.tsx b/src/components/products/products-list-item.tsx index c69957b..a150b0a 100644 --- a/src/components/products/products-list-item.tsx +++ b/src/components/products/products-list-item.tsx @@ -1,26 +1,98 @@ +import { ClockIcon, EditIcon, PackageIcon } from "lucide-react"; import { type RouterOutput } from "~/server/api/root"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Card, CardContent } from "../ui/card"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../ui/tooltip"; +import { Authorization } from "~/hooks/useAuth"; export const ProductListItem = ({ product, onClick, + voucherSymbol, + isOwner }: { product: RouterOutput["voucher"]["commodities"][number]; onClick?: (product: RouterOutput["voucher"]["commodities"][number]) => void; + voucherSymbol: string; + isOwner: boolean; }) => { + const getPriceDisplay = () => { + if ( + product.price === null || + product.price === undefined || + product.price === 0 + ) { + return Price on request; + } + return ( + + {product.price} {voucherSymbol} + + ); + }; + return ( -
onClick?.(product)} - className="grid grid-cols-6 gap-2 items-center p-2 rounded-sm" - > -
-
{product.commodity_name}
-
{product.commodity_description}
-
-
{product.quantity}
-
- every  - {product.frequency} + +
+
+ + {product.commodity_type} + +
-
+ +
+

{product.commodity_name}

+ + + + + + + +

Edit this product

+
+
+
+
+
+

+ {product.commodity_description} +

+
+
{getPriceDisplay()}
+
+
+ + + Qty: {product.quantity} + +
+
+ + + Freq: {product.frequency} + +
+
+
+
+ ); }; diff --git a/src/components/tables/infinite-table.tsx b/src/components/tables/infinite-table.tsx index a5b8750..3b96cb3 100644 --- a/src/components/tables/infinite-table.tsx +++ b/src/components/tables/infinite-table.tsx @@ -114,11 +114,7 @@ export function InfiniteTable(props: TableProps) { containerClassName={props.containerClassName} > {table.getHeaderGroups().map((headerGroup) => ( diff --git a/src/components/tables/transactions-table.tsx b/src/components/tables/transactions-table.tsx index d808277..0e60465 100644 --- a/src/components/tables/transactions-table.tsx +++ b/src/components/tables/transactions-table.tsx @@ -1,14 +1,15 @@ import React from "react"; +import { keepPreviousData } from "@tanstack/react-query"; import Link from "next/link"; import { formatUnits } from "viem"; import { api } from "~/utils/api"; import { celoscanUrl } from "~/utils/celo"; import Address from "../address"; import { Icons } from "../icons"; +import { useVoucherDetails } from "../pools/hooks"; import { Badge } from "../ui/badge"; import { InfiniteTable } from "./infinite-table"; -import { keepPreviousData } from "@tanstack/react-query"; export function TransactionsTable({ voucherAddress, @@ -27,12 +28,22 @@ export function TransactionsTable({ refetchOnWindowFocus: false, } ); + const { data: details } = useVoucherDetails(voucherAddress as `0x${string}`); + //we must flatten the array of arrays from the useInfiniteQuery hook const flatData = React.useMemo( - () => data?.pages?.flatMap((page) => page.transactions) ?? [], - [data] + () => + data?.pages + ?.flatMap((page) => page.transactions) + .map((t) => ({ + ...t, + transfer_value: formatUnits( + BigInt(t.transfer_value), + details?.decimals ?? 0 + ), + })) ?? [], + [data, details] ); - return ( (info.getValue() as Date).toLocaleString(), }, - { - header: "Type", - accessorKey: "tx_type", - cell: (info) => ( - - {info.getValue() as string} - - ), - }, { accessorKey: "sender_address", header: "Sender", @@ -68,9 +68,8 @@ export function TransactionsTable({ ), }, { - accessorKey: "tx_value", + accessorKey: "transfer_value", header: "Amount", - cell: (info) => formatUnits(BigInt(info.getValue()), 6), }, { accessorKey: "success", diff --git a/src/components/transactions/transaction-list.tsx b/src/components/transactions/transaction-list.tsx index f466066..eeb5944 100644 --- a/src/components/transactions/transaction-list.tsx +++ b/src/components/transactions/transaction-list.tsx @@ -2,22 +2,11 @@ import Link from "next/link"; import Address from "~/components/address"; import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; import { useAuth } from "~/hooks/useAuth"; -import { api } from "~/utils/api"; +import { api, type RouterOutputs } from "~/utils/api"; import { toUserUnitsString } from "~/utils/units"; +import { useVoucherDetails } from "../pools/hooks"; -type Transaction = { - tx_type: string | null; - id: number; - tx_hash: string; - block_number: number; - tx_index: number; - voucher_address: string; - sender_address: string; - recipient_address: string; - tx_value: string; - date_block: Date; - success: boolean; -}; +type Transaction = RouterOutputs["transaction"]["list"]["transactions"][number]; type TransactionProps = { tx: Transaction; }; @@ -29,8 +18,9 @@ export const TransactionListItem = (props: TransactionProps) => { const auth = useAuth(); const { data: vouchers } = api.voucher.list.useQuery(); const voucher = vouchers?.find( - (v) => v.voucher_address === props.tx.voucher_address + (v) => v.voucher_address === props.tx.contract_address ); + const {data: details} = useVoucherDetails(props.tx.contract_address as `0x${string}`) const address = auth?.user?.account.blockchain_address === props.tx.sender_address ? props.tx.recipient_address @@ -38,10 +28,10 @@ export const TransactionListItem = (props: TransactionProps) => { const received = auth?.user?.account.blockchain_address === props.tx.recipient_address; return ( -
+
- + {voucher?.symbol} @@ -55,7 +45,7 @@ export const TransactionListItem = (props: TransactionProps) => { )}
- {props.tx.date_block.toLocaleTimeString([], { + {props.tx.date_block?.toLocaleTimeString([], { day: "numeric", month: "short", year: "numeric", @@ -65,10 +55,10 @@ export const TransactionListItem = (props: TransactionProps) => {
{received ? "+" : "-"} - {toUserUnitsString(BigInt(props.tx.tx_value))} + {toUserUnitsString(BigInt(props.tx.transfer_value), details?.decimals)} {voucher?.symbol ?? ""} diff --git a/src/components/ui/search-input.tsx b/src/components/ui/search-input.tsx new file mode 100644 index 0000000..28e8e58 --- /dev/null +++ b/src/components/ui/search-input.tsx @@ -0,0 +1,20 @@ +import { type InputHTMLAttributes } from "react"; +import { Input } from "./input"; +import { Search } from "lucide-react"; + +interface SearchInputProps extends InputHTMLAttributes {} + +export function SearchInput(props: SearchInputProps) { + return ( +
+ +
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/components/voucher/forms/create-voucher-form/stepper.tsx b/src/components/voucher/forms/create-voucher-form/stepper.tsx index b865935..9484f3a 100644 --- a/src/components/voucher/forms/create-voucher-form/stepper.tsx +++ b/src/components/voucher/forms/create-voucher-form/stepper.tsx @@ -32,8 +32,8 @@ const Stepper: React.FC = ({ steps }) => { index === activeStep ? "bg-gradient-to-r from-green-500 via-gray-300 to-gray-300" : index <= activeStep - ? "bg-green-500" - : "bg-gray-300" + ? "bg-green-500" + : "bg-gray-300" } ${index < steps.length - 1 ? "mr-1" : ""}`} style={{ width: `calc(${100 / steps.length}% - 0.25rem)`, diff --git a/src/components/voucher/forms/create-voucher-form/steps/expiration.tsx b/src/components/voucher/forms/create-voucher-form/steps/expiration.tsx index e7e5888..95a2b0f 100644 --- a/src/components/voucher/forms/create-voucher-form/steps/expiration.tsx +++ b/src/components/voucher/forms/create-voucher-form/steps/expiration.tsx @@ -42,28 +42,27 @@ export const ExpirationStep = () => { return (
- +
+ +
- {/* Demurrage */} {["gradual", "both"].includes(type) && ( - <> +
{ name="communityFund" label="Community Fund Address" placeholder="0x..." - description="This is the address where expired vouchers will be sent to after each redistribution period. This might be your CELO blockchain address or that of your association. Note that distribution of expired vouchers can be a wonderful participatory community process." + description="This is the address where expired vouchers will be sent to after each redistribution period." endAdornment={ } /> - +
)} {["both", "date"].includes(type) && ( { const stepper = useVoucherStepper(); const auth = useAuth(); return ( -
-
-

- Community Asset Vouchers (CAVs) are offers for goods or - services on a public decentralized ledger called Celo. They are similar to loyalty points or gift cards. This guide will help you design and publish a CAV. Here's the process for creating - a CAV: +

+

Welcome to CAV Creation

+

+ Community Asset Vouchers (CAVs) are offers for goods or services on a public decentralized ledger called Celo. They are similar to loyalty points or gift cards. This guide will help you design and publish a CAV.

-
-
    -
  1. - About you: Who are you as the Issuer creating and - publishing the CAV? -
  2. -
  3. - Naming & Purpose: What's your CAV's name - and what can it be redeemed for? Is it a gift card for your store? -
  4. -
  5. - Valuation & Amount: How many CAVs do you want create and what's their total worth? -
  6. -
  7. - Expiry: Does your CAVs expire over time and where are they renewed? -
  8. -
  9. - Customization: Any special features for your CAV? -
  10. -
  11. - Finalization: Here, you'll sign and publish your - CAV on the Celo ledger. -
  12. -
-
+
+

Process Overview

+
    +
  1. About you: Who are you as the Issuer creating and publishing the CAV?
  2. +
  3. Naming & Purpose: What's your CAV's name and what can it be redeemed for?
  4. +
  5. Valuation & Amount: How many CAVs do you want to create and what's their total worth?
  6. +
  7. Expiry: Does your CAV expire over time and where are they renewed?
  8. +
  9. Customization: Any special features for your CAV?
  10. +
  11. Finalization: Here, you'll sign and publish your CAV on the Celo ledger.
  12. +
+
{ } /> -
{ } /> -
-
+
diff --git a/src/components/voucher/forms/create-voucher-form/steps/name-and-products.tsx b/src/components/voucher/forms/create-voucher-form/steps/name-and-products.tsx index 6005166..b9267c7 100644 --- a/src/components/voucher/forms/create-voucher-form/steps/name-and-products.tsx +++ b/src/components/voucher/forms/create-voucher-form/steps/name-and-products.tsx @@ -51,21 +51,23 @@ export const NameAndProductsStep = () => { } /> - - - + + +
+ { description="Tell people about your Community Asset Voucher (CAV)" /> -
+
-
- Product(s): +
+ Product(s):
-
+
{fields.map((field, index) => (
- - -
- - -
+
+ + +
+ + +
+
))}
diff --git a/src/components/voucher/forms/create-voucher-form/steps/value-and-supply.tsx b/src/components/voucher/forms/create-voucher-form/steps/value-and-supply.tsx index cdf9260..e981737 100644 --- a/src/components/voucher/forms/create-voucher-form/steps/value-and-supply.tsx +++ b/src/components/voucher/forms/create-voucher-form/steps/value-and-supply.tsx @@ -42,22 +42,22 @@ export const ValueAndSupplyStep = () => {
} /> - - +
+ + +
{ console.error(e))} diff --git a/src/components/voucher/user-voucher-balance-item.tsx b/src/components/voucher/user-voucher-balance-item.tsx new file mode 100644 index 0000000..b15f108 --- /dev/null +++ b/src/components/voucher/user-voucher-balance-item.tsx @@ -0,0 +1,62 @@ +import { ChevronDown, ChevronUp } from "lucide-react"; +import Link from "next/link"; +import { useState } from "react"; +import { type TokenValue } from "~/utils/units"; +import { SendDialog } from "../dialogs/send-dialog"; +import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; +import { Button } from "../ui/button"; +import { Card } from "../ui/card"; + +export const UserVoucherBalanceItem = ({ + voucher, + balance, +}: { + voucher: { + id?: number | undefined; + voucher_address: string | undefined; + symbol: string | undefined; + voucher_name: string | undefined; + icon_url?: string | null; + }; + balance?: TokenValue | undefined; +}) => { + const [isExpanded, setIsExpanded] = useState(false); + + return ( + +
setIsExpanded(!isExpanded)} + > + + + + {voucher.voucher_name?.substring(0, 2).toLocaleUpperCase()} + + +
+

+ {voucher.voucher_name} +

+

{voucher.symbol}

+
+ {balance && ( +

{balance.formatted}

+ )} + {isExpanded ? : } +
+ + {isExpanded && ( +
+ + + + Send} + voucherAddress={voucher.voucher_address as `0x${string}`} + /> +
+ )} +
+ ); +}; diff --git a/src/components/voucher/user-voucher-balance-list.tsx b/src/components/voucher/user-voucher-balance-list.tsx new file mode 100644 index 0000000..ec5da29 --- /dev/null +++ b/src/components/voucher/user-voucher-balance-list.tsx @@ -0,0 +1,28 @@ +import { useAuth } from "~/hooks/useAuth"; +import { type RouterOutput } from "~/server/api/root"; +import { useMultiVoucherBalances } from "./hooks"; +import { UserVoucherBalanceItem } from "./user-voucher-balance-item"; + +export const UserVoucherBalanceList = ({ + vouchers, +}: { + vouchers: RouterOutput["me"]["vouchers"]; +}) => { + const auth = useAuth(); + const balances = useMultiVoucherBalances( + vouchers?.map((v) => v.voucher_address as `0x${string}`) ?? [], + auth?.user?.account.blockchain_address + ); + + return ( + <> + {vouchers?.map((v, i) => ( + + ))} + + ); +}; diff --git a/src/components/voucher/voucher-contract-functions.tsx b/src/components/voucher/voucher-contract-functions.tsx index eea78f1..5568828 100644 --- a/src/components/voucher/voucher-contract-functions.tsx +++ b/src/components/voucher/voucher-contract-functions.tsx @@ -1,46 +1,83 @@ -import { PlusIcon } from "@radix-ui/react-icons"; import { ArchiveIcon, SendIcon, WalletIcon } from "lucide-react"; import { useAccount, useWalletClient } from "wagmi"; import { useIsWriter } from "~/hooks/useIsWriter"; import { cn } from "~/lib/utils"; -import MintToDialog from "../dialogs/mint-to-dialog"; import { SendDialog } from "../dialogs/send-dialog"; -import { type GetTokenReturnType } from "@wagmi/core"; import { toast } from "sonner"; import { useIsMounted } from "~/hooks/useIsMounted"; import { useIsOwner } from "~/hooks/useIsOwner"; +import { type RouterOutputs } from "~/utils/api"; import ChangeSinkAddressDialog from "../dialogs/change-sink-dialog"; +import { useVoucherDetails } from "../pools/hooks"; import { Button } from "../ui/button"; -interface VoucherContractFunctionsProps { +interface ManageVoucherFunctionsProps { className?: string; - voucher: { - id: number; - voucher_address: string; - }; - token?: GetTokenReturnType; + voucher_address: string; } -export function VoucherContractFunctions({ +interface BasicVoucherFunctionsProps { + className?: string; + voucher_address: string; + voucher?: RouterOutputs["voucher"]["byAddress"]; +} +export function ManageVoucherFunctions({ className, + voucher_address, +}: ManageVoucherFunctionsProps) { + const mounted = useIsMounted(); + const isWriter = useIsWriter(voucher_address); + const isOwner = useIsOwner(voucher_address); + if (!mounted) { + return null; + } + return ( +
+ + + Send + + } + /> + {(isWriter || isOwner) && ( + + + Change Fund + + } + /> + )} +
+ ); +} + +export function BasicVoucherFunctions({ + className, + voucher_address, voucher, - token, -}: VoucherContractFunctionsProps) { +}: BasicVoucherFunctionsProps) { const account = useAccount(); const mounted = useIsMounted(); - const isWriter = useIsWriter(voucher.voucher_address); - const isOwner = useIsOwner(voucher.voucher_address); const wallet = useWalletClient(); + const { data: details } = useVoucherDetails(voucher_address as `0x${string}`); function watchVoucher() { - if (token?.symbol && token?.decimals) { + if (details?.symbol && details?.decimals) { wallet.data ?.watchAsset({ type: "ERC20", options: { - address: voucher.voucher_address, - symbol: token?.symbol, - decimals: token?.decimals, - image: "https://sarafu.network/android-chrome-512x512.png", + address: voucher_address, + symbol: details.symbol, + decimals: details.decimals, + image: + voucher?.icon_url || + "https://sarafu.network/android-chrome-512x512.png", }, }) .then((done) => { @@ -62,7 +99,7 @@ export function VoucherContractFunctions({ return (
@@ -70,28 +107,7 @@ export function VoucherContractFunctions({ } /> - {(isWriter || isOwner) && ( - - - Mint - - } - /> - )} - {(isWriter || isOwner) && ( - - - Change Fund - - } - /> - )} + {account?.connector?.id && ["io.metamask"].includes(account?.connector?.id) && ( - } - > - - - {pool && } - {isOwner && pool && ( - + )} +
+
+ +

+ {pool?.name} +

+
+ {poolData?.tags && poolData.tags.length > 0 && ( +
+ +
+ {poolData.tags.map((tag, index) => ( + + {tag} + + ))} +
+
+ )} +

+ {poolData?.swap_pool_description} +

+
+ {auth?.account ? ( + <> + - Withdraw + Swap } - /> - )} - - ) : ( - - )} + > + + + {pool && } + {isOwner && pool && } + {isOwner && pool && ( + + + Withdraw + + } + /> + )} + + ) : ( + + )} +
-

- {poolData?.swap_pool_description} -

- - - Vouchers - Swaps - Deposits - Data - - - + +
+ + + + Vouchers + + + Transactions + + + Data + + + + + Edit + + + + Charts - - -
- - - - - - - - - - -
+ +
+ + + + + + + -
- - - - -
- +
+ + + + +
+ setDateRange(newRange)} + /> +
+ +
+
+
+
); diff --git a/src/pages/pools/create.tsx b/src/pages/pools/create.tsx index 3e90c38..dd94417 100644 --- a/src/pages/pools/create.tsx +++ b/src/pages/pools/create.tsx @@ -7,18 +7,15 @@ import { CreatePoolForm } from "~/components/pools/forms/create-pool-form"; export default function CreatePoolPage() { return ( - + -
+
Create Your Own Pool -
-
+
+
Create Pool -

+

Create Your Own Pool

- -

- Empower your community by curating your own commitment pool. With - our intuitive tools, you can easily set up and manage a pool - tailored to your community's needs. -

-

- Create opportunities for access to credit, trade, and - collaboration among local businesses. -

-

- Join us in fostering economic empowerment and growth in your - community. Start creating your pool today -

+
+

+ Empower your community by curating your own commitment pool. With + our intuitive tools, you can easily set up and manage a pool + tailored to your community's needs. +

+

+ Create opportunities for access to credit, trade, and + collaboration among local businesses. +

+

+ Join us in fostering economic empowerment and growth in your + community. Start creating your pool today! +

+
+
+
+
-
diff --git a/src/pages/pools/index.tsx b/src/pages/pools/index.tsx index 0b2a131..b33b4e6 100644 --- a/src/pages/pools/index.tsx +++ b/src/pages/pools/index.tsx @@ -1,44 +1,130 @@ +import { ChevronDown, PlusIcon, Search, X } from "lucide-react"; import Head from "next/head"; import Link from "next/link"; +import { useState } from "react"; import { BreadcrumbResponsive } from "~/components/breadcrumbs"; import { Icons } from "~/components/icons"; import { ContentContainer } from "~/components/layout/content-container"; import { PoolList } from "~/components/pools/pool-list"; -import { buttonVariants } from "~/components/ui/button"; +import { Badge } from "~/components/ui/badge"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover"; +import { api } from "~/utils/api"; export default function PoolsPage() { + const [searchTerm, setSearchTerm] = useState(""); + const [searchTags, setSearchTags] = useState([]); + const { data: tags } = api.tags.list.useQuery(); + + const toggleTag = (tag: string) => { + setSearchTags((prevTags) => + prevTags.includes(tag) + ? prevTags.filter((t) => t !== tag) + : [...prevTags, tag] + ); + }; + return ( - + + + Swap Pools + + + + + -
- - Swap Pools - - - - -
-
- - Create Pool - + +
+
+
+
+ setSearchTerm(e.target.value)} + className="pl-10 pr-4 py-2 w-full" + /> + +
- + +
+ + + + + +
+
+

Tags

+

+ Select tags to filter pools +

+
+
+ {tags?.map((tag) => ( + toggleTag(tag.tag)} + variant={searchTags.includes(tag.tag) ? "default" : "outline"} + className="cursor-pointer" + > + {tag.tag} + + ))} +
+
+
+
+ + +
+
+ + {searchTags.length > 0 && ( +
+ {searchTags.map((tag) => ( + + {tag} + toggleTag(tag)} + /> + + ))} +
+ )} + +
+
diff --git a/src/pages/vouchers/[address]/index.tsx b/src/pages/vouchers/[address]/index.tsx index 5d4a65f..45c2804 100644 --- a/src/pages/vouchers/[address]/index.tsx +++ b/src/pages/vouchers/[address]/index.tsx @@ -1,39 +1,43 @@ import { createServerSideHelpers } from "@trpc/react-query/server"; -import { type UTCTimestamp } from "lightweight-charts"; -import { type GetStaticPaths, type GetStaticPropsContext } from "next"; +import { + type GetStaticPaths, + type GetStaticPropsContext, + type InferGetStaticPropsType, +} from "next"; import dynamic from "next/dynamic"; import { useRouter } from "next/router"; -import { LineChart } from "~/components/charts/line-chart"; import { Card } from "~/components/ui/card"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { appRouter } from "~/server/api/root"; import { api } from "~/utils/api"; -import { toUserUnits, toUserUnitsString } from "~/utils/units"; +import { toUserUnitsString } from "~/utils/units"; -import { - AtSignIcon, - EditIcon, - GlobeIcon, - MapPinIcon, - UserIcon, -} from "lucide-react"; +import { type UTCTimestamp } from "lightweight-charts"; +import { EditIcon } from "lucide-react"; import Head from "next/head"; import Image from "next/image"; import Link from "next/link"; import { useToken } from "wagmi"; import { BreadcrumbResponsive } from "~/components/breadcrumbs"; import StatisticsCard from "~/components/cards/statistics-card"; +import { LineChart } from "~/components/charts/line-chart"; +import { ContractFunctions } from "~/components/contract/ContractFunctions"; import { Icons } from "~/components/icons"; import { ContentContainer } from "~/components/layout/content-container"; +import { getVoucherDetails } from "~/components/pools/contract-functions"; import { useContractIndex, useSwapPool } from "~/components/pools/hooks"; import { ProductList } from "~/components/products/product-list"; import { TransactionsTable } from "~/components/tables/transactions-table"; -import { AspectRatio } from "~/components/ui/aspect-ratio"; import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; import { CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import UpdateVoucherForm from "~/components/voucher/forms/update-voucher-form"; -import { VoucherContractFunctions } from "~/components/voucher/voucher-contract-functions"; +import { + BasicVoucherFunctions, + ManageVoucherFunctions, +} from "~/components/voucher/voucher-contract-functions"; import { VoucherHoldersTable } from "~/components/voucher/voucher-holders-table"; +import { abi as DMRAbi } from "~/contracts/erc20-demurrage-token/contract"; +import { abi as GiftableAbi } from "~/contracts/erc20-giftable-token/contract"; import { env } from "~/env"; import { Authorization } from "~/hooks/useAuth"; import { useIsMounted } from "~/hooks/useIsMounted"; @@ -68,15 +72,17 @@ export async function getStaticProps( }, transformer: SuperJson, // optional - adds superjson serialization }); - const address = context.params?.address as string; + const address = context.params?.address as `0x{string}`; // prefetch `post.byId` await helpers.voucher.byAddress.prefetch({ voucherAddress: address, }); + const details = await getVoucherDetails(address); return { props: { trpcState: helpers.dehydrate(), address, + details, }, // Next.js will attempt to re-generate the page: // - When a request comes in @@ -102,7 +108,9 @@ export const getStaticPaths: GetStaticPaths = async () => { const from = new Date(new Date().setMonth(new Date().getMonth() - 1)); const to = new Date(); -const VoucherPage = () => { +const VoucherPage = ({ + details, +}: InferGetStaticPropsType) => { const router = useRouter(); const voucher_address = router.query.address as `0x${string}`; const { data: poolsRegistry } = useContractIndex( @@ -120,7 +128,6 @@ const VoucherPage = () => { enabled: !!voucher_address, }, }); - const { data: txsPerDay } = api.stats.txsPerDay.useQuery({ voucherAddress: voucher_address, }); @@ -135,252 +142,301 @@ const VoucherPage = () => { to: to, }, }); - if (!voucher) return
Voucher not Found
; + + const getAbiByVoucherType = (voucherType: string | undefined) => { + if (voucherType === "GIFTABLE") { + return GiftableAbi; + } else if (voucherType === "DEMURRAGE") { + return DMRAbi; + } + return undefined; + }; + const getVoucherTypeName = (voucherType: string | undefined) => { + if (voucherType === "GIFTABLE") { + return "Giftable"; + } else if (voucherType === "DEMURRAGE") { + return "Expiring"; + } + return "Unknown"; + }; + const abi = getAbiByVoucherType(voucher?.voucher_type); + return ( - + - {`${voucher.voucher_name}`} + {`${details?.name ?? ""}`} - - + + -
-
- - - - {voucher.voucher_name?.substring(0, 2).toLocaleUpperCase()} - - -

- {voucher.voucher_name}{" "} - ({voucher.symbol}) -

+
+
+ {voucher?.banner_url && ( +
+ {details.name +
+ )} +
+ + + + {details.name?.substring(0, 2).toLocaleUpperCase()} + + +
+

+ {details.name} +

+
+

{details.symbol}

+ {voucher?.voucher_type && ( + + {getVoucherTypeName(voucher.voucher_type)} + + )} +
+ {voucher?.voucher_value && voucher?.voucher_uoa && ( +
+

+ 1 HOUR = {voucher.voucher_value} {voucher.voucher_uoa} of + Products +

+
+ )} +
+
+
- - {isMounted && token && ( - - )} - - + + Home + Data Transactions Holders - + + Manage + + - + + Update -
- -
- {/* Description */} + + + {isMounted && abi && ( + + )} + + +
+
+ + + About + + +

+ {voucher?.voucher_description} +

+
+
-
-

{voucher.voucher_description}

-
- {voucher.banner_url && ( - - Banner + + - - )} -
- - - - -
-
-
- -

- Pool Memberships -

- {poolsRegistry?.contractAddresses?.map((address) => ( - - ))} + +
-
- 0} - value={stats?.transactions.total.toString() || 0} - title="Transactions" - Icon={Icons.hash} - /> - 0} - value={stats?.accounts.total || 0} - title="Active Users" - Icon={Icons.person} - /> + +
+ + + Pool Memberships + + + {poolsRegistry?.contractAddresses?.length === 0 ? ( +
+ +

No pool memberships

+
+ ) : ( +
+ {poolsRegistry?.contractAddresses?.map((address) => ( + + ))} +
+ )} +
+
- - - - - - - - -
- - + + + +
+ 0} + /> + 0} + /> + + 0} - value={toUserUnitsString(stats?.volume.total)} - title="Volume" - Icon={Icons.hash} - /> - 0} - value={stats?.transactions.total.toString() || 0} - title="Transactions" - Icon={Icons.hash} - /> - 0} - value={stats?.accounts.total || 0} - title="Active Users" - Icon={Icons.person} - /> + details.decimals + ) + )} + isIncrease={(stats?.volume.delta || 0) > 0} + /> +
+
+
+ + + Information + + + {voucher && } + +
-
-
- - - Information - - - {voucher_address && ( - - )} - - -
- - - Network + + + Network + Transactions + Volume + Map + + + + + ({ + time: (new Date(v.x).getTime() / + 1000) as UTCTimestamp, + value: parseInt(v.y.toString()), + })) || [] + } + /> + + + ({ + time: (v.x.getTime() / 1000) as UTCTimestamp, + value: parseInt(toUserUnitsString(BigInt(v.y))), + })) || [] + } + /> + + + + + +
+ +
+
+
+
+
+
+ - Transactions - Volume - Map - - - - - ({ - time: (v.x.getTime() / 1000) as UTCTimestamp, - value: parseInt(v.y), - })) || [] - } - /> - - - ({ - time: (v.x.getTime() / 1000) as UTCTimestamp, - value: parseInt(toUserUnitsString(BigInt(v.y))), - })) || [] - } - /> - - - - + + + - -
- -
-
-
-
- -
-
- - - -
+ + + + + + {voucher && } +
@@ -426,39 +482,3 @@ function VoucherPoolListItem(props: { ); } -{ - /*
-
- {label} - {info && ( - - - - )} -
-
- {typeof value === "function" ? ( - value - ) : ( -

{value}

- )} -
-
; */ -} -function VoucherDetailItem(props: { - label: string; - value: string | null; - Icon: React.ElementType; -}) { - return ( -
-
- {" "} - {props.label} -
-
-

{props.value}

-
-
- ); -} diff --git a/src/pages/vouchers/index.tsx b/src/pages/vouchers/index.tsx index 870cb0c..1d19ee0 100644 --- a/src/pages/vouchers/index.tsx +++ b/src/pages/vouchers/index.tsx @@ -1,24 +1,34 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ import { createServerSideHelpers } from "@trpc/react-query/server"; +import { type LatLngBounds } from "leaflet"; +import { debounce } from "lodash"; +import { Loader2, PlusIcon, Search } from "lucide-react"; import dynamic from "next/dynamic"; -import { useRouter } from "next/router"; -import React from "react"; - import Head from "next/head"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import React, { useCallback, useState } from "react"; import { BreadcrumbResponsive } from "~/components/breadcrumbs"; import { ContentContainer } from "~/components/layout/content-container"; +import { Button } from "~/components/ui/button"; import { Card } from "~/components/ui/card"; import { Input } from "~/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; -import { appRouter } from "~/server/api/root"; +import { useAuth } from "~/hooks/useAuth"; +import { appRouter, type RouterOutput } from "~/server/api/root"; import { graphDB, indexerDB } from "~/server/db"; import { api } from "~/utils/api"; import SuperJson from "~/utils/trpc-transformer"; -import { VoucherListItem } from "../../components/voucher/voucher-list-item"; +import { type MapProps } from "../../components/map"; +import { VoucherList } from "../../components/voucher/voucher-list"; + +type VoucherItem = RouterOutput["voucher"]["list"][number]; + +const Map = dynamic>( + () => import("../../components/map"), + { ssr: false } +); -const Map = dynamic(() => import("../../components/map"), { - ssr: false, -}); export async function getStaticProps() { const helpers = createServerSideHelpers({ router: appRouter, @@ -42,66 +52,104 @@ export async function getStaticProps() { revalidate: 60, // In seconds }; } + const VouchersPage = () => { - const { data: vouchers } = api.voucher.list.useQuery(undefined, { + const { data: vouchers, isLoading } = api.voucher.list.useQuery(undefined, { initialData: [], }); - const [search, setSearch] = React.useState(""); + const [search, setSearch] = useState(""); + const [mapZoom, setMapZoom] = useState(2); + const [mapBounds, setMapBounds] = useState(null); const router = useRouter(); + const user = useAuth(); + + const debouncedSearch = useCallback( + debounce((value: string) => setSearch(value), 300), + [] + ); + const filteredVouchers = React.useMemo( () => vouchers?.filter( (voucher) => - voucher.voucher_name?.toLowerCase().includes(search.toLowerCase()) || - voucher.location_name?.toLowerCase().includes(search.toLowerCase()) || - voucher.symbol?.toLowerCase().includes(search.toLowerCase()) + (voucher.voucher_name?.toLowerCase().includes(search.toLowerCase()) || + voucher.location_name + ?.toLowerCase() + .includes(search.toLowerCase()) || + voucher.symbol?.toLowerCase().includes(search.toLowerCase())) && + (!mapBounds || + (voucher.geo && mapBounds.contains([voucher.geo.x, voucher.geo.y]))) ), - [vouchers, search] + [vouchers, search, mapBounds] ); + return ( -
+
- Vouchers + Vouchers - Sarafu Network - - - -

- Explore community asset vouchers -

-
- setSearch(v.target.value)} + + -
- {filteredVouchers.map((voucher, idx) => ( - - ))} + +
+

+ Explore +

+
+
+
+
+ debouncedSearch(e.target.value)} + /> + +
+ {user && ( + + )}
+ {isLoading ? ( +
+ +
+ ) : ( +
+ +
+ )}
- - + + Map Stats @@ -110,35 +158,25 @@ const VouchersPage = () => { Graphs - - + + { - return item.voucher_name || ""; - }} - // @ts-ignore - onItemClicked={(item: (typeof filteredVouchers)[0]) => { - void router.push(`/vouchers/${item.voucher_address}`); - }} - zoom={2} - // @ts-ignore - getLatLng={(item: (typeof filteredVouchers)[0]) => { - return item.geo - ? [item.geo.x, item.geo.y] - : [-3.654593340629959, 39.85153198242188]; - }} + style={{ height: "590px", width: "100%", zIndex: 1 }} + items={filteredVouchers} + getTooltip={(item) => item.voucher_name || ""} + onItemClicked={(item) => + void router.push(`/vouchers/${item.voucher_address}`) + } + zoom={mapZoom} + onZoomChange={setMapZoom} + onBoundsChange={setMapBounds} + getLatLng={(item) => + item.geo ? [item.geo.x, item.geo.y] : undefined + } /> - - + +
diff --git a/src/pages/wallet/explore.tsx b/src/pages/wallet/explore.tsx index 8617824..8e0fdfc 100644 --- a/src/pages/wallet/explore.tsx +++ b/src/pages/wallet/explore.tsx @@ -1,6 +1,3 @@ -import { getIronSession } from "iron-session"; -import { type GetServerSideProps } from "next"; -import { useRouter } from "next/router"; import React from "react"; import { BreadcrumbResponsive } from "~/components/breadcrumbs"; import { ContentContainer } from "~/components/layout/content-container"; @@ -10,35 +7,10 @@ import { Input } from "~/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import { VoucherList } from "~/components/voucher/voucher-list"; import { useAuth } from "~/hooks/useAuth"; -import { sessionOptions, type SessionData } from "~/lib/session"; import { api } from "~/utils/api"; -export const getServerSideProps: GetServerSideProps = async ({ - req, - res, -}) => { - const session = await getIronSession(req, res, sessionOptions); - const user = session.user; - if (user === undefined) { - res.setHeader("location", "/"); - res.statusCode = 302; - res.end(); - return { - props: {}, - }; - } - return { - props: {}, - }; -}; - const WalletPage = () => { const auth = useAuth(); - const router = useRouter(); - React.useEffect(() => { - if (!auth?.user) { - router.push("/").catch(console.error); - } - }, [auth?.user]); + const { data: vouchers } = api.voucher.list.useQuery(); const [search, setSearch] = React.useState(""); const filteredVouchers = React.useMemo( @@ -86,7 +58,7 @@ const WalletPage = () => { - + diff --git a/src/pages/wallet/index.tsx b/src/pages/wallet/index.tsx index 14d72c2..2585f73 100644 --- a/src/pages/wallet/index.tsx +++ b/src/pages/wallet/index.tsx @@ -1,14 +1,17 @@ import { getIronSession } from "iron-session"; import { QrCodeIcon, SendIcon } from "lucide-react"; import { type GetServerSideProps } from "next"; +import { useAccount } from "wagmi"; +import Balance from "~/components/balance"; import { ReceiveDialog } from "~/components/dialogs/receive-dialog"; import { SendDialog } from "~/components/dialogs/send-dialog"; import { ContentContainer } from "~/components/layout/content-container"; +import { useVoucherDetails } from "~/components/pools/hooks"; import { TransactionList } from "~/components/transactions/transaction-list"; import { Button } from "~/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; import UserGasStatus from "~/components/users/user-gas-status"; -import { VoucherList } from "~/components/voucher/voucher-list"; +import { UserVoucherBalanceList } from "~/components/voucher/user-voucher-balance-list"; import { useAuth } from "~/hooks/useAuth"; import { sessionOptions, type SessionData } from "~/lib/session"; import { api } from "~/utils/api"; @@ -45,25 +48,42 @@ const WalletPage = () => { } ); const { data: vouchers } = api.me.vouchers.useQuery(); - + const { data: me } = api.me.get.useQuery(); + const account = useAccount(); + const { data: voucherDetails } = useVoucherDetails( + me?.default_voucher as `0x${string}` + ); const txs = data?.transactions; return (
+
+
+ {voucherDetails?.symbol ?? "Unknown"} +
+
+ + + +
+
- + } /> - + } @@ -77,11 +97,14 @@ const WalletPage = () => { History Balances - + - - + +
diff --git a/src/server/api/root.ts b/src/server/api/root.ts index a6585cd..ce762e0 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -9,8 +9,8 @@ import { meRouter } from "./routers/me"; import { poolRouter } from "./routers/pool"; import { productsRouter } from "./routers/products"; import { statsRouter } from "./routers/stats"; -import { userRouter } from "./routers/user"; import { tagsRouter } from "./routers/tags"; +import { userRouter } from "./routers/user"; /** * This is the primary router for your server. diff --git a/src/server/api/routers/me.ts b/src/server/api/routers/me.ts index 9015fb4..9cf6f4c 100644 --- a/src/server/api/routers/me.ts +++ b/src/server/api/routers/me.ts @@ -1,11 +1,11 @@ import { TRPCError } from "@trpc/server"; +import { sql } from "kysely"; import { isAddress } from "viem"; +import { getVoucherDetails } from "~/components/pools/contract-functions"; import { UserProfileFormSchema } from "~/components/users/forms/profile-form"; import { authenticatedProcedure, createTRPCRouter } from "~/server/api/trpc"; -import { GasGiftStatus } from "~/server/enums"; +import { GasGiftStatus, type AccountRoleType } from "~/server/enums"; import { sendGasRequestedEmbed } from "../../discord"; -import { sql } from "kysely"; -import {type AccountRoleType } from "~/server/enums"; export const meRouter = createTRPCRouter({ get: authenticatedProcedure.query(async ({ ctx }) => { @@ -22,7 +22,9 @@ export const meRouter = createTRPCRouter({ ) .where("accounts.blockchain_address", "=", address) .select([ - sql`accounts.account_role`.as("account_role"), + sql`accounts.account_role`.as( + "account_role" + ), "personal_information.given_names", "personal_information.family_name", "personal_information.gender", @@ -99,25 +101,47 @@ export const meRouter = createTRPCRouter({ if (!address || !isAddress(address)) { return []; } + const vouchers = await ctx.indexerDB + .selectFrom("token_transfer") + .select("contract_address") + .where((eb) => + eb.or([ + eb("sender_address", "=", address), + eb("recipient_address", "=", address), + ]) + ) + .distinct() + .execute(); + const voucherAddresses = vouchers.map((v) => v.contract_address); const result = await ctx.graphDB .selectFrom("vouchers") .selectAll() - .where( - "voucher_address", - "in", - ctx.graphDB - .selectFrom("transactions") - .select("voucher_address") - .where((eb) => - eb.or([ - eb("sender_address", "=", address), - eb("recipient_address", "=", address), - ]) - ) - .distinct() - ) + .where("voucher_address", "in", voucherAddresses) .execute(); - return result; + + const userVouchers: ( + | (typeof result)[number] + | { + voucher_address: string; + symbol: string; + voucher_name: string; + } + )[] = result; + // Add vouchers that are not in the result but in the vouchers array + const resultSet = new Set(result.map((v) => v.voucher_address)); + for (const voucher of vouchers) { + if (!resultSet.has(voucher.contract_address)) { + const details = await getVoucherDetails( + voucher.contract_address as `0x${string}` + ); + userVouchers.push({ + voucher_address: voucher.contract_address as `0x${string}`, + symbol: details.symbol ?? "Unknown", + voucher_name: details.name ?? "Unknown", + }); + } + } + return userVouchers; }), requestGas: authenticatedProcedure.mutation(async ({ ctx }) => { diff --git a/src/server/api/routers/pool.ts b/src/server/api/routers/pool.ts index 892ffec..f6fcda1 100644 --- a/src/server/api/routers/pool.ts +++ b/src/server/api/routers/pool.ts @@ -1,6 +1,8 @@ import { TRPCError } from "@trpc/server"; -import { isAddress } from "viem"; +import { sql } from "kysely"; +import { formatUnits, isAddress } from "viem"; import { z } from "zod"; +import { getMultipleVoucherDetails } from "~/components/pools/contract-functions"; import { PoolIndex } from "~/contracts"; import { TokenIndex } from "~/contracts/erc20-token-index"; import { getIsOwner } from "~/contracts/helpers"; @@ -134,7 +136,12 @@ export const poolRouter = createTRPCRouter({ ctx.user.account.blockchain_address, input ); - const canDelete = hasPermission(ctx.user, isContractOwner, "Pools", "DELETE"); + const canDelete = hasPermission( + ctx.user, + isContractOwner, + "Pools", + "DELETE" + ); if (!canDelete) { throw new TRPCError({ code: "UNAUTHORIZED", @@ -238,7 +245,12 @@ export const poolRouter = createTRPCRouter({ ctx.user.account.blockchain_address, input.address ); - const canUpdate = hasPermission(ctx.user, isContractOwner, "Pools", "UPDATE"); + const canUpdate = hasPermission( + ctx.user, + isContractOwner, + "Pools", + "UPDATE" + ); if (!canUpdate) { throw new TRPCError({ code: "UNAUTHORIZED", @@ -315,4 +327,187 @@ export const poolRouter = createTRPCRouter({ return { message: "Pool updated successfully" }; }), + transactions: publicProcedure + .input( + z.object({ + limit: z.number().min(1).nullish(), + cursor: z.number().nullish(), + address: z.string().refine(isAddress, { message: "Invalid address" }), + type: z.enum(["swap", "deposit", "all"]).nullish(), + inToken: z + .string() + .refine(isAddress, { message: "Invalid address" }) + .nullish(), + outToken: z + .string() + .refine(isAddress, { message: "Invalid address" }) + .nullish(), + }) + ) + .query(async ({ ctx, input }) => { + const limit = input.limit ?? 10; + const cursor = input.cursor ?? 0; + const type = input.type ?? "all"; + let query = ctx.indexerDB + .selectFrom( + ctx.indexerDB + .selectFrom("pool_swap") + .leftJoin("tx", "tx.id", "pool_swap.tx_id") + .where("pool_swap.contract_address", "=", input.address) + .select([ + sql<"swap" | "deposit">`'swap'`.as("type"), + "tx.date_block", + "tx.tx_hash", + "tx.success", + "pool_swap.initiator_address", + "pool_swap.token_in_address", + "pool_swap.token_out_address", + "pool_swap.in_value", + "pool_swap.out_value", + "pool_swap.fee", + ]) + .union( + ctx.indexerDB + .selectFrom("pool_deposit") + .leftJoin("tx", "tx.id", "pool_deposit.tx_id") + .where("pool_deposit.contract_address", "=", input.address) + .select([ + sql<"deposit">`'deposit'`.as("type"), + "tx.date_block", + "tx.tx_hash", + "tx.success", + "pool_deposit.initiator_address", + "pool_deposit.token_in_address", + sql`NULL`.as("token_out_address"), + "pool_deposit.in_value", + sql`NULL`.as("out_value"), + sql`NULL`.as("fee"), + ]) + ) + .as("combined_transactions") + ) + .select([ + "type", + "date_block", + "tx_hash", + "success", + "initiator_address", + "token_in_address", + "token_out_address", + "in_value", + "out_value", + "fee", + ]); + + // Apply filters + if (type !== "all") { + query = query.where("type", "=", type); + } + if (input.inToken) { + query = query.where("token_in_address", "=", input.inToken); + } + if (input.outToken) { + query = query.where("token_out_address", "=", input.outToken); + } + + const transactions = await query + .orderBy("date_block", "desc") + .limit(limit) + .offset(cursor) + .execute(); + + return { + transactions, + nextCursor: transactions.length === limit ? cursor + limit : undefined, + }; + }), + + + tokenDistribution: publicProcedure + .input( + z.object({ + address: z.string().refine(isAddress, { message: "Invalid address" }), + from: z.date(), + to: z.date(), + }) + ) + .query(async ({ ctx, input }) => { + const distributionData = await ctx.indexerDB + .selectFrom((subquery) => + subquery + .selectFrom("pool_deposit") + .innerJoin("tx", "tx.id", "pool_deposit.tx_id") + .select([ + "pool_deposit.token_in_address as token_address", + sql`SUM(pool_deposit.in_value)`.as("deposit_value"), + sql`0`.as("swap_in_value"), + sql`0`.as("swap_out_value"), + ]) + .where("pool_deposit.contract_address", "=", input.address) + .where("tx.date_block", ">=", input.from) + .where("tx.date_block", "<=", input.to) + .groupBy("pool_deposit.token_in_address") + .union( + subquery + .selectFrom("pool_swap") + .innerJoin("tx", "tx.id", "pool_swap.tx_id") + .select([ + "pool_swap.token_in_address as token_address", + sql`0`.as("deposit_value"), + sql`SUM(pool_swap.in_value)`.as("swap_in_value"), + sql`0`.as("swap_out_value"), + ]) + .where("pool_swap.contract_address", "=", input.address) + .where('tx.date_block', '>=', input.from) + .where('tx.date_block', '<=', input.to) + .groupBy("pool_swap.token_in_address") + ) + .union( + subquery + .selectFrom("pool_swap") + .innerJoin("tx", "tx.id", "pool_swap.tx_id") + .select([ + "pool_swap.token_out_address as token_address", + sql`0`.as("deposit_value"), + sql`0`.as("swap_in_value"), + sql`SUM(pool_swap.out_value)`.as("swap_out_value"), + ]) + .where("pool_swap.contract_address", "=", input.address) + .where('tx.date_block', '>=', input.from) + .where('tx.date_block', '<=', input.to) + .groupBy("pool_swap.token_out_address") + ) + .as("combined") + ) + .select([ + "token_address", + sql`SUM(deposit_value)`.as("total_deposit_value"), + sql`SUM(swap_in_value)`.as("total_swap_in_value"), + sql`SUM(swap_out_value)`.as("total_swap_out_value"), + ]) + .groupBy("token_address") + .execute(); + + const details = await getMultipleVoucherDetails( + distributionData.map((d) => d.token_address) as `0x${string}`[] + ); + + return distributionData.map((d) => { + const tokenDetail = details.find( + (detail) => detail.address === d.token_address + ); + const formatValue = (value: string) => { + return tokenDetail?.decimals ? formatUnits(BigInt(value), tokenDetail.decimals) : value; + }; + return { + address: d.token_address, + deposit_value: formatValue(d.total_deposit_value), + swap_in_value: formatValue(d.total_swap_in_value), + swap_out_value: formatValue(d.total_swap_out_value), + name: tokenDetail?.name, + symbol: tokenDetail?.symbol, + decimals: tokenDetail?.decimals, + }; + }); + }), }); diff --git a/src/server/api/routers/products.ts b/src/server/api/routers/products.ts index c0e731b..4c91423 100644 --- a/src/server/api/routers/products.ts +++ b/src/server/api/routers/products.ts @@ -86,6 +86,7 @@ export const productsRouter = createTRPCRouter({ commodity_type: input.commodity_type, quantity: input.quantity, frequency: input.frequency ?? "", + price: input.price, }) .where("id", "=", input.id) .returningAll() diff --git a/src/server/api/routers/stats.ts b/src/server/api/routers/stats.ts index bd718b6..fc10797 100644 --- a/src/server/api/routers/stats.ts +++ b/src/server/api/routers/stats.ts @@ -2,6 +2,7 @@ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ /* eslint-disable @typescript-eslint/no-unsafe-call */ import { sql } from "kysely"; +import { getAddress } from "viem"; import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; @@ -43,30 +44,47 @@ export const statsRouter = createTRPCRouter({ }) ) .query(async ({ ctx, input }) => { - const from = input.dateRange?.from ?? new Date("2023-06-01"); - const to = input.dateRange?.to ?? new Date(); - if (input?.voucherAddress) { - const result = await sql<{ x: Date; y: string }>`WITH date_range AS ( - SELECT day::date FROM generate_series(${from}, ${to}, INTERVAL '1 day') day - ) - SELECT date_range.day AS x, COUNT(transactions.id) AS y - FROM date_range - LEFT JOIN transactions ON date_range.day = CAST(transactions.date_block AS date) - AND transactions.success = true AND transactions.voucher_address = ${input.voucherAddress} - GROUP BY date_range.day - ORDER BY date_range.day`.execute(ctx.graphDB); - return result.rows; - } else { - const result = await sql<{ x: Date; y: string }>`WITH date_range AS ( - SELECT day::date FROM generate_series(${from}, ${to}, INTERVAL '1 day') day - ) - SELECT date_range.day AS x, COUNT(transactions.id) AS y - FROM date_range - LEFT JOIN transactions ON date_range.day = CAST(transactions.date_block AS date) - AND transactions.success = true - GROUP BY date_range.day - ORDER BY date_range.day`.execute(ctx.graphDB); - return result.rows; + try { + const from = input.dateRange?.from ?? new Date("2023-06-01"); + const to = input.dateRange?.to ?? new Date(); + + let subquery = ctx.indexerDB + .selectFrom("token_transfer") + .select([ + sql`DATE(tx.date_block)`.as("date"), + "token_transfer.tx_id", + ]) + .innerJoin("tx", "tx.id", "token_transfer.tx_id") + .where("tx.date_block", ">=", from) + .where("tx.date_block", "<=", to) + .where("tx.success", "=", true); + + if (input.voucherAddress) { + subquery = subquery.where( + "token_transfer.contract_address", + "=", + getAddress(input.voucherAddress) + ); + } + + const result = await ctx.indexerDB + .selectFrom(subquery.as("subq")) + .select([ + "subq.date as x", + sql`COUNT(DISTINCT subq.tx_id)`.as("y"), + ]) + .groupBy("subq.date") + .orderBy("subq.date") + .execute(); + + if (result.length === 0) { + console.log("No results found for the given criteria"); + return []; + } + return result; + } catch (error) { + console.error("Error in txsPerDay query:", error); + throw error; } }), @@ -169,44 +187,53 @@ export const statsRouter = createTRPCRouter({ from: new Date(input.dateRange.from.getTime() - timeDiff), to: new Date(input.dateRange.to.getTime() - timeDiff), }; - const period = sql<"current" | "outside" | "previous">`CASE - WHEN date_block >= ${input.dateRange.from} AND date_block < ${input.dateRange.to} THEN 'current' - WHEN date_block >= ${lastPeriod.from} AND date_block < ${lastPeriod.to} THEN 'previous' + + const period = sql<"current" | "previous" | "outside">`CASE + WHEN tx.date_block >= ${input.dateRange.from} AND tx.date_block < ${input.dateRange.to} THEN 'current' + WHEN tx.date_block >= ${lastPeriod.from} AND tx.date_block < ${lastPeriod.to} THEN 'previous' ELSE 'outside' - END`.as("period"); - const volume = sql`SUM(transactions.tx_value)`.as("total_volume"); - const uniqueAccounts = sql`COUNT(DISTINCT accounts.id)`.as( - "unique_accounts" + END`.as("period"); + + const volume = sql`SUM(token_transfer.transfer_value)`.as( + "total_volume" ); - const totalTxs = sql`COUNT(transactions.id)`.as( - `total_transactions` + const uniqueAccounts = + sql`COUNT(DISTINCT token_transfer.sender_address)`.as( + "unique_accounts" + ); + const totalTxs = sql`COUNT(DISTINCT token_transfer.tx_id)`.as( + "total_transactions" ); - let query = ctx.graphDB - .selectFrom("transactions") + + let query = ctx.indexerDB + .selectFrom("token_transfer") + .innerJoin("tx", "tx.id", "token_transfer.tx_id") .select([period, volume, uniqueAccounts, totalTxs]) - .innerJoin("accounts", "accounts.blockchain_address", "sender_address") - .where("transactions.date_block", ">=", lastPeriod.from) - .where("transactions.date_block", "<=", input.dateRange.to) - .where("transactions.tx_type", "=", "TRANSFER") - .where("transactions.success", "=", true) + .where("tx.date_block", ">=", lastPeriod.from) + .where("tx.date_block", "<=", input.dateRange.to) + .where("tx.success", "=", true) .groupBy("period"); + if (input.voucherAddress) { query = query.where( - "transactions.voucher_address", + "token_transfer.contract_address", "=", input.voucherAddress ); } + const result = await query.execute(); + const current = result.find((row) => row.period === "current"); const previous = result.find((row) => row.period === "previous"); + const data = { period: "current", volume: { - total: current?.total_volume ?? BigInt(0), + total: BigInt(current?.total_volume ?? "0"), delta: - parseInt(current?.total_volume?.toString() ?? "0") - - parseInt(previous?.total_volume?.toString() ?? "0"), + BigInt(current?.total_volume ?? "0") - + BigInt(previous?.total_volume ?? "0"), }, accounts: { total: current?.unique_accounts ?? 0, @@ -251,4 +278,52 @@ export const statsRouter = createTRPCRouter({ `.execute(ctx.graphDB); return result.rows; }), + + poolStats: publicProcedure + .input( + z.object({ + dateRange: z.object({ + from: z.date(), + to: z.date(), + }), + }) + ) + .query(async ({ ctx, input }) => { + const { from, to } = input.dateRange; + + const totalPools = await ctx.graphDB + .selectFrom("swap_pools") + .select(sql`count(*)`.as("count")) + .executeTakeFirst(); + + const activePools = await ctx.indexerDB + .selectFrom("pool_swap") + .select(sql`count(distinct contract_address)`.as("count")) + .innerJoin("tx", "tx.id", "pool_swap.tx_id") + .where("tx.date_block", ">=", from) + .where("tx.date_block", "<=", to) + .executeTakeFirst(); + + const totalLiquidity = await ctx.indexerDB + .selectFrom("pool_deposit") + .select(sql`sum(in_value)`.as("total_liquidity")) + .innerJoin("tx", "tx.id", "pool_deposit.tx_id") + .where("tx.date_block", "<=", to) + .executeTakeFirst(); + + const totalVolume = await ctx.indexerDB + .selectFrom("pool_swap") + .select(sql`sum(in_value)`.as("total_volume")) + .innerJoin("tx", "tx.id", "pool_swap.tx_id") + .where("tx.date_block", ">=", from) + .where("tx.date_block", "<=", to) + .executeTakeFirst(); + + return { + totalPools: totalPools?.count ?? 0, + activePools: activePools?.count ?? 0, + totalLiquidity: parseFloat(totalLiquidity?.total_liquidity ?? "0"), + totalVolume: parseFloat(totalVolume?.total_volume ?? "0"), + }; + }), }); diff --git a/src/server/api/routers/transaction.ts b/src/server/api/routers/transaction.ts index 4cf11b3..3f7bcf8 100644 --- a/src/server/api/routers/transaction.ts +++ b/src/server/api/routers/transaction.ts @@ -14,17 +14,18 @@ export const transactionRouter = createTRPCRouter({ .query(async ({ ctx, input }) => { const limit = input?.limit ?? 20; const cursor = input?.cursor ?? 0; - let query = ctx.graphDB - .selectFrom("transactions") + let query = ctx.indexerDB + .selectFrom("token_transfer") + .leftJoin("tx", "tx_id", "tx.id") .selectAll() .limit(limit) .offset(cursor) - .orderBy("date_block", "desc"); + .orderBy("tx.date_block", "desc"); if (input?.voucherAddress) { - query = query.where("voucher_address", "=", input.voucherAddress); + query = query.where("contract_address", "=", input.voucherAddress); } if (input?.voucherAddress) { - query = query.where("voucher_address", "=", input.voucherAddress); + query = query.where("contract_address", "=", input.voucherAddress); } if (input?.accountAddress) { const accountAddress = input.accountAddress; diff --git a/src/server/api/routers/voucher.ts b/src/server/api/routers/voucher.ts index 75c5679..b650bb9 100644 --- a/src/server/api/routers/voucher.ts +++ b/src/server/api/routers/voucher.ts @@ -10,7 +10,7 @@ import { publicProcedure, } from "~/server/api/trpc"; import { sendVoucherEmbed } from "~/server/discord"; -import { AccountRoleType, CommodityType, VoucherType } from "~/server/enums"; +import { AccountRoleType, CommodityType } from "~/server/enums"; import { getPermissions } from "~/utils/permissions"; const insertVoucherInput = z.object({ @@ -19,6 +19,7 @@ const insertVoucherInput = z.object({ .string() .refine(isAddress, { message: "Invalid address format" }), contractVersion: z.string(), + type: z.enum(["DEMURRAGE", "GIFTABLE"]), }); const updateVoucherInput = z.object({ geo: z @@ -202,29 +203,28 @@ export const voucherRouter = createTRPCRouter({ }) ) .query(({ ctx, input }) => { - return ctx.graphDB - .selectFrom("transactions") - .innerJoin( - "accounts", - "transactions.recipient_address", - "accounts.blockchain_address" - ) - .distinctOn("transactions.recipient_address") - .where("transactions.voucher_address", "=", input.voucherAddress) - .select(["accounts.created_at", "blockchain_address as address"]) + return ctx.indexerDB + .selectFrom("token_transfer") + .leftJoin("tx", "tx.id", "token_transfer.tx_id") + .distinctOn("recipient_address") + .where("token_transfer.contract_address", "=", input.voucherAddress) + .select(["recipient_address as address"]) .execute(); }), deploy: authenticatedProcedure .input(insertVoucherInput) .mutation(async ({ ctx, input }) => { const voucherAddress = getAddress(input.voucherAddress); - if (input.expiration.type !== "gradual") { + if (!["gradual", "none"].includes(input.expiration.type)) { throw new TRPCError({ code: "BAD_REQUEST", - message: `Only gradual expiration is supported`, + message: `Only gradual or none expiration is supported`, }); } - const communityFund = input.expiration.communityFund; + const communityFund = + input.expiration.type === "gradual" + ? input.expiration.communityFund + : ""; if (!ctx.session?.user?.id) { throw new TRPCError({ code: "UNAUTHORIZED", @@ -247,7 +247,7 @@ export const voucherRouter = createTRPCRouter({ voucher_value: input.valueAndSupply.value, voucher_website: input.aboutYou.website, voucher_uoa: input.valueAndSupply.uoa, - voucher_type: VoucherType.DEMURRAGE, + voucher_type: input.type, geo: input.aboutYou.geo, location_name: input.aboutYou.location ?? " ", internal: internal, diff --git a/src/utils/permissions.ts b/src/utils/permissions.ts index ff940f0..16bf201 100644 --- a/src/utils/permissions.ts +++ b/src/utils/permissions.ts @@ -27,6 +27,9 @@ export const permissions = { Gas: { APPROVE: ["SUPER_ADMIN", "ADMIN", "STAFF"], }, + Contract: { + UPDATE: ["OWNER"], + }, } as const; export type Permissions = typeof permissions;