diff --git a/.gitignore b/.gitignore index 8a40cdc..5101e2f 100644 --- a/.gitignore +++ b/.gitignore @@ -130,6 +130,9 @@ dist # Serverless Webpack directories .webpack/ +# generated code +websites/app/src/hooks/contracts/generated.ts + # Optional stylelint cache .stylelintcache @@ -205,6 +208,4 @@ subgraph/*/build/* subgraph/*/contracts/* # Local Netlify folder -.netlify - -websites/app/.yarn \ No newline at end of file +.netlify \ No newline at end of file diff --git a/websites/app/.parcelrc b/websites/app/.parcelrc deleted file mode 100644 index e35a0d8..0000000 --- a/websites/app/.parcelrc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "@parcel/config-default", - "transformers": { - "*.svg": ["...", "@parcel/transformer-svg-react"], - "tsx:*.svg": ["@parcel/transformer-svg-react"], - "tsx:*": ["..."] - } -} \ No newline at end of file diff --git a/websites/app/.prettierrc b/websites/app/.prettierrc new file mode 100644 index 0000000..b4bfed3 --- /dev/null +++ b/websites/app/.prettierrc @@ -0,0 +1,3 @@ +{ + "plugins": ["prettier-plugin-tailwindcss"] +} diff --git a/websites/app/netlify.toml b/websites/app/netlify.toml new file mode 100644 index 0000000..b858ffa --- /dev/null +++ b/websites/app/netlify.toml @@ -0,0 +1,8 @@ +[build] + publish = "dist" + command = "wagmi generate && yarn run build" + +[build.environment] + NODE_VERSION = "20.18.3" + YARN_VERSION = "4.8.1" + NPM_FLAGS = "--legacy-peer-deps" \ No newline at end of file diff --git a/websites/app/package.json b/websites/app/package.json index 9d1e700..ea91826 100644 --- a/websites/app/package.json +++ b/websites/app/package.json @@ -1,9 +1,10 @@ { - "name": "@kleros/perma-curate", + "name": "@kleros/scout", "homepage": "./", "source": "src/index.html", "version": "0.1.0", "license": "MIT", + "type": "module", "alias": { "src": "./src", "utils": "./src/utils", @@ -21,24 +22,35 @@ "gifs": "./src/assets/gifs" }, "scripts": { - "start": "parcel", - "build": "parcel build --public-url ./" + "clean": "rimraf node_modules dist", + "start": "wagmi generate && vite", + "build": "vite build", + "preview": "vite preview" }, "dependencies": { "@cyntler/react-doc-viewer": "^1.16.3", + "@kleros/kleros-app": "^2.0.2", + "@kleros/ui-components-library": "^3.4.5", + "@reown/appkit": "^1.7.1", + "@reown/appkit-adapter-wagmi": "^1.7.1", "@scure/base": "^1.2.5", "@solana/web3.js": "^1.98.0", - "@tanstack/react-query": "^5.12.2", + "@tanstack/react-query": "^5.69.0", + "@wagmi/connectors": "^5.7.11", + "@wagmi/core": "^2.16.7", "bs58check": "^4.0.0", "cross-fetch": "^4.0.0", "ethers": "^6.8.0", "graphql": "^16.8.1", "graphql-request": "^6.1.0", "humanize-duration": "^3.32.1", + "json-2-csv": "^5.5.9", "overlayscrollbars": "^2.3.0", "overlayscrollbars-react": "^0.5.2", "react": "^18.0.0", "react-dom": "^18.0.0", + "react-error-boundary": "^4.1.2", + "react-identicons": "^1.2.5", "react-loading-skeleton": "^3.4.0", "react-markdown": "^8.0.7", "react-modal": "^3.15.1", @@ -47,11 +59,12 @@ "react-select": "^5.8.0", "react-toastify": "^9.1.3", "react-use": "^17.4.0", - "styled-components": "^5.3.9" + "recharts": "^3.1.2", + "styled-components": "^5.3.9", + "viem": "^2.24.1", + "wagmi": "^2.14.15" }, "devDependencies": { - "@parcel/transformer-svg-react": "2.8.3", - "@parcel/watcher": "~2.2.0", "@tanstack/eslint-plugin-query": "^5.12.1", "@types/node": "^20.0.0", "@types/react": "^18.0.0", @@ -59,16 +72,20 @@ "@types/react-modal": "^3.14.1", "@types/react-router-dom": "5.3.3", "@types/styled-components": "^5.1.26", + "@vitejs/plugin-react-swc": "^3.10.1", + "@wagmi/cli": "^2.3.2", "autoprefixer": "^10.0.0", "buffer": "^5.5.0", "eslint": "^8.0.0", - "eslint-import-resolver-parcel": "^1.10.6", "eslint-plugin-react": "^7.0.0", "eslint-plugin-react-hooks": "^4.0.0", - "parcel": "2.8.3", "postcss": "^8.0.0", "process": "^0.11.10", - "typescript": "^4.9.5" + "typescript": "^4.9.5", + "vite": "^6.3.5", + "vite-plugin-node-polyfills": "^0.23.0", + "vite-plugin-svgr": "^4.3.0", + "vite-tsconfig-paths": "^5.1.4" }, "eslintConfig": { "extends": [ @@ -93,7 +110,7 @@ "singleQuote": true }, "volta": { - "node": "18.19.1", - "yarn": "3.3.1" + "node": "20.18.3", + "yarn": "4.8.1" } } diff --git a/websites/app/src/app.tsx b/websites/app/src/app.tsx index 34ad1f6..a50ec4d 100644 --- a/websites/app/src/app.tsx +++ b/websites/app/src/app.tsx @@ -1,36 +1,66 @@ -import React, { useRef } from 'react' -import styled from 'styled-components' -import 'overlayscrollbars/styles/overlayscrollbars.css' -import 'react-loading-skeleton/dist/skeleton.css' -import { OverlayScrollbarsComponent } from 'overlayscrollbars-react' -import { OverlayScrollContext } from 'context/OverlayScrollContext' -import Home from 'pages/Home' -import StyledComponentsProvider from 'context/StyledComponentsProvider' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React, { lazy, Suspense, useRef } from 'react'; +import styled from 'styled-components'; +import { ErrorBoundary } from "react-error-boundary"; +import 'overlayscrollbars/styles/overlayscrollbars.css'; +import 'react-loading-skeleton/dist/skeleton.css'; +import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; +import { OverlayScrollContext } from 'context/OverlayScrollContext'; +import StyledComponentsProvider from 'context/StyledComponentsProvider'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Routes, Route, Navigate } from 'react-router-dom'; +import Layout from './layout'; +import Web3Provider from "context/Web3Provider"; +import ErrorFallback from "./components/ErrorFallback"; +import Registries from './pages/Registries/'; +import ItemDetails from './pages/ItemDetails/'; + +const Dashboard = lazy(() => import('pages/Dashboard')); const StyledOverlayScrollbarsComponent = styled(OverlayScrollbarsComponent)` height: 100vh; width: 100vw; -` -const queryClient = new QueryClient() +`; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 3, + retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), + staleTime: 10000, + refetchOnWindowFocus: false, + refetchOnReconnect: true, + }, + }, +}); const App: React.FC = () => { - const containerRef = useRef(null) + const containerRef = useRef(null); return ( - - - - - - - - - - ) -} + + + + + + + + + }> + } /> + } /> + } /> + } /> + Page not found} /> + + + + + + + + + + ); +}; -export default App +export default App; diff --git a/websites/app/src/assets/svgs/chains/arbitrum.svg b/websites/app/src/assets/svgs/chains/arbitrum.svg new file mode 100644 index 0000000..5cbd7a5 --- /dev/null +++ b/websites/app/src/assets/svgs/chains/arbitrum.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/chains/avalanche.svg b/websites/app/src/assets/svgs/chains/avalanche.svg new file mode 100644 index 0000000..a7db9ad --- /dev/null +++ b/websites/app/src/assets/svgs/chains/avalanche.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/chains/base.svg b/websites/app/src/assets/svgs/chains/base.svg new file mode 100644 index 0000000..61a97fd --- /dev/null +++ b/websites/app/src/assets/svgs/chains/base.svg @@ -0,0 +1,275 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/chains/bnb.svg b/websites/app/src/assets/svgs/chains/bnb.svg new file mode 100644 index 0000000..98fcae5 --- /dev/null +++ b/websites/app/src/assets/svgs/chains/bnb.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/websites/app/src/assets/svgs/chains/celo.svg b/websites/app/src/assets/svgs/chains/celo.svg new file mode 100644 index 0000000..c140373 --- /dev/null +++ b/websites/app/src/assets/svgs/chains/celo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/chains/ethereum.svg b/websites/app/src/assets/svgs/chains/ethereum.svg new file mode 100644 index 0000000..84b5b22 --- /dev/null +++ b/websites/app/src/assets/svgs/chains/ethereum.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/websites/app/src/assets/svgs/chains/fantom.svg b/websites/app/src/assets/svgs/chains/fantom.svg new file mode 100644 index 0000000..2b353b8 --- /dev/null +++ b/websites/app/src/assets/svgs/chains/fantom.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/chains/gnosis.svg b/websites/app/src/assets/svgs/chains/gnosis.svg new file mode 100644 index 0000000..6f72955 --- /dev/null +++ b/websites/app/src/assets/svgs/chains/gnosis.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/chains/optimism.svg b/websites/app/src/assets/svgs/chains/optimism.svg new file mode 100644 index 0000000..f29183d --- /dev/null +++ b/websites/app/src/assets/svgs/chains/optimism.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/chains/polygon.svg b/websites/app/src/assets/svgs/chains/polygon.svg new file mode 100644 index 0000000..8fe17d4 --- /dev/null +++ b/websites/app/src/assets/svgs/chains/polygon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/chains/scroll.svg b/websites/app/src/assets/svgs/chains/scroll.svg new file mode 100644 index 0000000..0b212de --- /dev/null +++ b/websites/app/src/assets/svgs/chains/scroll.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/chains/solana.svg b/websites/app/src/assets/svgs/chains/solana.svg new file mode 100644 index 0000000..1b17e41 --- /dev/null +++ b/websites/app/src/assets/svgs/chains/solana.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/chains/zksync.svg b/websites/app/src/assets/svgs/chains/zksync.svg new file mode 100644 index 0000000..0c4e7d1 --- /dev/null +++ b/websites/app/src/assets/svgs/chains/zksync.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/footer/secured-by-kleros.svg b/websites/app/src/assets/svgs/footer/secured-by-kleros.svg new file mode 100644 index 0000000..cceaf5c --- /dev/null +++ b/websites/app/src/assets/svgs/footer/secured-by-kleros.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/header/hamburger.svg b/websites/app/src/assets/svgs/header/hamburger.svg new file mode 100644 index 0000000..c02f5de --- /dev/null +++ b/websites/app/src/assets/svgs/header/hamburger.svg @@ -0,0 +1,3 @@ + + + diff --git a/websites/app/src/assets/svgs/header/hero-shadow.svg b/websites/app/src/assets/svgs/header/hero-shadow.svg new file mode 100644 index 0000000..5766ab5 --- /dev/null +++ b/websites/app/src/assets/svgs/header/hero-shadow.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/websites/app/src/assets/svgs/header/kleros-scout.svg b/websites/app/src/assets/svgs/header/kleros-scout.svg new file mode 100644 index 0000000..9dd1f99 --- /dev/null +++ b/websites/app/src/assets/svgs/header/kleros-scout.svg @@ -0,0 +1,3 @@ + + + diff --git a/websites/app/src/assets/svgs/icons/active-rewards.svg b/websites/app/src/assets/svgs/icons/active-rewards.svg new file mode 100644 index 0000000..19f12f0 --- /dev/null +++ b/websites/app/src/assets/svgs/icons/active-rewards.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/icons/activity.svg b/websites/app/src/assets/svgs/icons/activity.svg new file mode 100644 index 0000000..3fd1a6f --- /dev/null +++ b/websites/app/src/assets/svgs/icons/activity.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/icons/arrow-down.svg b/websites/app/src/assets/svgs/icons/arrow-down.svg new file mode 100644 index 0000000..2de8525 --- /dev/null +++ b/websites/app/src/assets/svgs/icons/arrow-down.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/icons/arrow.svg b/websites/app/src/assets/svgs/icons/arrow.svg new file mode 100644 index 0000000..ce9ad36 --- /dev/null +++ b/websites/app/src/assets/svgs/icons/arrow.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/icons/assets-verified.svg b/websites/app/src/assets/svgs/icons/assets-verified.svg new file mode 100644 index 0000000..4c309f7 --- /dev/null +++ b/websites/app/src/assets/svgs/icons/assets-verified.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/icons/book-circle.svg b/websites/app/src/assets/svgs/icons/book-circle.svg new file mode 100644 index 0000000..e8ac2d4 --- /dev/null +++ b/websites/app/src/assets/svgs/icons/book-circle.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/icons/book.svg b/websites/app/src/assets/svgs/icons/book.svg new file mode 100644 index 0000000..a66d9df --- /dev/null +++ b/websites/app/src/assets/svgs/icons/book.svg @@ -0,0 +1,3 @@ + + + diff --git a/websites/app/src/assets/svgs/icons/bounties.svg b/websites/app/src/assets/svgs/icons/bounties.svg new file mode 100644 index 0000000..eef7afb --- /dev/null +++ b/websites/app/src/assets/svgs/icons/bounties.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/icons/bug.svg b/websites/app/src/assets/svgs/icons/bug.svg new file mode 100644 index 0000000..02608f6 --- /dev/null +++ b/websites/app/src/assets/svgs/icons/bug.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/icons/calendar.svg b/websites/app/src/assets/svgs/icons/calendar.svg new file mode 100644 index 0000000..38d34b3 --- /dev/null +++ b/websites/app/src/assets/svgs/icons/calendar.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/icons/chat.svg b/websites/app/src/assets/svgs/icons/chat.svg new file mode 100644 index 0000000..65b1de9 --- /dev/null +++ b/websites/app/src/assets/svgs/icons/chat.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/icons/coins.svg b/websites/app/src/assets/svgs/icons/coins.svg new file mode 100644 index 0000000..2b738a0 --- /dev/null +++ b/websites/app/src/assets/svgs/icons/coins.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/icons/curate-image.png b/websites/app/src/assets/svgs/icons/curate-image.png new file mode 100644 index 0000000..9a61ecf Binary files /dev/null and b/websites/app/src/assets/svgs/icons/curate-image.png differ diff --git a/websites/app/src/assets/svgs/icons/curators.svg b/websites/app/src/assets/svgs/icons/curators.svg new file mode 100644 index 0000000..22505d6 --- /dev/null +++ b/websites/app/src/assets/svgs/icons/curators.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/icons/dispute-resolver.svg b/websites/app/src/assets/svgs/icons/dispute-resolver.svg new file mode 100644 index 0000000..8c3d668 --- /dev/null +++ b/websites/app/src/assets/svgs/icons/dispute-resolver.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/icons/documentation.svg b/websites/app/src/assets/svgs/icons/documentation.svg new file mode 100644 index 0000000..b96b823 --- /dev/null +++ b/websites/app/src/assets/svgs/icons/documentation.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/icons/escrow.svg b/websites/app/src/assets/svgs/icons/escrow.svg new file mode 100644 index 0000000..3da89c3 --- /dev/null +++ b/websites/app/src/assets/svgs/icons/escrow.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/websites/app/src/assets/svgs/icons/eth.svg b/websites/app/src/assets/svgs/icons/eth.svg new file mode 100644 index 0000000..28595a8 --- /dev/null +++ b/websites/app/src/assets/svgs/icons/eth.svg @@ -0,0 +1,3 @@ + + + diff --git a/websites/app/src/assets/svgs/icons/export.svg b/websites/app/src/assets/svgs/icons/export.svg new file mode 100644 index 0000000..1db0093 --- /dev/null +++ b/websites/app/src/assets/svgs/icons/export.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/websites/app/src/assets/svgs/icons/filters.svg b/websites/app/src/assets/svgs/icons/filters.svg new file mode 100644 index 0000000..2b94493 --- /dev/null +++ b/websites/app/src/assets/svgs/icons/filters.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/icons/governor.svg b/websites/app/src/assets/svgs/icons/governor.svg new file mode 100644 index 0000000..d067c43 --- /dev/null +++ b/websites/app/src/assets/svgs/icons/governor.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/websites/app/src/assets/svgs/icons/hourglass.svg b/websites/app/src/assets/svgs/icons/hourglass.svg new file mode 100644 index 0000000..5b9c0f7 --- /dev/null +++ b/websites/app/src/assets/svgs/icons/hourglass.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/icons/info-circle.svg b/websites/app/src/assets/svgs/icons/info-circle.svg new file mode 100644 index 0000000..bcb80ff --- /dev/null +++ b/websites/app/src/assets/svgs/icons/info-circle.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/icons/poh-image.png b/websites/app/src/assets/svgs/icons/poh-image.png new file mode 100644 index 0000000..3f7e06a Binary files /dev/null and b/websites/app/src/assets/svgs/icons/poh-image.png differ diff --git a/websites/app/src/assets/svgs/icons/rewards.svg b/websites/app/src/assets/svgs/icons/rewards.svg new file mode 100644 index 0000000..96ea81b --- /dev/null +++ b/websites/app/src/assets/svgs/icons/rewards.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/icons/search.svg b/websites/app/src/assets/svgs/icons/search.svg index 933bc1c..7f9cdc8 100644 --- a/websites/app/src/assets/svgs/icons/search.svg +++ b/websites/app/src/assets/svgs/icons/search.svg @@ -1,3 +1,10 @@ - - + + + + + + + + + diff --git a/websites/app/src/assets/svgs/icons/sort.svg b/websites/app/src/assets/svgs/icons/sort.svg new file mode 100644 index 0000000..e01dcc5 --- /dev/null +++ b/websites/app/src/assets/svgs/icons/sort.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/icons/submissions.svg b/websites/app/src/assets/svgs/icons/submissions.svg new file mode 100644 index 0000000..bc6fc7d --- /dev/null +++ b/websites/app/src/assets/svgs/icons/submissions.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/icons/vea.svg b/websites/app/src/assets/svgs/icons/vea.svg new file mode 100644 index 0000000..1fa6470 --- /dev/null +++ b/websites/app/src/assets/svgs/icons/vea.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/websites/app/src/assets/svgs/icons/warning-outline.svg b/websites/app/src/assets/svgs/icons/warning-outline.svg new file mode 100644 index 0000000..fc09d8c --- /dev/null +++ b/websites/app/src/assets/svgs/icons/warning-outline.svg @@ -0,0 +1,15 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/menu-icons/dark-mode.svg b/websites/app/src/assets/svgs/menu-icons/dark-mode.svg new file mode 100644 index 0000000..ca71068 --- /dev/null +++ b/websites/app/src/assets/svgs/menu-icons/dark-mode.svg @@ -0,0 +1,3 @@ + + + diff --git a/websites/app/src/assets/svgs/menu-icons/help.svg b/websites/app/src/assets/svgs/menu-icons/help.svg new file mode 100644 index 0000000..1c89e1f --- /dev/null +++ b/websites/app/src/assets/svgs/menu-icons/help.svg @@ -0,0 +1,3 @@ + + + diff --git a/websites/app/src/assets/svgs/menu-icons/kleros-solutions.svg b/websites/app/src/assets/svgs/menu-icons/kleros-solutions.svg new file mode 100644 index 0000000..0e5cdf7 --- /dev/null +++ b/websites/app/src/assets/svgs/menu-icons/kleros-solutions.svg @@ -0,0 +1,3 @@ + + + diff --git a/websites/app/src/assets/svgs/menu-icons/light-mode.svg b/websites/app/src/assets/svgs/menu-icons/light-mode.svg new file mode 100644 index 0000000..e514086 --- /dev/null +++ b/websites/app/src/assets/svgs/menu-icons/light-mode.svg @@ -0,0 +1,3 @@ + + + diff --git a/websites/app/src/assets/svgs/menu-icons/notifications.svg b/websites/app/src/assets/svgs/menu-icons/notifications.svg new file mode 100644 index 0000000..c13406c --- /dev/null +++ b/websites/app/src/assets/svgs/menu-icons/notifications.svg @@ -0,0 +1,3 @@ + + + diff --git a/websites/app/src/assets/svgs/menu-icons/settings.svg b/websites/app/src/assets/svgs/menu-icons/settings.svg new file mode 100644 index 0000000..69e5d81 --- /dev/null +++ b/websites/app/src/assets/svgs/menu-icons/settings.svg @@ -0,0 +1,3 @@ + + + diff --git a/websites/app/src/assets/svgs/sidebar/activity.svg b/websites/app/src/assets/svgs/sidebar/activity.svg new file mode 100644 index 0000000..cc17416 --- /dev/null +++ b/websites/app/src/assets/svgs/sidebar/activity.svg @@ -0,0 +1,3 @@ + + + diff --git a/websites/app/src/assets/svgs/sidebar/arrow.svg b/websites/app/src/assets/svgs/sidebar/arrow.svg new file mode 100644 index 0000000..270e8cd --- /dev/null +++ b/websites/app/src/assets/svgs/sidebar/arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/websites/app/src/assets/svgs/sidebar/book.svg b/websites/app/src/assets/svgs/sidebar/book.svg new file mode 100644 index 0000000..d9fa11b --- /dev/null +++ b/websites/app/src/assets/svgs/sidebar/book.svg @@ -0,0 +1,3 @@ + + + diff --git a/websites/app/src/assets/svgs/sidebar/home.svg b/websites/app/src/assets/svgs/sidebar/home.svg new file mode 100644 index 0000000..9ec1fbc --- /dev/null +++ b/websites/app/src/assets/svgs/sidebar/home.svg @@ -0,0 +1,3 @@ + + + diff --git a/websites/app/src/assets/svgs/sidebar/pnk.svg b/websites/app/src/assets/svgs/sidebar/pnk.svg new file mode 100644 index 0000000..a38c96d --- /dev/null +++ b/websites/app/src/assets/svgs/sidebar/pnk.svg @@ -0,0 +1,3 @@ + + + diff --git a/websites/app/src/assets/svgs/sidebar/rewards.svg b/websites/app/src/assets/svgs/sidebar/rewards.svg new file mode 100644 index 0000000..29c9123 --- /dev/null +++ b/websites/app/src/assets/svgs/sidebar/rewards.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/socialmedia/discord.svg b/websites/app/src/assets/svgs/socialmedia/discord.svg new file mode 100644 index 0000000..4bc7790 --- /dev/null +++ b/websites/app/src/assets/svgs/socialmedia/discord.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/socialmedia/etherscan.svg b/websites/app/src/assets/svgs/socialmedia/etherscan.svg new file mode 100644 index 0000000..56aea03 --- /dev/null +++ b/websites/app/src/assets/svgs/socialmedia/etherscan.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/socialmedia/ghost-blog.svg b/websites/app/src/assets/svgs/socialmedia/ghost-blog.svg new file mode 100644 index 0000000..cbd9a81 --- /dev/null +++ b/websites/app/src/assets/svgs/socialmedia/ghost-blog.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/socialmedia/github.svg b/websites/app/src/assets/svgs/socialmedia/github.svg new file mode 100644 index 0000000..96a4959 --- /dev/null +++ b/websites/app/src/assets/svgs/socialmedia/github.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/socialmedia/linkedin.svg b/websites/app/src/assets/svgs/socialmedia/linkedin.svg new file mode 100644 index 0000000..5e82fa6 --- /dev/null +++ b/websites/app/src/assets/svgs/socialmedia/linkedin.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/socialmedia/reddit.svg b/websites/app/src/assets/svgs/socialmedia/reddit.svg new file mode 100644 index 0000000..29638ab --- /dev/null +++ b/websites/app/src/assets/svgs/socialmedia/reddit.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/socialmedia/slack.svg b/websites/app/src/assets/svgs/socialmedia/slack.svg new file mode 100644 index 0000000..25551ed --- /dev/null +++ b/websites/app/src/assets/svgs/socialmedia/slack.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/socialmedia/snapshot.svg b/websites/app/src/assets/svgs/socialmedia/snapshot.svg new file mode 100644 index 0000000..fa69e36 --- /dev/null +++ b/websites/app/src/assets/svgs/socialmedia/snapshot.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/socialmedia/telegram.svg b/websites/app/src/assets/svgs/socialmedia/telegram.svg new file mode 100644 index 0000000..73c7f50 --- /dev/null +++ b/websites/app/src/assets/svgs/socialmedia/telegram.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/assets/svgs/socialmedia/x.svg b/websites/app/src/assets/svgs/socialmedia/x.svg new file mode 100644 index 0000000..0643bf9 --- /dev/null +++ b/websites/app/src/assets/svgs/socialmedia/x.svg @@ -0,0 +1,3 @@ + + + diff --git a/websites/app/src/assets/svgs/socialmedia/youtube.svg b/websites/app/src/assets/svgs/socialmedia/youtube.svg new file mode 100644 index 0000000..f2635fe --- /dev/null +++ b/websites/app/src/assets/svgs/socialmedia/youtube.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/websites/app/src/components/AddressDisplay.tsx b/websites/app/src/components/AddressDisplay.tsx index dd508cf..91ce9a9 100644 --- a/websites/app/src/components/AddressDisplay.tsx +++ b/websites/app/src/components/AddressDisplay.tsx @@ -2,24 +2,97 @@ import React from 'react' import styled from 'styled-components' import { chains } from 'utils/chains' import { chainColorMap } from 'utils/colorMappings' +import { ExternalLink } from './ExternalLink' +import { hoverShortTransitionTiming } from 'styles/commonStyles' +import NewTabIcon from 'svgs/icons/new-tab.svg' -const Container = styled.div`` +import ArbitrumIcon from 'svgs/chains/arbitrum.svg' +import AvalancheIcon from 'svgs/chains/avalanche.svg' +import BaseIcon from 'svgs/chains/base.svg' +import BnbIcon from 'svgs/chains/bnb.svg' +import CeloIcon from 'svgs/chains/celo.svg' +import EthereumIcon from 'svgs/chains/ethereum.svg' +import FantomIcon from 'svgs/chains/fantom.svg' +import GnosisIcon from 'svgs/chains/gnosis.svg' +import OptimismIcon from 'svgs/chains/optimism.svg' +import PolygonIcon from 'svgs/chains/polygon.svg' +import ScrollIcon from 'svgs/chains/scroll.svg' +import SolanaIcon from 'svgs/chains/solana.svg' +import ZkSyncIcon from 'svgs/chains/zksync.svg' + +const Container = styled.div` + display: flex; + flex-direction: row; + align-items: center; + margin-bottom: 8px; +` + +const IconWrapper = styled.div` + width: 20px; + height: 20px; + margin-right: 8px; + display: flex; + align-items: center; + justify-content: center; + + & > svg { + width: 100%; + height: 100%; + } +` const StyledSpan = styled.span<{ bgColor: string }>` - padding: 1px 4px; + padding: 4px 8px; color: white; - border-radius: 4px; + border-radius: 40px; + font-size: 16px; background-color: ${(props) => props.bgColor}; - margin-right: 4px; + margin-right: 8px; ` -const StyledAddressA = styled.a` - text-decoration: underline; - color: #fff; +const StyledExternalLink = styled(ExternalLink)` + ${hoverShortTransitionTiming} + color: ${({ theme }) => theme.secondaryPurple}; + display: flex; + align-items: center; + flex-direction: row; + gap: 4px; + + svg { + fill: ${({ theme }) => theme.secondaryPurple}; + } + + :hover { + color: ${({ theme }) => theme.tintPurple}; + text-decoration: none; + + svg { + fill: ${({ theme }) => theme.tintPurple}; + } + } +` + +const StyledNewTabIcon = styled(NewTabIcon)` + margin-bottom: 2px; ` -const truncateAddress = (addr: string) => { - return `${addr?.substring(0, 6)}...${addr?.substring(addr.length - 4)}` +const truncateAddress = (addr: string) => `${addr?.substring(0, 6)}...${addr?.substring(addr.length - 4)}` + +const chainIconMap: Record> = { + '42161': ArbitrumIcon, + '43114': AvalancheIcon, + '8453': BaseIcon, + '56': BnbIcon, + '42220': CeloIcon, + '1': EthereumIcon, + '250': FantomIcon, + '100': GnosisIcon, + '10': OptimismIcon, + '137': PolygonIcon, + '534352': ScrollIcon, + '4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ': SolanaIcon, + '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': SolanaIcon, + '324': ZkSyncIcon, } interface IAddressDisplay { @@ -29,21 +102,27 @@ interface IAddressDisplay { const AddressDisplay: React.FC = ({ address }) => { const parts = address?.split(':') const keyForReference = `${parts?.[0]}:${parts?.[1]}` - const reference = chains.find( - (ref) => `${ref.namespace}:${ref.id}` === keyForReference - ) + const reference = chains.find((ref) => `${ref.namespace}:${ref.id}` === keyForReference) const bgColor = chainColorMap[keyForReference] || '#a0aec0' + const ChainIcon = chainIconMap[reference?.id ?? ''] return ( - {reference?.label} + {ChainIcon ? ( + + + + ) : ( + {reference?.label} + )} {parts?.[2] && ( - - {truncateAddress(parts?.[2])} - + {truncateAddress(parts?.[2])} + )} ) diff --git a/websites/app/src/components/AttachmentDisplay/Header.tsx b/websites/app/src/components/AttachmentDisplay/Header.tsx index 17005e5..86d7bc2 100644 --- a/websites/app/src/components/AttachmentDisplay/Header.tsx +++ b/websites/app/src/components/AttachmentDisplay/Header.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useCallback } from "react"; import styled from "styled-components"; import { useNavigate } from "react-router-dom"; @@ -53,9 +53,9 @@ const StyledButton = styled.button` const Header: React.FC = () => { const navigate = useNavigate(); - const handleReturn = () => { + const handleReturn = useCallback(() => { navigate(-1); - }; + }, [navigate]); return ( diff --git a/websites/app/src/components/AttachmentDisplay/index.tsx b/websites/app/src/components/AttachmentDisplay/index.tsx index 049e8db..b9fcb02 100644 --- a/websites/app/src/components/AttachmentDisplay/index.tsx +++ b/websites/app/src/components/AttachmentDisplay/index.tsx @@ -1,6 +1,8 @@ import React, { lazy, Suspense } from "react"; import styled from "styled-components"; +import { MAX_WIDTH_LANDSCAPE } from "styles/landscapeStyle"; + import { useSearchParams } from "react-router-dom"; import NewTabIcon from "svgs/icons/new-tab.svg"; @@ -12,11 +14,11 @@ import Header from "./Header"; const FileViewer = lazy(() => import("components/FileViewer")); const Container = styled.div` - width: 90%; + width: 100%; display: flex; flex-direction: column; gap: 8px; - max-width: 1110px; + max-width: ${MAX_WIDTH_LANDSCAPE}; `; const LoaderContainer = styled.div` diff --git a/websites/app/src/components/Button.tsx b/websites/app/src/components/Button.tsx index ce31ae3..35eedb9 100644 --- a/websites/app/src/components/Button.tsx +++ b/websites/app/src/components/Button.tsx @@ -9,7 +9,7 @@ const Button = styled.button` font-weight: bold; padding: 8px ${responsiveSize(8, 20)}; border: none; - border-radius: 12px; + border-radius: 9999px; cursor: pointer; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); diff --git a/websites/app/src/components/ConfirmationBox.tsx b/websites/app/src/components/ConfirmationBox.tsx new file mode 100644 index 0000000..f44a59b --- /dev/null +++ b/websites/app/src/components/ConfirmationBox.tsx @@ -0,0 +1,234 @@ +import React, { useState } from 'react' +import styled, { css } from 'styled-components' +import { landscapeStyle } from 'styles/landscapeStyle' +import { responsiveSize } from 'styles/responsiveSize' +import { DepositParams } from 'utils/fetchRegistryDeposits' +import { SubmitButton } from 'pages/Registries/SubmitEntries/AddEntryModal' +import { StyledCloseButton, ClosedButtonContainer } from 'pages/Registries' +import { GraphItemDetails } from 'utils/itemDetails' +import { useCurateInteractions } from 'hooks/contracts/useCurateInteractions' +import { EnsureChain } from 'components/EnsureChain' +import ipfsPublish from 'utils/ipfsPublish' +import { getIPFSPath } from 'utils/getIPFSPath' +import { Address } from 'viem' + +const ModalOverlay = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 50; +` + +const Container = styled.div` + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 84vw; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.08) 0%, + rgba(153, 153, 153, 0.08) 100% + ); + border: 1px solid ${({ theme }) => theme.lightGrey}; + border-radius: 12px; + color: ${({ theme }) => theme.primaryText}; + display: flex; + flex-direction: column; + backdrop-filter: blur(50px); + box-shadow: 0px 1px 2px 0px rgba(0, 0, 0, 0.05); + + ${landscapeStyle( + () => css` + width: 50%; + ` + )} +` + +const InnerContainer = styled.div` + display: flex; + position: relative; + flex-direction: column; + padding: ${responsiveSize(16, 24)}; + gap: 16px; +` + +const ConfirmationTitle = styled.h3` + display: flex; + flex-direction: row; + justify-content: space-between; + margin-top: 0; + gap: 24px; +` + +const TextArea = styled.textarea` + width: 93%; + padding: 12px; + border: 1px solid ${({ theme }) => theme.lightGrey}; + outline: none; + overflow: auto; + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + color: ${({ theme }) => theme.primaryText}; + font-family: 'Inter', sans-serif; + font-size: 14px; + resize: vertical; + + &:focus { + border-color: ${({ theme }) => theme.primaryText}; + box-shadow: 0 0 0 2px rgba(205, 157, 255, 0.2); + } + + &::placeholder { + color: ${({ theme }) => theme.secondaryText}; + } + + ${landscapeStyle( + () => css` + width: 97%; + ` + )} +` + +interface IConfirmationBox { + evidenceConfirmationType: string; + isConfirmationOpen: boolean; + setIsConfirmationOpen: (isOpen: boolean) => void; + detailsData: GraphItemDetails; + deposits: DepositParams | undefined; + arbitrationCostData: bigint | undefined; +} + +const ConfirmationBox: React.FC = ({ + evidenceConfirmationType, + isConfirmationOpen, + setIsConfirmationOpen, + detailsData, + deposits, + arbitrationCostData, +}) => { + const [evidenceTitle, setEvidenceTitle] = useState('') + const [evidenceText, setEvidenceText] = useState('') + const { submitEvidence, challengeRequest, removeItem } = useCurateInteractions() + + return ( + + + + +
+ {(() => { + switch (evidenceConfirmationType) { + case 'Evidence': + return 'Enter the evidence message you want to submit' + case 'RegistrationRequested': + return 'Provide a reason for challenging this entry' + case 'Registered': + return 'Provide a reason for removing this entry' + case 'ClearingRequested': + return 'Provide a reason for challenging this removal request' + default: + return 'Default message' + } + })()} +
+ setIsConfirmationOpen(false)}> + + + + + + + + + { + try { + const evidenceObject = { + title: evidenceTitle, + description: evidenceText, + } + const enc = new TextEncoder() + const fileData = enc.encode(JSON.stringify(evidenceObject)) + const ipfsObject = await ipfsPublish('evidence.json', fileData) + const ipfsPath = getIPFSPath(ipfsObject) + + let result = false + const registryAddress = detailsData.registryAddress as Address + const itemId = detailsData.itemID + const arbitrationCost = arbitrationCostData as bigint + + switch (evidenceConfirmationType) { + case 'Evidence': + await submitEvidence(registryAddress, itemId, ipfsPath) + result = true + break + case 'RegistrationRequested': + if (deposits?.submissionChallengeBaseDeposit) { + await challengeRequest( + registryAddress, + itemId, + ipfsPath, + BigInt(deposits.submissionChallengeBaseDeposit), + arbitrationCost + ) + result = true + } + break + case 'Registered': + if (deposits?.removalBaseDeposit) { + await removeItem( + registryAddress, + itemId, + ipfsPath, + deposits, + arbitrationCost + ) + result = true + } + break + case 'ClearingRequested': + if (deposits?.removalChallengeBaseDeposit) { + await challengeRequest( + registryAddress, + itemId, + ipfsPath, + BigInt(deposits.removalChallengeBaseDeposit), + arbitrationCost + ) + result = true + } + break + } + + if (result) { + setIsConfirmationOpen(false) + } + } catch (error) { + console.error('Error performing action:', error) + } + }} + > + Confirm + + + + + + ) +} +export default ConfirmationBox diff --git a/websites/app/src/components/ConnectWallet/AccountDisplay.tsx b/websites/app/src/components/ConnectWallet/AccountDisplay.tsx new file mode 100644 index 0000000..86ec9e9 --- /dev/null +++ b/websites/app/src/components/ConnectWallet/AccountDisplay.tsx @@ -0,0 +1,182 @@ +import React from "react"; +import styled, { css } from "styled-components"; + +import Identicon from "react-identicons"; +import { isAddress } from "viem"; +import { normalize } from "viem/ens"; +import { useAccount, useChainId, useEnsAvatar, useEnsName } from "wagmi"; + +import { getChain } from "consts/chains"; +import { shortenAddress } from "utils/shortenAddress"; + +import { landscapeStyle } from "styles/landscapeStyle"; + +const Container = styled.div` + display: flex; + flex-direction: column; + justify-content: space-between; + height: auto; + gap: 8px; + background-color: ${({ theme }) => theme.whiteBackground}; + padding: 0px; + cursor: pointer; + + &:hover { + label { + color: ${({ theme }) => theme.white} !important; + transition: color 0.2s; + } + } + + ${landscapeStyle( + () => css` + background-color: ${({ theme }) => theme.whiteLowOpacitySubtle}; + &:hover { + transition: background-color 0.1s; + background-color: ${({ theme }) => theme.whiteLowOpacityStrong}; + } + flex-direction: row; + align-content: center; + border-radius: 300px; + gap: 0px; + padding: 0 12px; + ` + )} +`; + +const AccountContainer = styled.div` + min-height: 32px; + display: flex; + align-items: center; + width: fit-content; + gap: 8px; + + > label { + font-size: 16px; + font-weight: 600; + } + + ${landscapeStyle( + () => css` + gap: 12px; + > label { + color: ${({ theme }) => theme.white}CC !important; + font-weight: 400; + font-size: 14px; + } + ` + )} +`; + +const ChainConnectionContainer = styled.div` + display: flex; + width: fit-content; + min-height: 32px; + align-items: center; + padding-left: 0px; + > label { + color: ${({ theme }) => theme.success}; + font-size: 16px; + + font-weight: 500; + } + + :before { + content: ""; + width: 8px; + height: 8px; + margin: 0px 13px 0px 3px; + border-radius: 50%; + background-color: ${({ theme }) => theme.success}; + } + + ${landscapeStyle( + () => css` + display: none; + ` + )} +`; + +const StyledIdenticon = styled(Identicon)<{ size: `${number}` }>` + align-items: center; + width: ${({ size }) => size + "px"} !important; + height: ${({ size }) => size + "px"} !important; +`; + +const StyledAvatar = styled.img<{ size: `${number}` }>` + align-items: center; + object-fit: cover; + border-radius: 50%; + width: ${({ size }) => size + "px"}; + height: ${({ size }) => size + "px"}; +`; + +const StyledSmallLabel = styled.label` + font-size: 14px !important; +`; + +interface IIdenticonOrAvatar { + size?: `${number}`; + address?: `0x${string}`; +} + +export const IdenticonOrAvatar: React.FC = ({ size = "20", address: propAddress }) => { + const { address: defaultAddress } = useAccount(); + const address = propAddress || defaultAddress; + + const { data: name } = useEnsName({ + address, + chainId: 1, + }); + const { data: avatar } = useEnsAvatar({ + name: normalize(name ?? ""), + chainId: 1, + }); + + return avatar ? ( + + ) : ( + + ); +}; + +interface IAddressOrName { + address?: `0x${string}`; + smallDisplay?: boolean; +} + +export const AddressOrName: React.FC = ({ address: propAddress, smallDisplay }) => { + const { address: defaultAddress } = useAccount(); + const address = propAddress || defaultAddress; + + const { data } = useEnsName({ + address, + chainId: 1, + }); + + const content = data ?? (isAddress(address!) ? shortenAddress(address) : address); + + return smallDisplay ? {content} : ; +}; + +export const ChainDisplay: React.FC = () => { + const chainId = useChainId(); + const chain = getChain(chainId); + return ; +}; + +const AccountDisplay: React.FC = () => { + return ( + + + + + + + + + + ); +}; + +export default AccountDisplay; diff --git a/websites/app/src/components/ConnectWallet/index.tsx b/websites/app/src/components/ConnectWallet/index.tsx new file mode 100644 index 0000000..9ee45d6 --- /dev/null +++ b/websites/app/src/components/ConnectWallet/index.tsx @@ -0,0 +1,61 @@ +import React, { useCallback } from "react"; + +import { useAppKit, useAppKitState } from "@reown/appkit/react"; +import { useAccount, useSwitchChain } from "wagmi"; + +import { Button } from "@kleros/ui-components-library"; + +import { SUPPORTED_CHAINS, DEFAULT_CHAIN } from "consts/chains"; + +import AccountDisplay from "./AccountDisplay"; + +export const SwitchChainButton: React.FC<{ className?: string }> = ({ className }) => { + // TODO isLoading is not documented, but exists in the type, might have changed to isPending + const { switchChain, isLoading } = useSwitchChain(); + const handleSwitch = useCallback(() => { + if (!switchChain) { + console.error("Cannot switch network. Please do it manually."); + return; + } + try { + switchChain({ chainId: DEFAULT_CHAIN }); + } catch (err) { + console.error(err); + } + }, [switchChain]); + return ( + + ); +}; + +export default FilterButton; \ No newline at end of file diff --git a/websites/app/src/components/FilterModal.tsx b/websites/app/src/components/FilterModal.tsx new file mode 100644 index 0000000..44734ea --- /dev/null +++ b/websites/app/src/components/FilterModal.tsx @@ -0,0 +1,737 @@ +import React, { useCallback, useMemo, useRef } from 'react'; +import styled from 'styled-components'; +import { useSearchParams } from 'react-router-dom'; +import { chains } from 'utils/chains'; +import { useFocusOutside } from 'hooks/useFocusOutside'; +import FiltersIcon from 'svgs/icons/filters.svg'; +import SortIcon from 'svgs/icons/sort.svg'; + +import EthereumIcon from 'svgs/chains/ethereum.svg'; +import SolanaIcon from 'svgs/chains/solana.svg'; +import BaseIcon from 'svgs/chains/base.svg'; +import CeloIcon from 'svgs/chains/celo.svg'; +import ScrollIcon from 'svgs/chains/scroll.svg'; +import FantomIcon from 'svgs/chains/fantom.svg'; +import ZkSyncIcon from 'svgs/chains/zksync.svg'; +import GnosisIcon from 'svgs/chains/gnosis.svg'; +import PolygonIcon from 'svgs/chains/polygon.svg'; +import OptimismIcon from 'svgs/chains/optimism.svg'; +import ArbitrumIcon from 'svgs/chains/arbitrum.svg'; +import AvalancheIcon from 'svgs/chains/avalanche.svg'; +import BnbIcon from 'svgs/chains/bnb.svg'; + +const ModalOverlay = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 50; +`; + +const ModalWrapper = styled.div` + position: relative; + width: 90vw; + max-width: 800px; + max-height: 90vh; + border-radius: 20px; + + &:before { + content: ''; + position: absolute; + inset: 0; + padding: 1px; + border-radius: 20px; + background: linear-gradient(180deg, #7186FF90 0%, #BEBEC590 100%); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; + } +`; + +const ModalContainer = styled.div` + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.08) 0%, + rgba(153, 153, 153, 0.08) 100% + ); + backdrop-filter: blur(50px); + border-radius: 20px; + border: 1px solid rgba(113, 134, 255, 0.3); + width: 100%; + height: 100%; + max-height: 90vh; + overflow-y: auto; + padding: 32px; + display: flex; + flex-direction: column; + gap: 24px; + position: relative; + box-shadow: 0px 8px 32px rgba(0, 0, 0, 0.4); +`; + +const ModalHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid rgba(113, 134, 255, 0.3); + padding-bottom: 20px; +`; + +const ModalTitle = styled.h2` + color: ${({ theme }) => theme.primaryText}; + font-size: 20px; + font-weight: 600; + margin: 0; +`; + +const CloseButton = styled.button` + background: none; + border: none; + color: ${({ theme }) => theme.secondaryText}; + font-size: 24px; + cursor: pointer; + padding: 4px; + transition: color 0.2s; + + &:hover { + color: ${({ theme }) => theme.primaryText}; + } +`; + +const FilterSection = styled.div` + display: flex; + flex-direction: column; + gap: 16px; +`; + +const SectionTitle = styled.h3` + color: ${({ theme }) => theme.primaryText}; + font-size: 16px; + font-weight: 600; + margin: 0; + padding-bottom: 4px; + display: flex; + align-items: center; + gap: 8px; + + svg { + width: 16px; + height: 16px; + fill: ${({ theme }) => theme.primaryText}; + } +`; + +const SectionGrid = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +`; + +const FilterColumn = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const FilterGroup = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const FilterGroupTitle = styled.h4` + color: ${({ theme }) => theme.accent}; + font-size: 14px; + font-weight: 600; + margin: 0; + padding-bottom: 8px; + display: flex; + align-items: center; + gap: 8px; + + svg { + width: 14px; + height: 14px; + fill: ${({ theme }) => theme.accent}; + } +`; + +const CheckboxGroup = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const GroupHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +`; + +const ActionButton = styled.button` + background: none; + border: none; + color: ${({ theme }) => theme.accent}; + font-size: 12px; + font-weight: 500; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + transition: all 0.2s; + opacity: 0.7; + + &:hover { + background: ${({ theme }) => theme.lightGrey}; + opacity: 1; + } +`; + +const CheckboxItem = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + color: ${({ theme }) => theme.primaryText}; + font-size: 14px; + padding: 4px 0; + transition: color 0.2s; + + &:hover { + color: ${({ theme }) => theme.accent}; + + .only-button { + opacity: 1; + } + } +`; + +const CheckboxLabel = styled.label` + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + flex: 1; +`; + +const OnlyButton = styled.button` + background: none; + border: none; + color: ${({ theme }) => theme.accent}; + font-size: 11px; + font-weight: 500; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + transition: all 0.2s; + opacity: 0; + + &:hover { + background: ${({ theme }) => theme.lightGrey}; + } +`; + +const StatusCircle = styled.div<{ status: string }>` + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; + background: ${({ status }) => { + switch (status) { + case 'Registered': return '#22c55e'; // green for included + case 'RegistrationRequested': return '#eab308'; // yellow for registration requested + case 'ClearingRequested': return '#f97316'; // orange for removal requested + case 'Absent': return '#ef4444'; // red for removed + case 'true': return '#ef4444'; // red for challenged + case 'false': return '#22c55e'; // green for unchallenged + default: return '#6b7280'; // gray for default + } + }}; +`; + +const Checkbox = styled.input.attrs({ type: 'checkbox' })` + width: 16px; + height: 16px; + accent-color: ${({ theme }) => theme.accent}; +`; + +const NetworkGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 8px; + margin-top: 8px; +`; + +const NetworkItem = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + color: ${({ theme }) => theme.primaryText}; + font-size: 14px; + padding: 6px 8px; + border-radius: 8px; + transition: all 0.2s; + + &:hover { + background: ${({ theme }) => theme.lightGrey}; + color: ${({ theme }) => theme.accent}; + + .only-button { + opacity: 1; + } + } +`; + +const NetworkLabel = styled.label` + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + flex: 1; + + svg { + width: 16px; + height: 16px; + flex-shrink: 0; + } +`; + +const SortSection = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +const SortOptions = styled.div` + display: flex; + gap: 16px; +`; + +const SortOption = styled.label` + display: flex; + align-items: center; + gap: 8px; + color: ${({ theme }) => theme.primaryText}; + font-size: 14px; + cursor: pointer; +`; + +const RadioButton = styled.input.attrs({ type: 'radio' })` + width: 16px; + height: 16px; + accent-color: ${({ theme }) => theme.accent}; +`; + +const FooterButtons = styled.div` + display: flex; + justify-content: flex-end; + gap: 12px; + padding-top: 20px; + border-top: 1px solid rgba(113, 134, 255, 0.3); +`; + +const Button = styled.button<{ variant?: 'primary' | 'secondary' }>` + padding: 12px 24px; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + border: 1px solid; + transition: all 0.2s; + + ${({ variant = 'secondary', theme }) => variant === 'primary' + ? ` + background: ${theme.accent}; + color: ${theme.lightBackground}; + border-color: ${theme.accent}; + + &:hover { + background: ${theme.primary}; + border-color: ${theme.primary}; + } + ` + : ` + background: transparent; + color: ${theme.accent}; + border-color: ${theme.accent}; + + &:hover { + background: ${theme.lightGrey}; + color: ${theme.primaryText}; + } + ` + } +`; + +const STATUS_LABELS = { + 'Registered': 'Included', + 'RegistrationRequested': 'Registration Requested', + 'ClearingRequested': 'Removal Requested', + 'Absent': 'Removed' +}; + +const REGISTRATION_STATUSES = Object.keys(STATUS_LABELS); + +const CHALLENGE_STATUSES = [ + { value: 'true', label: 'Challenged' }, + { value: 'false', label: 'Unchallenged' } +]; + +// Chain icon mapping +const getChainIcon = (chainId: string) => { + const iconMap = { + '1': EthereumIcon, + '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': SolanaIcon, + '8453': BaseIcon, + '42220': CeloIcon, + '534352': ScrollIcon, + '250': FantomIcon, + '324': ZkSyncIcon, + '100': GnosisIcon, + '137': PolygonIcon, + '10': OptimismIcon, + '42161': ArbitrumIcon, + '43114': AvalancheIcon, + '56': BnbIcon, + }; + return iconMap[chainId] || null; +}; + +interface FilterModalProps { + isOpen: boolean; + onClose: () => void; + chainFilters: string[]; + onChainFiltersChange: (chains: string[]) => void; + userAddress?: string; +} + +const FilterModal: React.FC = ({ + isOpen, + onClose, + chainFilters, + onChainFiltersChange, + userAddress +}) => { + const [searchParams, setSearchParams] = useSearchParams(); + const modalRef = useRef(null); + + useFocusOutside(modalRef, () => onClose()); + + const registrationStatuses = useMemo(() => { + return searchParams.getAll('status'); + }, [searchParams]); + + const disputedValues = useMemo(() => { + return searchParams.getAll('disputed'); + }, [searchParams]); + + const orderDirection = useMemo(() => searchParams.get('orderDirection') || 'desc', [searchParams]); + + const handleStatusChange = useCallback((status: string) => { + setSearchParams((prev) => { + const newParams = new URLSearchParams(prev); + const currentStatuses = newParams.getAll('status'); + + if (currentStatuses.includes(status)) { + // Remove this status - rebuild the list without it + newParams.delete('status'); + currentStatuses.filter(s => s !== status).forEach(s => newParams.append('status', s)); + } else { + // Add this status + newParams.append('status', status); + } + + newParams.set('page', '1'); + if (userAddress) { + newParams.set('userAddress', userAddress); + } + return newParams; + }, { replace: true }); + }, [setSearchParams, userAddress]); + + const handleDisputedChange = useCallback((disputed: string) => { + setSearchParams((prev) => { + const newParams = new URLSearchParams(prev); + const currentDisputed = newParams.getAll('disputed'); + + if (currentDisputed.includes(disputed)) { + // Remove this disputed value - rebuild the list without it + newParams.delete('disputed'); + currentDisputed.filter(d => d !== disputed).forEach(d => newParams.append('disputed', d)); + } else { + // Add this disputed value + newParams.append('disputed', disputed); + } + + newParams.set('page', '1'); + if (userAddress) { + newParams.set('userAddress', userAddress); + } + return newParams; + }, { replace: true }); + }, [setSearchParams, userAddress]); + + const handleNetworkChange = useCallback((networkId: string) => { + const newChainFilters = chainFilters.includes(networkId) + ? chainFilters.filter(id => id !== networkId) + : [...chainFilters, networkId]; + onChainFiltersChange(newChainFilters); + }, [chainFilters, onChainFiltersChange]); + + const handleOrderDirectionChange = useCallback((direction: string) => { + setSearchParams(prev => { + const newParams = new URLSearchParams(prev); + newParams.set('orderDirection', direction); + newParams.set('page', '1'); + // Maintain userAddress filter if present + if (userAddress) { + newParams.set('userAddress', userAddress); + } + return newParams; + }); + }, [setSearchParams, userAddress]); + + const handleStatusOnly = useCallback((selectedStatus: string) => { + setSearchParams((prev) => { + const newParams = new URLSearchParams(prev); + newParams.delete('status'); + newParams.append('status', selectedStatus); + newParams.set('page', '1'); + if (userAddress) { + newParams.set('userAddress', userAddress); + } + return newParams; + }, { replace: true }); + }, [setSearchParams, userAddress]); + + const handleStatusAll = useCallback(() => { + setSearchParams((prev) => { + const newParams = new URLSearchParams(prev); + newParams.delete('status'); + REGISTRATION_STATUSES.forEach(status => newParams.append('status', status)); + newParams.set('page', '1'); + if (userAddress) { + newParams.set('userAddress', userAddress); + } + return newParams; + }, { replace: true }); + }, [setSearchParams, userAddress]); + + const handleDisputedOnly = useCallback((selectedDisputed: string) => { + setSearchParams((prev) => { + const newParams = new URLSearchParams(prev); + newParams.delete('disputed'); + newParams.append('disputed', selectedDisputed); + newParams.set('page', '1'); + if (userAddress) { + newParams.set('userAddress', userAddress); + } + return newParams; + }, { replace: true }); + }, [setSearchParams, userAddress]); + + const handleDisputedAll = useCallback(() => { + setSearchParams((prev) => { + const newParams = new URLSearchParams(prev); + newParams.delete('disputed'); + CHALLENGE_STATUSES.forEach(challenge => newParams.append('disputed', challenge.value)); + newParams.set('page', '1'); + if (userAddress) { + newParams.set('userAddress', userAddress); + } + return newParams; + }, { replace: true }); + }, [setSearchParams, userAddress]); + + const handleNetworkOnly = useCallback((selectedNetworkId: string) => { + onChainFiltersChange([selectedNetworkId]); + }, [onChainFiltersChange]); + + const availableChains = useMemo(() => { + return chains.filter(chain => !chain.deprecated); + }, []); + + const handleNetworkAll = useCallback(() => { + const allChainIds = [...availableChains.map(chain => chain.id), 'unknown']; + onChainFiltersChange(allChainIds); + }, [onChainFiltersChange, availableChains]); + + if (!isOpen) return null; + + return ( + + + + + Filters + × + + + + + + + + + + Verification Status + + + All + + + + {REGISTRATION_STATUSES.map((status) => ( + + + handleStatusChange(status)} + /> + + {STATUS_LABELS[status]} + + handleStatusOnly(status)} + type="button" + > + Only + + + ))} + + + + + + + + + + Challenge Status + + + All + + + + {CHALLENGE_STATUSES.map((challenge) => ( + + + handleDisputedChange(challenge.value)} + /> + + {challenge.label} + + handleDisputedOnly(challenge.value)} + type="button" + > + Only + + + ))} + + + + + + + + + + + Networks + + + All + + + + {availableChains.map((chain) => { + const ChainIcon = getChainIcon(chain.id); + return ( + + + handleNetworkChange(chain.id)} + /> + {ChainIcon && } + {chain.name} + + handleNetworkOnly(chain.id)} + type="button" + > + Only + + + ); + })} + + + handleNetworkChange('unknown')} + /> + Unknown chains + + handleNetworkOnly('unknown')} + type="button" + > + Only + + + + + + + + + Sort by + + + + handleOrderDirectionChange('desc')} + /> + Newest + + + handleOrderDirectionChange('asc')} + /> + Oldest + + + + + + + + + + + ); +}; + +export default FilterModal; \ No newline at end of file diff --git a/websites/app/src/components/LightButton.tsx b/websites/app/src/components/LightButton.tsx new file mode 100644 index 0000000..86307d5 --- /dev/null +++ b/websites/app/src/components/LightButton.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import styled, { css } from "styled-components"; +import { landscapeStyle } from "styles/landscapeStyle"; +import { hoverShortTransitionTiming } from "styles/commonStyles"; + +import { Button } from "@kleros/ui-components-library"; + +const StyledButton = styled(Button)<{ isMobileNavbar?: boolean }>` + ${hoverShortTransitionTiming} + background-color: transparent; + padding: 8px !important; + border-radius: 7px; + .button-text { + color: ${({ theme }) => theme.primaryText}; + font-weight: 400; + } + .button-svg { + fill: ${({ theme, isMobileNavbar }) => (isMobileNavbar ? theme.secondaryText : `${theme.white}BF`)} !important; + } + + &:hover { + .button-svg { + fill: ${({ theme, isMobileNavbar }) => (isMobileNavbar ? theme.primaryText : `${theme.white}`)} !important; + } + background-color: ${({ theme }) => theme.whiteLowOpacityStrong}; + } + + ${landscapeStyle( + () => css` + padding: 8px !important; + .button-svg { + margin-right: 0; + } + ` + )} +`; + +interface ILightButton { + text: string; + Icon?: React.FC>; + onClick?: React.MouseEventHandler; + disabled?: boolean; + className?: string; + isMobileNavbar?: boolean; +} + +const LightButton: React.FC = ({ text, Icon, onClick, disabled, className, isMobileNavbar }) => ( + +); + +export default LightButton; diff --git a/websites/app/src/components/Overlay.tsx b/websites/app/src/components/Overlay.tsx new file mode 100644 index 0000000..b9b2978 --- /dev/null +++ b/websites/app/src/components/Overlay.tsx @@ -0,0 +1,11 @@ +import styled from "styled-components"; + +export const Overlay = styled.div` + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: ${({ theme }) => theme.blackLowOpacity}; + z-index: 30; +`; diff --git a/websites/app/src/components/OverlayPortal.tsx b/websites/app/src/components/OverlayPortal.tsx new file mode 100644 index 0000000..b2f3c94 --- /dev/null +++ b/websites/app/src/components/OverlayPortal.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import styled from "styled-components"; + +const PortalContainer = styled.div` + position: fixed; + top: 0; + left: 0; + z-index: 9999; + width: 100%; + height: 100%; +`; + +const OverlayPortal: React.FC<{ children: React.ReactNode }> = ({ children }) => { + return ReactDOM.createPortal({children}, document.body); +}; + +export default OverlayPortal; diff --git a/websites/app/src/components/ScrollTop.tsx b/websites/app/src/components/ScrollTop.tsx new file mode 100644 index 0000000..08bd9e2 --- /dev/null +++ b/websites/app/src/components/ScrollTop.tsx @@ -0,0 +1,33 @@ +import React, { useEffect, useRef } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +import { useScrollTop } from "hooks/useScrollTop"; + +const ScrollTop: React.FC = () => { + const scrollTop = useScrollTop(); + const { search, pathname } = useLocation(); + const navigate = useNavigate(); + const hasScrolled = useRef(false); + + useEffect(() => { + if (hasScrolled.current) return; + const params = new URLSearchParams(search); + const section = params.get("section"); + + if (section) { + const targetElement = document.getElementById(section); + if (targetElement) { + targetElement.scrollIntoView({ behavior: "smooth" }); + hasScrolled.current = true; + navigate(pathname, { replace: true }); + return; + } + } + + scrollTop(); + }, []); + + return null; +}; + +export default ScrollTop; diff --git a/websites/app/src/components/StyledArrowLink.tsx b/websites/app/src/components/StyledArrowLink.tsx new file mode 100644 index 0000000..5a69e4b --- /dev/null +++ b/websites/app/src/components/StyledArrowLink.tsx @@ -0,0 +1,27 @@ +import styled from "styled-components"; + +import { Link } from "react-router-dom"; + +export const StyledArrowLink = styled(Link)` + display: flex; + gap: 8px; + align-items: center; + font-size: 16px; + + > svg { + height: 16px; + width: 16px; + + path { + fill: ${({ theme }) => theme.primaryBlue}; + } + } + + &:hover { + color: ${({ theme }) => theme.secondaryBlue}; + svg path { + transition: fill 0.1s; + fill: ${({ theme }) => theme.secondaryBlue}; + } + } +`; diff --git a/websites/app/src/components/StyledPagination.tsx b/websites/app/src/components/StyledPagination.tsx new file mode 100644 index 0000000..75d89f8 --- /dev/null +++ b/websites/app/src/components/StyledPagination.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import styled from "styled-components"; + +interface Props { + currentPage: number; + numPages: number; + callback: (page: number) => void; +} + +const PaginationWrapper = styled.nav` + margin-top: 24px; + margin-left: auto; + margin-right: auto; + display: flex; + gap: 8px; +`; + +const PageButton = styled.button<{ selected?: boolean }>` + background: #2b2f46; + border: 1px solid #3e445f; + color: #c7cae6; + min-width: 40px; + min-height: 40px; + border-radius: 6px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease; + ${({ selected }) => + selected && + ` + background: #5676d9; + border-color: #5676d9; + color: #ffffff; + `} + &:hover:not(:disabled):not([aria-current="true"]) { + background: #303552; + border-color: #414970; + color: #ffffff; + } + &:disabled { + background: transparent; + border-color: #3e445f; + color: #5f6585; + cursor: default; + } +`; + +const StyledPagination: React.FC = ({ currentPage, numPages, callback }) => { + const pages: (number | string)[] = []; + if (numPages <= 5) { + for (let i = 1; i <= numPages; i++) pages.push(i); + } else { + pages.push(1); + if (currentPage > 3) pages.push("..."); + const start = Math.max(2, currentPage - 1); + const end = Math.min(numPages - 1, currentPage + 1); + for (let i = start; i <= end; i++) pages.push(i); + if (currentPage < numPages - 2) pages.push("..."); + pages.push(numPages); + } + return ( + + callback(currentPage - 1)}> + ❮ + + {pages.map((p, i) => + typeof p === "number" ? ( + callback(p)} + > + {p} + + ) : ( + + {p} + + ) + )} + callback(currentPage + 1)}> + ❯ + + + ); +}; + +export { StyledPagination }; +export default StyledPagination; diff --git a/websites/app/src/components/SubmittedByLink.tsx b/websites/app/src/components/SubmittedByLink.tsx new file mode 100644 index 0000000..e5dbb2e --- /dev/null +++ b/websites/app/src/components/SubmittedByLink.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import styled from 'styled-components'; +import { Link } from 'react-router-dom'; +import { shortenAddress } from 'utils/shortenAddress'; +import ArrowIcon from 'assets/svgs/icons/arrow.svg'; + +const StyledSubmittedByLink = styled(Link)` + display: inline-flex; + align-items: center; + gap: 6px; + color: inherit; + text-decoration: none; + transition: all 0.2s ease; + border-radius: 4px; + padding: 2px 4px; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + color: #ffffff; + + svg { + transform: translateX(2px); + } + } + + svg { + width: 14px; + height: 14px; + transition: transform 0.2s ease; + opacity: 0.7; + + path { + fill: currentColor; + } + } +`; + +interface SubmittedByLinkProps { + address: string; + className?: string; +} + +const SubmittedByLink: React.FC = ({ address, className }) => { + const shortenedAddress = shortenAddress(address); + + return ( + + {shortenedAddress} + + + ); +}; + +export default SubmittedByLink; \ No newline at end of file diff --git a/websites/app/src/consts/chains.ts b/websites/app/src/consts/chains.ts new file mode 100644 index 0000000..3755b27 --- /dev/null +++ b/websites/app/src/consts/chains.ts @@ -0,0 +1,26 @@ +import { type AppKitNetwork, gnosis } from "@reown/appkit/networks"; +import { type Chain, extractChain } from "viem"; + +export const DEFAULT_CHAIN = gnosis.id; + +// Read/Write +export const SUPPORTED_CHAINS: Record = { + [gnosis.id]: gnosis, +}; + +// Read Only +export const QUERY_CHAINS: Record = { + [gnosis.id]: gnosis, +}; + +export const ALL_CHAINS = [...Object.values(SUPPORTED_CHAINS), ...Object.values(QUERY_CHAINS)]; + +export const SUPPORTED_CHAIN_IDS = Object.keys(SUPPORTED_CHAINS); + +export const QUERY_CHAIN_IDS = Object.keys(QUERY_CHAINS); + +export const getChain = (chainId: number): Chain | null => + extractChain({ + chains: ALL_CHAINS, + id: chainId, + }); diff --git a/websites/app/src/consts/contracts.ts b/websites/app/src/consts/contracts.ts new file mode 100644 index 0000000..b55c3ef --- /dev/null +++ b/websites/app/src/consts/contracts.ts @@ -0,0 +1,15 @@ +import { Address } from 'viem'; +import { gnosis } from '@reown/appkit/networks'; + +export const klerosLiquidAddresses: Record = { + [gnosis.id]: '0x9C1dA9A04925bDfDedf0f6421bC7EEa8305F9002' as Address +} as const; + +export const registryAddresses = { + Single_Tags: '0x66260c69d03837016d88c9877e61e08ef74c59f2' as Address, + Tags_Queries: '0xae6aaed5434244be3699c56e7ebc828194f26dc3' as Address, + CDN: '0x957a53a994860be4750810131d9c876b2f52d6e1' as Address, + Tokens: '0xee1502e29795ef6c2d60f8d7120596abe3bad990' as Address, +} as const; + +export type RegistryType = keyof typeof registryAddresses; \ No newline at end of file diff --git a/websites/app/src/consts/index.tsx b/websites/app/src/consts/index.tsx index 0d5d133..c6a4018 100644 --- a/websites/app/src/consts/index.tsx +++ b/websites/app/src/consts/index.tsx @@ -1,2 +1,14 @@ +export const EMAIL_REGEX = + /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ +export const TELEGRAM_REGEX = /^@\w{5,32}$/ +export const ETH_ADDRESS_REGEX = /^0x[a-fA-F0-9]{40}$/ +export const ETH_SIGNATURE_REGEX = /^0x[a-fA-F0-9]{130}$/ + +export const DAPPLOOKER_API_KEY = + import.meta.env.REACT_APP_DAPPLOOKER_API_KEY || '' export const SUBGRAPH_GNOSIS_ENDPOINT = + import.meta.env.REACT_APP_SUBGRAPH_GNOSIS_ENDPOINT || 'https://indexer.hyperindex.xyz/1a2f51c/v1/graphql' +export const SUBGRAPH_KLEROS_DISPLAY_GNOSIS_ENDPOINT = + import.meta.env.REACT_APP_SUBGRAPH_KLEROS_DISPLAY_GNOSIS_ENDPOINT || + 'https://api.studio.thegraph.com/query/61738/kleros-display-gnosis/version/latest' diff --git a/websites/app/src/consts/socialmedia.ts b/websites/app/src/consts/socialmedia.ts new file mode 100644 index 0000000..7a3a156 --- /dev/null +++ b/websites/app/src/consts/socialmedia.ts @@ -0,0 +1,33 @@ +import DiscordLogo from "svgs/socialmedia/discord.svg"; +import GithubLogo from "svgs/socialmedia/github.svg"; +import LinkedinLogo from "svgs/socialmedia/linkedin.svg"; +import YouTubeLogo from "svgs/socialmedia/youtube.svg"; +import TelegramLogo from "svgs/socialmedia/telegram.svg"; +import XLogo from "svgs/socialmedia/x.svg"; + +export const socialmedia = { + telegram: { + icon: TelegramLogo, + url: "https://t.me/KlerosCurate", + }, + x: { + icon: XLogo, + url: "https://x.com/KlerosCurate", + }, + discord: { + icon: DiscordLogo, + url: "https://discord.com/invite/MhXQGCyHd9", + }, + youtube: { + icon: YouTubeLogo, + url: "https://youtube.com/@kleros_io", + }, + github: { + icon: GithubLogo, + url: "https://github.com/kleros/scout", + }, + linkedin: { + icon: LinkedinLogo, + url: "https://www.linkedin.com/company/kleros/", + }, +}; diff --git a/websites/app/src/context/StyledComponentsProvider.tsx b/websites/app/src/context/StyledComponentsProvider.tsx index bd9ce94..d45f88f 100644 --- a/websites/app/src/context/StyledComponentsProvider.tsx +++ b/websites/app/src/context/StyledComponentsProvider.tsx @@ -1,15 +1,19 @@ -import React from 'react' -import { GlobalStyle } from 'styles/global-style' +import React from "react"; +import { ThemeProvider } from "styled-components"; + +import { GlobalStyle } from "styles/global-style"; +import { darkTheme } from "styles/themes"; const StyledComponentsProvider: React.FC<{ - children: React.ReactNode + children: React.ReactNode; }> = ({ children }) => { + return ( - <> + {children} - - ) -} + + ); +}; -export default StyledComponentsProvider +export default StyledComponentsProvider; diff --git a/websites/app/src/context/Web3Provider.tsx b/websites/app/src/context/Web3Provider.tsx new file mode 100644 index 0000000..528f639 --- /dev/null +++ b/websites/app/src/context/Web3Provider.tsx @@ -0,0 +1,101 @@ +import React from "react"; + +import { + mainnet, + arbitrumSepolia, + arbitrum, + gnosisChiado, + sepolia, + gnosis, + type AppKitNetwork, +} from "@reown/appkit/networks"; +import { createAppKit } from "@reown/appkit/react"; +import { WagmiAdapter } from "@reown/appkit-adapter-wagmi"; +import { fallback, http, WagmiProvider, webSocket } from "wagmi"; + +import { ALL_CHAINS, DEFAULT_CHAIN } from "consts/chains"; + +const alchemyApiKey = import.meta.env.ALCHEMY_API_KEY; +if (!alchemyApiKey) { + throw new Error("Alchemy API key is not set in ALCHEMY_API_KEY environment variable."); +} + +// https://github.com/alchemyplatform/alchemy-sdk-js/blob/c4440cb/src/types/types.ts#L98-L153 +const alchemyToViemChain: Record = { + [arbitrumSepolia.id]: "arb-sepolia", + [arbitrum.id]: "arb-mainnet", + [mainnet.id]: "eth-mainnet", + [sepolia.id]: "eth-sepolia", + [gnosis.id]: "gnosis-mainnet", + [gnosisChiado.id]: "gnosis-chiado", +}; + +type AlchemyProtocol = "https" | "wss"; + +// https://github.com/alchemyplatform/alchemy-sdk-js/blob/c4440cb/src/util/const.ts#L16-L18 +function alchemyURL(protocol: AlchemyProtocol, chainId: number | string): string { + const network = alchemyToViemChain[chainId]; + if (!network) { + throw new Error(`Unsupported chain ID: ${chainId}`); + } + return `${protocol}://${network}.g.alchemy.com/v2/${alchemyApiKey}`; +} + +export const getChainRpcUrl = (protocol: AlchemyProtocol, chainId: number | string) => { + return alchemyURL(protocol, chainId); +}; + +export const getDefaultChainRpcUrl = (protocol: AlchemyProtocol) => { + return getChainRpcUrl(protocol, DEFAULT_CHAIN); +}; + +export const getTransports = () => { + const alchemyTransport = (chain: AppKitNetwork) => + fallback([http(alchemyURL("https", chain.id)), webSocket(alchemyURL("wss", chain.id))]); + const defaultTransport = (chain: AppKitNetwork) => + fallback([http(chain.rpcUrls.default?.http?.[0]), webSocket(chain.rpcUrls.default?.webSocket?.[0])]); + + return { + [gnosis.id]: defaultTransport(gnosis), + [mainnet.id]: alchemyTransport(mainnet), // Always enabled for ENS resolution + }; +}; + +const chains = ALL_CHAINS as [AppKitNetwork, ...AppKitNetwork[]]; +const transports = getTransports(); + +const projectId = import.meta.env.WALLETCONNECT_PROJECT_ID; +if (!projectId) { + throw new Error("WalletConnect project ID is not set in WALLETCONNECT_PROJECT_ID environment variable."); +} + +export const wagmiAdapter = new WagmiAdapter({ + networks: chains, + projectId, + transports, +}); + +createAppKit({ + adapters: [wagmiAdapter], + networks: chains, + defaultNetwork: gnosis, + projectId, + allowUnsupportedChain: true, + themeVariables: { + "--w3m-color-mix-strength": 20, + // overlay portal is at 9999 + "--w3m-z-index": 10000, + }, + features: { + // adding these here to toggle in futute if needed + // email: false, + // socials: false, + // onramp:false, + // swap: false + }, +}); +const Web3Provider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + return {children} ; +}; + +export default Web3Provider; diff --git a/websites/app/src/favicon.ico b/websites/app/src/favicon.ico index afeb107..e4602b9 100644 Binary files a/websites/app/src/favicon.ico and b/websites/app/src/favicon.ico differ diff --git a/websites/app/src/hooks/contracts/index.ts b/websites/app/src/hooks/contracts/index.ts new file mode 100644 index 0000000..6f53343 --- /dev/null +++ b/websites/app/src/hooks/contracts/index.ts @@ -0,0 +1,2 @@ +export * from './generated'; +export * from './useKlerosCurate'; \ No newline at end of file diff --git a/websites/app/src/hooks/contracts/useContractInteraction.ts b/websites/app/src/hooks/contracts/useContractInteraction.ts new file mode 100644 index 0000000..6131f58 --- /dev/null +++ b/websites/app/src/hooks/contracts/useContractInteraction.ts @@ -0,0 +1,71 @@ +import { useState, useCallback } from 'react'; +import { usePublicClient, useWalletClient } from 'wagmi'; +import { simulateContract } from '@wagmi/core'; +import { wagmiAdapter } from '../../context/Web3Provider'; +import { wrapWithToast, WrapWithToastReturnType } from '../../utils/wrapWithToast'; +import { Address, ContractFunctionArgs, ContractFunctionName, Abi } from 'viem'; + +interface UseContractInteractionParams< + TAbi extends Abi, + TFunctionName extends ContractFunctionName +> { + address: Address; + abi: TAbi; + functionName: TFunctionName; + args?: ContractFunctionArgs; + value?: bigint; + enabled?: boolean; +} + +export const useContractInteraction = < + TAbi extends Abi, + TFunctionName extends ContractFunctionName +>({ + address, + abi, + functionName, + args, + value, + enabled = true, +}: UseContractInteractionParams) => { + const [isLoading, setIsLoading] = useState(false); + const publicClient = usePublicClient(); + const { data: walletClient } = useWalletClient(); + + const execute = useCallback(async (): Promise => { + if (!walletClient || !publicClient || !enabled) { + return { status: false }; + } + + setIsLoading(true); + + try { + // First simulate the contract call + const { request } = await simulateContract(wagmiAdapter.wagmiConfig, { + address, + abi, + functionName, + args, + value, + }); + + // Then execute with toast wrapper + const result = await wrapWithToast( + async () => await walletClient.writeContract(request), + publicClient + ); + + return result; + } catch (error) { + console.error('Contract interaction failed:', error); + return { status: false }; + } finally { + setIsLoading(false); + } + }, [address, abi, functionName, args, value, enabled, walletClient, publicClient]); + + return { + execute, + isLoading, + }; +}; \ No newline at end of file diff --git a/websites/app/src/hooks/contracts/useCurateInteractions.ts b/websites/app/src/hooks/contracts/useCurateInteractions.ts new file mode 100644 index 0000000..95abd4e --- /dev/null +++ b/websites/app/src/hooks/contracts/useCurateInteractions.ts @@ -0,0 +1,170 @@ +import { useState, useCallback } from 'react'; +import { useAccount, usePublicClient, useWalletClient } from 'wagmi'; +import { simulateContract } from '@wagmi/core'; +import { wagmiAdapter } from '../../context/Web3Provider'; +import { wrapWithToast } from '../../utils/wrapWithToast'; +import { Address } from 'viem'; +import { klerosCurateAbi } from './generated'; +import { DepositParams } from '../../utils/fetchRegistryDeposits'; + +export const useCurateInteractions = () => { + const [isLoading, setIsLoading] = useState(false); + const { address } = useAccount(); + const publicClient = usePublicClient(); + const { data: walletClient } = useWalletClient(); + + const addItem = useCallback(async ( + registryAddress: Address, + itemData: string, + depositParams: DepositParams + ) => { + if (!walletClient || !publicClient || !address) { + throw new Error('Wallet not connected'); + } + + setIsLoading(true); + try { + const value = BigInt(depositParams.arbitrationCost + depositParams.submissionBaseDeposit); + + // Simulate the contract call first + const { request } = await simulateContract(wagmiAdapter.wagmiConfig, { + address: registryAddress, + abi: klerosCurateAbi, + functionName: 'addItem', + args: [itemData], + value, + account: address, + }); + + // Execute with toast wrapper + const result = await wrapWithToast( + async () => await walletClient.writeContract(request), + publicClient + ); + + return result; + } catch (error) { + console.error('Error adding item:', error); + throw error; + } finally { + setIsLoading(false); + } + }, [walletClient, publicClient, address]); + + const removeItem = useCallback(async ( + registryAddress: Address, + itemId: string, + evidence: string, + depositParams: DepositParams, + arbitrationCost: bigint + ) => { + if (!walletClient || !publicClient || !address) { + throw new Error('Wallet not connected'); + } + + setIsLoading(true); + try { + const value = arbitrationCost + BigInt(depositParams.removalBaseDeposit); + + const { request } = await simulateContract(wagmiAdapter.wagmiConfig, { + address: registryAddress, + abi: klerosCurateAbi, + functionName: 'removeItem', + args: [itemId as `0x${string}`, evidence], + value, + account: address, + }); + + const result = await wrapWithToast( + async () => await walletClient.writeContract(request), + publicClient + ); + + return result; + } catch (error) { + console.error('Error removing item:', error); + throw error; + } finally { + setIsLoading(false); + } + }, [walletClient, publicClient, address]); + + const challengeRequest = useCallback(async ( + registryAddress: Address, + itemId: string, + evidence: string, + challengeDeposit: bigint, + arbitrationCost: bigint + ) => { + if (!walletClient || !publicClient || !address) { + throw new Error('Wallet not connected'); + } + + setIsLoading(true); + try { + const value = arbitrationCost + challengeDeposit; + + const { request } = await simulateContract(wagmiAdapter.wagmiConfig, { + address: registryAddress, + abi: klerosCurateAbi, + functionName: 'challengeRequest', + args: [itemId as `0x${string}`, evidence], + value, + account: address, + }); + + const result = await wrapWithToast( + async () => await walletClient.writeContract(request), + publicClient + ); + + return result; + } catch (error) { + console.error('Error challenging request:', error); + throw error; + } finally { + setIsLoading(false); + } + }, [walletClient, publicClient, address]); + + const submitEvidence = useCallback(async ( + registryAddress: Address, + itemId: string, + evidence: string + ) => { + if (!walletClient || !publicClient || !address) { + throw new Error('Wallet not connected'); + } + + setIsLoading(true); + try { + const { request } = await simulateContract(wagmiAdapter.wagmiConfig, { + address: registryAddress, + abi: klerosCurateAbi, + functionName: 'submitEvidence', + args: [itemId as `0x${string}`, evidence], + account: address, + }); + + const result = await wrapWithToast( + async () => await walletClient.writeContract(request), + publicClient + ); + + return result; + } catch (error) { + console.error('Error submitting evidence:', error); + throw error; + } finally { + setIsLoading(false); + } + }, [walletClient, publicClient, address]); + + return { + addItem, + removeItem, + challengeRequest, + submitEvidence, + isLoading, + }; +}; \ No newline at end of file diff --git a/websites/app/src/hooks/contracts/useKlerosCurate.ts b/websites/app/src/hooks/contracts/useKlerosCurate.ts new file mode 100644 index 0000000..8105197 --- /dev/null +++ b/websites/app/src/hooks/contracts/useKlerosCurate.ts @@ -0,0 +1,121 @@ +import { useAccount, useChainId } from 'wagmi'; +import { + useWriteKlerosCurateAddItem, + useWriteKlerosCurateRemoveItem, + useWriteKlerosCurateChallengeRequest, + useWriteKlerosCurateSubmitEvidence, + useReadKlerosCurateGetItemInfo, + useReadKlerosCurateGetRequestInfo, + useSimulateKlerosCurateAddItem, + useSimulateKlerosCurateRemoveItem, + useSimulateKlerosCurateChallengeRequest, + useSimulateKlerosCurateSubmitEvidence, +} from './generated'; +import { registryAddresses, RegistryType } from '../../consts/contracts'; +import { REFETCH_INTERVAL } from '../queries/consts'; + +export const useKlerosCurateItemInfo = (itemId: `0x${string}`, registryType: RegistryType) => { + const address = registryAddresses[registryType]; + + return useReadKlerosCurateGetItemInfo({ + address, + args: [itemId], + query: { + refetchInterval: REFETCH_INTERVAL, + enabled: !!itemId && !!address, + }, + }); +}; + +export const useKlerosCurateRequestInfo = ( + itemId: `0x${string}`, + requestId: bigint, + registryType: RegistryType +) => { + const address = registryAddresses[registryType]; + + return useReadKlerosCurateGetRequestInfo({ + address, + args: [itemId, requestId], + query: { + refetchInterval: REFETCH_INTERVAL, + enabled: !!itemId && requestId !== undefined && !!address, + }, + }); +}; + +// Write hooks with simulation +export const useAddItemToRegistry = (registryType: RegistryType) => { + const registryAddress = registryAddresses[registryType]; + + const simulate = useSimulateKlerosCurateAddItem(); + const write = useWriteKlerosCurateAddItem(); + + return { + simulate: (item: string, value: bigint) => + simulate.mutateAsync({ + address: registryAddress, + args: [item], + value, + }), + write: write.writeContract, + isLoading: write.isPending || simulate.isPending, + error: write.error || simulate.error, + }; +}; + +export const useRemoveItemFromRegistry = (registryType: RegistryType) => { + const registryAddress = registryAddresses[registryType]; + + const simulate = useSimulateKlerosCurateRemoveItem(); + const write = useWriteKlerosCurateRemoveItem(); + + return { + simulate: (itemId: `0x${string}`, evidence: string, value: bigint) => + simulate.mutateAsync({ + address: registryAddress, + args: [itemId, evidence], + value, + }), + write: write.writeContract, + isLoading: write.isPending || simulate.isPending, + error: write.error || simulate.error, + }; +}; + +export const useChallengeRequest = (registryType: RegistryType) => { + const registryAddress = registryAddresses[registryType]; + + const simulate = useSimulateKlerosCurateChallengeRequest(); + const write = useWriteKlerosCurateChallengeRequest(); + + return { + simulate: (itemId: `0x${string}`, evidence: string, value: bigint) => + simulate.mutateAsync({ + address: registryAddress, + args: [itemId, evidence], + value, + }), + write: write.writeContract, + isLoading: write.isPending || simulate.isPending, + error: write.error || simulate.error, + }; +}; + +export const useSubmitEvidence = (registryType: RegistryType) => { + const registryAddress = registryAddresses[registryType]; + + const simulate = useSimulateKlerosCurateSubmitEvidence(); + const write = useWriteKlerosCurateSubmitEvidence(); + + return { + simulate: (itemId: `0x${string}`, evidence: string) => + simulate.mutateAsync({ + address: registryAddress, + args: [itemId, evidence], + }), + write: write.writeContract, + isLoading: write.isPending || simulate.isPending, + error: write.error || simulate.error, + }; +}; \ No newline at end of file diff --git a/websites/app/src/hooks/countdown.ts b/websites/app/src/hooks/countdown.ts index 67d0654..f30e328 100644 --- a/websites/app/src/hooks/countdown.ts +++ b/websites/app/src/hooks/countdown.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import humanizeDuration from 'humanize-duration' -import { registryMap } from 'utils/fetchItems'; +import { registryMap } from 'utils/items'; import { JsonRpcProvider, Contract } from 'ethers'; export const useChallengePeriodDuration = (registryAddress: string) => { diff --git a/websites/app/src/hooks/queries/consts.ts b/websites/app/src/hooks/queries/consts.ts new file mode 100644 index 0000000..7bc884c --- /dev/null +++ b/websites/app/src/hooks/queries/consts.ts @@ -0,0 +1,9 @@ +export const REFETCH_INTERVAL = 5000; // 5 seconds +export const STALE_TIME = 10000; // 10 seconds + +// Query keys +export const queryKeys = { + items: (searchParams: URLSearchParams) => ['items', Object.fromEntries(searchParams)], + itemDetails: (itemId: string) => ['itemDetails', itemId], + itemCounts: () => ['itemCounts'], +} as const; \ No newline at end of file diff --git a/websites/app/src/hooks/queries/index.ts b/websites/app/src/hooks/queries/index.ts new file mode 100644 index 0000000..d632988 --- /dev/null +++ b/websites/app/src/hooks/queries/index.ts @@ -0,0 +1,5 @@ +export { useItemsQuery } from './useItemsQuery'; +export { useItemDetailsQuery } from './useItemDetailsQuery'; +export { useItemCountsQuery } from './useItemCountsQuery'; +export { useGraphqlBatcher } from './useGraphqlBatcher'; +export * from './consts'; \ No newline at end of file diff --git a/websites/app/src/hooks/queries/useExportItems.ts b/websites/app/src/hooks/queries/useExportItems.ts new file mode 100644 index 0000000..1bc9210 --- /dev/null +++ b/websites/app/src/hooks/queries/useExportItems.ts @@ -0,0 +1,255 @@ +import { gql, request } from 'graphql-request' +import { GraphItem } from 'utils/items' +import { useQuery } from '@tanstack/react-query' +import { SUBGRAPH_GNOSIS_ENDPOINT } from 'consts/index' +import { chains, getNamespaceForChainId } from 'utils/chains' + +export interface ExportFilters { + registryId?: string + status?: string[] + disputed?: boolean[] + fromDate?: string + toDate?: string + network?: string[] + text?: string +} + +export const useExportItems = (filters: ExportFilters) => { + return useQuery({ + queryKey: ['exportItems', filters], + enabled: false, // Only fetch when export button is clicked + queryFn: async () => { + let allData: GraphItem[] = [] + const first = 1000 + let skip = 0 + let keepFetching = true + + const { + registryId, + status = ['Registered'], + disputed = [false, true], + fromDate, + toDate, + network = [], + text = '', + } = filters + + if (!registryId) { + throw new Error('Registry ID is required for export') + } + + const isTagsQueriesRegistry = + registryId === '0xae6aaed5434244be3699c56e7ebc828194f26dc3' + + // Build network filter + const selectedChainIds = network.filter((id) => id !== 'unknown') + const includeUnknown = network.includes('unknown') + const definedChainIds = chains.map((c) => c.id) + const knownPrefixes = [ + ...new Set( + chains.map((chain) => { + if (chain.namespace === 'solana') { + return 'solana:' + } + return `${chain.namespace}:${chain.id}:` + }), + ), + ] + + let networkQueryObject = '' + if (isTagsQueriesRegistry && network.length > 0) { + const conditions = selectedChainIds.map( + (chainId) => + `{ _or: [{ key2: { _eq: "${chainId}"}}, { key1: { _eq: "${chainId}"}}]}`, + ) + if (includeUnknown) { + conditions.push( + `{ _and: [{ key1: { _nin: $definedChainIds}}, { key2: { _nin: $definedChainIds}}]}`, + ) + } + networkQueryObject = + conditions.length > 0 ? `{_or: [${conditions.join(',')}]}` : '{}' + } else if (network.length > 0) { + const conditions = selectedChainIds.map((chainId) => { + const namespace = getNamespaceForChainId(chainId) + if (namespace === 'solana') { + return `{key0: { _ilike: "solana:%"}}` + } + return `{key0: {_ilike: "${namespace}:${chainId}:%"}}` + }) + networkQueryObject = + conditions.length > 0 ? `{_or: [${conditions.join(',')}]}` : '{}' + } + + // Build text filter + const textFilterObject = text + ? `{_or: [ + {key0: {_ilike: $text}}, + {key1: {_ilike: $text}}, + {key2: {_ilike: $text}}, + {key3: {_ilike: $text}}, + {key4: {_ilike: $text}} + ]}` + : '' + + // Build date filter + let dateFilterObject = '' + if (fromDate || toDate) { + const conditions: string[] = [] + if (fromDate) { + const fromTimestamp = Math.floor(new Date(fromDate).getTime() / 1000) + conditions.push( + `{latestRequestSubmissionTime: { _gte: "${fromTimestamp}"}}`, + ) + } + if (toDate) { + const toTimestamp = Math.floor(new Date(toDate).getTime() / 1000) + conditions.push( + `{latestRequestSubmissionTime: {_lte: "${toTimestamp}"}}`, + ) + } + dateFilterObject = + conditions.length > 0 ? `{_and: [${conditions.join(',')}]}` : '' + } + + // Build the complete where clause + const whereConditions = [ + `{registry_id: {_eq: "${registryId}"}}`, + `{status: {_in: $status}}`, + `{disputed: {_in: $disputed}}`, + networkQueryObject && `${networkQueryObject}`, + textFilterObject && `${textFilterObject}`, + dateFilterObject && `${dateFilterObject}`, + ].filter(Boolean) as string[] + + const query = gql` + query ( + $status: [status!]! + $disputed: [Boolean!]! + $text: String + $skip: Int! + $first: Int! + ${includeUnknown && isTagsQueriesRegistry ? '$definedChainIds: [String!]!' : ''} + ) { + litems: LItem( + where: { + _and: [${whereConditions.join(',')}] + } + offset: $skip + limit: $first + order_by: {latestRequestSubmissionTime: desc} + ) { + id + latestRequestSubmissionTime + registryAddress + itemID + status + disputed + data + key0 + key1 + key2 + key3 + key4 + props { + value + type: itemType + label + description + isIdentifier + } + requests(limit: 1, order_by: {submissionTime: desc}) { + disputed + disputeID + submissionTime + resolved + requester + challenger + resolutionTime + deposit + rounds(limit: 1, order_by: {creationTime : desc}) { + appealPeriodStart + appealPeriodEnd + ruling + hasPaidRequester + hasPaidChallenger + amountPaidRequester + amountPaidChallenger + } + } + } + } + ` + + try { + while (keepFetching) { + const variables: any = { + status, + disputed, + skip, + first, + } + + if (text) { + variables.text = `%${text}%` + } + + if (includeUnknown && isTagsQueriesRegistry) { + variables.definedChainIds = definedChainIds + } + + const result = (await request({ + url: SUBGRAPH_GNOSIS_ENDPOINT, + document: query, + variables, + })) as any + + let items = result.litems + + // Client-side filtering for non-Tags_Queries registries + if (!isTagsQueriesRegistry && network.length > 0) { + const selectedPrefixes = selectedChainIds.map((chainId) => { + const namespace = getNamespaceForChainId(chainId) + if (namespace === 'solana') { + return 'solana:' + } + return `${namespace}:${chainId}:` + }) + + items = items.filter((item: GraphItem) => { + const key0 = item?.key0?.toLowerCase() || '' + const matchesSelectedChain = + selectedPrefixes.length > 0 + ? selectedPrefixes.some((prefix) => + key0.startsWith(prefix.toLowerCase()), + ) + : false + + const isUnknownChain = !knownPrefixes.some((prefix) => + key0.startsWith(prefix.toLowerCase()), + ) + + return ( + (selectedPrefixes.length > 0 && matchesSelectedChain) || + (includeUnknown && isUnknownChain) + ) + }) + } + + allData = allData.concat(items) + + if (items.length < first) { + keepFetching = false + } + + skip += first + } + } catch (error) { + console.error('Error fetching export data:', error) + throw error + } + + return allData + }, + }) +} diff --git a/websites/app/src/hooks/queries/useGraphqlBatcher.ts b/websites/app/src/hooks/queries/useGraphqlBatcher.ts new file mode 100644 index 0000000..49c3958 --- /dev/null +++ b/websites/app/src/hooks/queries/useGraphqlBatcher.ts @@ -0,0 +1,82 @@ +import { request, RequestDocument } from 'graphql-request'; +import { SUBGRAPH_GNOSIS_ENDPOINT } from 'consts'; + +interface BatchedRequest { + id: string; + document: RequestDocument; + variables?: any; + resolve: (data: any) => void; + reject: (error: any) => void; +} + +class GraphQLBatcher { + private queue: BatchedRequest[] = []; + private timeoutId: NodeJS.Timeout | null = null; + private readonly batchDelay = 10; // milliseconds + + async request(id: string, document: RequestDocument, variables?: any): Promise { + return new Promise((resolve, reject) => { + this.queue.push({ + id, + document, + variables, + resolve, + reject, + }); + + if (this.timeoutId) { + clearTimeout(this.timeoutId); + } + + this.timeoutId = setTimeout(() => { + this.processBatch(); + }, this.batchDelay); + }); + } + + private async processBatch() { + if (this.queue.length === 0) return; + + const batch = [...this.queue]; + this.queue = []; + this.timeoutId = null; + + // Group requests by document and variables to avoid duplicates + const uniqueRequests = new Map(); + const requestGroups = new Map(); + + for (const request of batch) { + const key = JSON.stringify({ document: request.document, variables: request.variables }); + if (!uniqueRequests.has(key)) { + uniqueRequests.set(key, request); + requestGroups.set(key, []); + } + requestGroups.get(key)?.push(request); + } + + // Execute unique requests + for (const [key, requestGroup] of requestGroups) { + const firstRequest = requestGroup[0]; + try { + const data = await request({ + url: SUBGRAPH_GNOSIS_ENDPOINT, + document: firstRequest.document, + variables: firstRequest.variables, + }); + + // Resolve all requests in this group with the same data + requestGroup.forEach(req => req.resolve(data)); + } catch (error) { + // Reject all requests in this group with the same error + requestGroup.forEach(req => req.reject(error)); + } + } + } +} + +// Global batcher instance +const graphqlBatcher = new GraphQLBatcher(); + +export const useGraphqlBatcher = () => { + return graphqlBatcher; +}; \ No newline at end of file diff --git a/websites/app/src/hooks/queries/useItemCountsQuery.ts b/websites/app/src/hooks/queries/useItemCountsQuery.ts new file mode 100644 index 0000000..34208be --- /dev/null +++ b/websites/app/src/hooks/queries/useItemCountsQuery.ts @@ -0,0 +1,167 @@ +import { useQuery } from '@tanstack/react-query' +import { gql } from 'graphql-request' +import { queryKeys, REFETCH_INTERVAL, STALE_TIME } from './consts' +import { useGraphqlBatcher } from './useGraphqlBatcher' +import { ItemCounts } from '../../utils/itemCounts' +import { registryMap } from 'utils/items' +import { + fetchRegistryDeposits, + DepositParams, +} from '../../utils/fetchRegistryDeposits' + +const FETCH_ITEM_COUNTS_QUERY = gql` + query FetchItemCounts { + Single_Tags: LRegistry_by_pk(id: "${registryMap.Single_Tags}") { + id + numberOfAbsent + numberOfRegistered + numberOfClearingRequested + numberOfChallengedClearing + numberOfRegistrationRequested + numberOfChallengedRegistrations + registrationMetaEvidence { + URI: uri + } + } + Tags_Queries: LRegistry_by_pk(id: "${registryMap.Tags_Queries}") { + id + numberOfAbsent + numberOfRegistered + numberOfClearingRequested + numberOfChallengedClearing + numberOfRegistrationRequested + numberOfChallengedRegistrations + registrationMetaEvidence { + URI: uri + } + } + CDN: LRegistry_by_pk(id: "${registryMap.CDN}") { + id + numberOfAbsent + numberOfRegistered + numberOfClearingRequested + numberOfChallengedClearing + numberOfRegistrationRequested + numberOfChallengedRegistrations + registrationMetaEvidence { + URI: uri + } + } + Tokens: LRegistry_by_pk(id: "${registryMap.Tokens}") { + id + numberOfAbsent + numberOfRegistered + numberOfClearingRequested + numberOfChallengedClearing + numberOfRegistrationRequested + numberOfChallengedRegistrations + registrationMetaEvidence { + URI: uri + } + } + } +` + +const convertStringFieldsToNumber = (obj: any): any => { + let result: any = Array.isArray(obj) ? [] : {} + + for (const key in obj) { + if (typeof obj[key] === 'object') { + result[key] = convertStringFieldsToNumber(obj[key]) + } else if (typeof obj[key] === 'string') { + result[key] = Number(obj[key]) + } else { + result[key] = obj[key] + } + } + + return result +} + +export const useItemCountsQuery = (enabled: boolean = true) => { + const graphqlBatcher = useGraphqlBatcher() + + return useQuery({ + queryKey: queryKeys.itemCounts(), + queryFn: async (): Promise => { + const requestId = crypto.randomUUID() + const result = await graphqlBatcher.request( + requestId, + FETCH_ITEM_COUNTS_QUERY, + ) + + const itemCounts: ItemCounts = convertStringFieldsToNumber(result) + + // Fetch metadata for all registries + const regMEs = await Promise.all([ + fetch( + 'https://cdn.kleros.link' + + result?.Single_Tags?.registrationMetaEvidence?.URI, + ).then((r) => r.json()), + fetch( + 'https://cdn.kleros.link' + + result?.Tags_Queries?.registrationMetaEvidence?.URI, + ).then((r) => r.json()), + fetch( + 'https://cdn.kleros.link' + + result?.CDN?.registrationMetaEvidence?.URI, + ).then((r) => r.json()), + fetch( + 'https://cdn.kleros.link' + + result?.Tokens?.registrationMetaEvidence?.URI, + ).then((r) => r.json()), + ]) + + // Inject metadata + itemCounts.Single_Tags.metadata = { + address: result?.Single_Tags?.id, + policyURI: regMEs[0].fileURI, + logoURI: regMEs[0].metadata.logoURI, + tcrTitle: regMEs[0].metadata.tcrTitle, + tcrDescription: regMEs[0].metadata.tcrDescription, + } + + itemCounts.Tags_Queries.metadata = { + address: result?.Tags_Queries?.id, + policyURI: regMEs[1].fileURI, + logoURI: regMEs[1].metadata.logoURI, + tcrTitle: regMEs[1].metadata.tcrTitle, + tcrDescription: regMEs[1].metadata.tcrDescription, + } + + itemCounts.CDN.metadata = { + address: result?.CDN?.id, + policyURI: regMEs[2].fileURI, + logoURI: regMEs[2].metadata.logoURI, + tcrTitle: regMEs[2].metadata.tcrTitle, + tcrDescription: regMEs[2].metadata.tcrDescription, + } + + itemCounts.Tokens.metadata = { + address: result?.Tokens?.id, + policyURI: regMEs[3].fileURI, + logoURI: regMEs[3].metadata.logoURI, + tcrTitle: regMEs[3].metadata.tcrTitle, + tcrDescription: regMEs[3].metadata.tcrDescription, + } + + // Fetch registry deposits + const regDs = await Promise.all([ + fetchRegistryDeposits(registryMap.Single_Tags), + fetchRegistryDeposits(registryMap.Tags_Queries), + fetchRegistryDeposits(registryMap.CDN), + fetchRegistryDeposits(registryMap.Tokens), + ]) + + itemCounts.Single_Tags.deposits = regDs[0] as DepositParams + itemCounts.Tags_Queries.deposits = regDs[1] as DepositParams + itemCounts.CDN.deposits = regDs[2] as DepositParams + itemCounts.Tokens.deposits = regDs[3] as DepositParams + + return itemCounts + }, + enabled, + refetchInterval: REFETCH_INTERVAL, + staleTime: STALE_TIME, + }) +} diff --git a/websites/app/src/hooks/queries/useItemDetailsQuery.ts b/websites/app/src/hooks/queries/useItemDetailsQuery.ts new file mode 100644 index 0000000..da85503 --- /dev/null +++ b/websites/app/src/hooks/queries/useItemDetailsQuery.ts @@ -0,0 +1,101 @@ +import { useQuery } from '@tanstack/react-query' +import { gql } from 'graphql-request' +import { queryKeys, REFETCH_INTERVAL, STALE_TIME } from './consts' +import { useGraphqlBatcher } from './useGraphqlBatcher' +import { GraphItemDetails } from '../../utils/itemDetails' + +const FETCH_ITEM_DETAILS_QUERY = gql` + query FetchItemDetails($id: String!) { + litem: LItem_by_pk(id: $id) { + key0 + key1 + key2 + key3 + props { + value + type: itemType + label + description + isIdentifier + } + itemID + registryAddress + status + disputed + requests(order_by: { submissionTime: desc }) { + requestType + disputed + disputeID + submissionTime + resolved + requester + arbitrator + arbitratorExtraData + challenger + creationTx + resolutionTx + deposit + disputeOutcome + resolutionTime + evidenceGroup { + id + evidences(order_by: { number: desc }) { + party + URI: uri + number + timestamp + txHash + title + description + fileURI + fileTypeExtension + } + } + rounds(order_by: { creationTime: desc }) { + appealed + appealPeriodStart + appealPeriodEnd + ruling + hasPaidRequester + hasPaidChallenger + amountPaidRequester + amountPaidChallenger + txHashAppealPossible + appealedAt + txHashAppealDecision + } + } + } + } +` + +interface UseItemDetailsQueryParams { + itemId: string + enabled?: boolean +} + +export const useItemDetailsQuery = ({ + itemId, + enabled = true, +}: UseItemDetailsQueryParams) => { + const graphqlBatcher = useGraphqlBatcher() + + return useQuery({ + queryKey: queryKeys.itemDetails(itemId), + queryFn: async (): Promise => { + const requestId = crypto.randomUUID() + const result = await graphqlBatcher.request( + requestId, + FETCH_ITEM_DETAILS_QUERY, + { + id: itemId, + }, + ) + + return result.litem + }, + enabled: enabled && itemId != null && itemId !== '', + refetchInterval: REFETCH_INTERVAL, + staleTime: STALE_TIME, + }) +} diff --git a/websites/app/src/hooks/queries/useItemsQuery.ts b/websites/app/src/hooks/queries/useItemsQuery.ts new file mode 100644 index 0000000..f4fe64e --- /dev/null +++ b/websites/app/src/hooks/queries/useItemsQuery.ts @@ -0,0 +1,228 @@ +import { useQuery } from '@tanstack/react-query' +import { gql } from 'graphql-request' +import { queryKeys, REFETCH_INTERVAL, STALE_TIME } from './consts' +import { useGraphqlBatcher } from './useGraphqlBatcher' +import { GraphItem, registryMap } from 'utils/items' +import { ITEMS_PER_PAGE } from '../../pages/Registries/index' +import { chains, getNamespaceForChainId } from '../../utils/chains' + +interface UseItemsQueryParams { + searchParams: URLSearchParams + chainFilters?: string[] + enabled?: boolean +} + +export const useItemsQuery = ({ + searchParams, + chainFilters = [], + enabled = true, +}: UseItemsQueryParams) => { + const graphqlBatcher = useGraphqlBatcher() + + const registry = searchParams.getAll('registry') + const status = searchParams.getAll('status') + const disputed = searchParams.getAll('disputed') + const network = chainFilters + const text = searchParams.get('text') || '' + const orderDirection = searchParams.get('orderDirection') || 'desc' + const page = Number(searchParams.get('page')) || 1 + + const shouldFetch = + enabled && + registry.length > 0 && + status.length > 0 && + disputed.length > 0 && + page > 0 + + return useQuery({ + queryKey: [...queryKeys.items(searchParams), chainFilters], + queryFn: async () => { + if (!shouldFetch) return [] + + const isTagsQueriesRegistry = registry.includes('Tags_Queries') + const selectedChainIds = network.filter((id) => id !== 'unknown') + const includeUnknown = network.includes('unknown') + const definedChainIds = chains.map((c) => c.id) + + // Build network filter based on registry type + let networkQueryObject = '' + if (isTagsQueriesRegistry) { + const conditions = selectedChainIds.map( + (chainId) => + `{ _or: [{ key2: { _eq: "${chainId}"}}, { key1: { _eq: "${chainId}"}}]}`, + ) + if (includeUnknown) { + conditions.push( + `{ _and: [{ key1: { _nin: $definedChainIds}}, { key2: { _nin: $definedChainIds}}]}`, + ) + } + networkQueryObject = + conditions.length > 0 ? `{_or: [${conditions.join(',')}]}` : '{}' + } else { + const conditions = selectedChainIds.map((chainId) => { + const namespace = getNamespaceForChainId(chainId) + if (namespace === 'solana') { + return `{key0: { _ilike: "solana:%"}}` + } + return `{key0: {_ilike: "${namespace}:${chainId}:%"}}` + }) + networkQueryObject = + conditions.length > 0 ? `{_or: [${conditions.join(',')}]}` : '{}' + } + + const textFilterObject = text + ? `{_or: [ + {key0: {_ilike: $text}}, + {key1: {_ilike: $text}}, + {key2: {_ilike: $text}}, + {key3: {_ilike: $text}}, + {key4: {_ilike: $text}} + ]}` + : '' + + // Build the complete query with filters + const queryWithFilters = gql` + query FetchItems( + $registry: [String] + $status: [status!]! + $disputed: [Boolean!]! + $text: String + $skip: Int! + $first: Int! + $orderDirection: order_by! + ${ + includeUnknown && isTagsQueriesRegistry + ? '$definedChainIds: [String!]!' + : '' + } + ) { + litems: LItem( + where: { + _and: [ + {registry_id: {_in :$registry}}, + {status: {_in: $status}}, + {disputed: {_in: $disputed}}, + ${networkQueryObject}, + ${text === '' ? '' : textFilterObject} + ] + } + offset: $skip + limit: $first + order_by: {latestRequestSubmissionTime : $orderDirection } + ) { + id + latestRequestSubmissionTime + registryAddress + itemID + status + disputed + data + key0 + key1 + key2 + key3 + key4 + props { + value + type: itemType + label + description + isIdentifier + } + requests(limit: 1, order_by: {submissionTime: desc}) { + disputed + disputeID + submissionTime + resolved + requester + challenger + resolutionTime + deposit + rounds(limit: 1, order_by: {creationTime : desc}) { + appealPeriodStart + appealPeriodEnd + ruling + hasPaidRequester + hasPaidChallenger + amountPaidRequester + amountPaidChallenger + } + } + } + } + ` + + const variables: any = { + registry: registry.map((r) => registryMap[r]).filter((i) => i !== null), + status, + disputed: disputed.map((e) => e === 'true'), + skip: (page - 1) * ITEMS_PER_PAGE, + first: ITEMS_PER_PAGE + 1, + orderDirection, + } + + if (text) { + variables.text = `%${text}%` + } + + if (includeUnknown && isTagsQueriesRegistry) { + variables.definedChainIds = definedChainIds + } + + const requestId = crypto.randomUUID() + const result = await graphqlBatcher.request( + requestId, + queryWithFilters, + variables, + ) + + let items: GraphItem[] = result.litems + + // Client-side filtering for non-Tags_Queries registries + if (!isTagsQueriesRegistry && network.length > 0) { + const knownPrefixes = [ + ...new Set( + chains.map((chain) => { + if (chain.namespace === 'solana') { + return 'solana:' + } + return `${chain.namespace}:${chain.id}:` + }), + ), + ] + + const selectedPrefixes = selectedChainIds.map((chainId) => { + const namespace = getNamespaceForChainId(chainId) + if (namespace === 'solana') { + return 'solana:' + } + return `${namespace}:${chainId}:` + }) + + items = items.filter((item: GraphItem) => { + const key0 = item?.key0?.toLowerCase() || '' + const matchesSelectedChain = + selectedPrefixes.length > 0 + ? selectedPrefixes.some((prefix) => + key0.startsWith(prefix.toLowerCase()), + ) + : false + + const isUnknownChain = !knownPrefixes.some((prefix) => + key0.startsWith(prefix.toLowerCase()), + ) + + return ( + (selectedPrefixes.length > 0 && matchesSelectedChain) || + (includeUnknown && isUnknownChain) + ) + }) + } + + return items + }, + enabled: shouldFetch, + refetchInterval: REFETCH_INTERVAL, + staleTime: STALE_TIME, + }) +} diff --git a/websites/app/src/hooks/useDapplookerStats.ts b/websites/app/src/hooks/useDapplookerStats.ts new file mode 100644 index 0000000..2081d6f --- /dev/null +++ b/websites/app/src/hooks/useDapplookerStats.ts @@ -0,0 +1,504 @@ +import { useQuery } from '@tanstack/react-query' +import { gql } from 'graphql-request' +import { useGraphqlBatcher } from './queries/useGraphqlBatcher' +import { registryMap } from 'utils/items' +import { DAPPLOOKER_API_KEY } from 'consts' + +type ChainName = 'ethereum' | 'polygon' | 'arbitrum' | 'optimism' | 'base' + +interface RegistryData { + id: string + numberOfRegistered: string + numberOfAbsent: string + numberOfRegistrationRequested: string + numberOfClearingRequested: string + numberOfChallengedRegistrations: string + numberOfChallengedClearing: string +} + +interface ItemData { + id: string + status: string + disputed: boolean + latestRequestSubmissionTime: string + registryAddress: string + key0?: string + key1?: string + key2?: string + key3?: string + key4?: string + requests: Array<{ + submissionTime: string + requester: string + challenger?: string + disputed: boolean + deposit: string + }> +} + +interface SubgraphResponse { + lregistries: RegistryData[] + litems: ItemData[] +} + +interface DapplookerStatsData { + totalAssetsVerified: number + totalSubmissions: number + totalCurators: number + tokens: { + assetsVerified: number + assetsVerifiedChange: number + } + cdn: { + assetsVerified: number + assetsVerifiedChange: number + } + singleTags: { + assetsVerified: number + assetsVerifiedChange: number + } + tagQueries: { + assetsVerified: number + assetsVerifiedChange: number + } + submissionsVsDisputes: { + submissions: number[] + disputes: number[] + dates: string[] + } + chainRanking: { + rank: number + chain: string + items: number + }[] +} + +const DASHBOARD_ID = 'f5dcef21-ad65-4671-a930-58d3ec67f6a2' + +const CHAIN_PREFIXES: Record = { + 'eip155:1:': 'ethereum', + 'eip155:137:': 'polygon', + 'eip155:42161:': 'arbitrum', + 'eip155:10:': 'optimism', + 'eip155:8453:': 'base', +} + +const CACHE_CONFIG = { + staleTime: 5 * 60 * 1000, // 5 minutes + refetchInterval: false, + retry: 2, +} as const + +const EMPTY_STATS: DapplookerStatsData = { + totalAssetsVerified: 0, + totalSubmissions: 0, + totalCurators: 0, + tokens: { assetsVerified: 0, assetsVerifiedChange: 0 }, + cdn: { assetsVerified: 0, assetsVerifiedChange: 0 }, + singleTags: { assetsVerified: 0, assetsVerifiedChange: 0 }, + tagQueries: { assetsVerified: 0, assetsVerifiedChange: 0 }, + submissionsVsDisputes: { submissions: [], disputes: [], dates: [] }, + chainRanking: [], +} + +const getCurateStatsQuery = () => { + const thirtyDaysAgo = Math.floor( + (Date.now() - 30 * 24 * 60 * 60 * 1000) / 1000, + ) + + return gql` + query CurateStats { + lregistries: LRegistry( + where: { id:{ + _in: [ + "0x66260c69d03837016d88c9877e61e08ef74c59f2", + "0xae6aaed5434244be3699c56e7ebc828194f26dc3", + "0x957a53a994860be4750810131d9c876b2f52d6e1", + "0xee1502e29795ef6c2d60f8d7120596abe3bad990" + ]}} + ) { + id + numberOfRegistered + numberOfAbsent + numberOfRegistrationRequested + numberOfClearingRequested + numberOfChallengedRegistrations + numberOfChallengedClearing + } + litems: LItem( + limit: 1000, + order_by: {latestRequestSubmissionTime : desc } + where: { latestRequestSubmissionTime: {_gt: "${thirtyDaysAgo}"} } + ) { + id + status + disputed + latestRequestSubmissionTime + registryAddress + requests(limit: 1, order_by: {submissionTime: desc}) { + submissionTime + requester + challenger + disputed + deposit + } + key0 + key1 + key2 + key3 + key4 + } + } + ` +} + +// Query to get curator statistics +const CURATOR_STATS_QUERY = gql` + query CuratorStats { + litems: LItem(limit: 1000) { + requests { + requester + challenger + } + } + } +` + +const getCardType = (cardName: string): string => { + if (cardName.includes('curators')) return 'curators' + if (cardName.includes('total submissions')) return 'totalSubmissions' + if (cardName.includes('chain ranking')) return 'chainRanking' + if (cardName.includes('tokens v2')) return 'tokens' + if (cardName.includes('cdn v2')) return 'cdn' + if (cardName.includes('address tags v2')) return 'singleTags' + if (cardName.includes('3x security registries')) return 'tagQueries' + return 'unknown' +} + +const fetchDapplookerData = async ( + graphqlBatcher: any, +): Promise => { + if (!DAPPLOOKER_API_KEY) { + return null + } + + try { + const apiUrl = `/api/dapplooker/public/api/public/dashboard/${DASHBOARD_ID}` + + let dashboardResponse = await fetch(apiUrl, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }) + + if (!dashboardResponse.ok) { + const fallbackUrl = `/api/dapplooker/public/api/public/dashboard/${DASHBOARD_ID}` + + dashboardResponse = await fetch(fallbackUrl, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }) + } + + if (dashboardResponse.ok) { + const responseText = await dashboardResponse.text() + + let dashboardInfo + try { + dashboardInfo = JSON.parse(responseText) + } catch (jsonError) { + return null + } + + if ( + dashboardInfo.ordered_cards && + Array.isArray(dashboardInfo.ordered_cards) + ) { + const cardDataPromises = [] + + for (const cardWrapper of dashboardInfo.ordered_cards) { + const card = cardWrapper.card + if (!card?.id || !card?.name) continue + + const cardName = card.name.toLowerCase() + const isRelevantCard = + card.display === 'scalar' || + cardName.includes('chain ranking') || + cardName.includes('tokens v2 - submission') || + cardName.includes('cdn v2 - submission') || + cardName.includes('address tags v2 - submission') || + cardName.includes('3x security registries - stacked by registry') + + if (isRelevantCard) { + cardDataPromises.push( + Promise.resolve({ + id: card.id, + name: card.name, + display: card.display, + cardType: getCardType(cardName), + }), + ) + } + } + + if (cardDataPromises.length > 0) { + const cardResults = await Promise.all(cardDataPromises) + const validCardResults = cardResults.filter(Boolean) + + if (validCardResults.length > 0) { + return await fetchKlerosSubgraphData(graphqlBatcher) + } + } + } + } + + return null + } catch (error) { + return null + } +} + +const getChainFromKey = (key0: string): ChainName | null => { + for (const [prefix, chain] of Object.entries(CHAIN_PREFIXES)) { + if (key0.startsWith(prefix)) { + return chain + } + } + return null +} + +const calculateRegistryStats = ( + registries: RegistryData[], + registryId: string, + fallbackItems?: ItemData[], +) => { + // Try direct match first + let registry = registries.find( + (r) => r.id.toLowerCase() === registryId.toLowerCase(), + ) + + // If not found, try matching without '0x' prefix in case of format difference + if (!registry && registryId.startsWith('0x')) { + registry = registries.find( + (r) => r.id.toLowerCase() === registryId.slice(2).toLowerCase(), + ) + } + + // If still not found, try adding '0x' prefix in case registry ID doesn't have it + if (!registry && !registryId.startsWith('0x')) { + registry = registries.find( + (r) => r.id.toLowerCase() === `0x${registryId}`.toLowerCase(), + ) + } + + if (!registry) { + // Fallback: Calculate from items if available + if (fallbackItems) { + const registryItems = fallbackItems.filter( + (item) => + item.registryAddress?.toLowerCase() === registryId.toLowerCase(), + ) + + const registered = registryItems.filter( + (item) => item.status === 'Registered', + ).length + const registrationRequested = registryItems.filter( + (item) => item.status === 'RegistrationRequested', + ).length + const clearingRequested = registryItems.filter( + (item) => item.status === 'ClearingRequested', + ).length + const totalSubmissions = + registered + registrationRequested + clearingRequested + + return { + assetsVerified: registered, + assetsVerifiedChange: totalSubmissions, + } + } + + return { assetsVerified: 0, assetsVerifiedChange: 0 } + } + + const registered = parseInt(registry.numberOfRegistered, 10) || 0 + const registrationRequested = + parseInt(registry.numberOfRegistrationRequested, 10) || 0 + const clearingRequested = + parseInt(registry.numberOfClearingRequested, 10) || 0 + + const totalSubmissions = + registered + registrationRequested + clearingRequested + + return { + assetsVerified: registered, + assetsVerifiedChange: totalSubmissions, + } +} + +const generateDateRange = (days: number): Date[] => { + const now = new Date() + return Array.from({ length: days }, (_, i) => { + const date = new Date(now) + date.setDate(date.getDate() - (days - 1 - i)) + return date + }) +} + +const filterItemsByDate = ( + items: ItemData[], + targetDate: Date, + includeDisputed = false, +): number => { + const dayStart = new Date(targetDate) + dayStart.setHours(0, 0, 0, 0) + const dayEnd = new Date(targetDate) + dayEnd.setHours(23, 59, 59, 999) + + return items.filter((item) => { + const submissionTime = new Date( + parseInt(item.latestRequestSubmissionTime, 10) * 1000, + ) + const isInDateRange = submissionTime >= dayStart && submissionTime <= dayEnd + return includeDisputed ? isInDateRange && item.disputed : isInDateRange + }).length +} + +const calculateChainRanking = (items: ItemData[]) => { + const chainCounts = new Map() + + items.forEach((item) => { + const key0 = item?.key0 + if (!key0) return + + const chain = getChainFromKey(key0) + if (chain) { + chainCounts.set(chain, (chainCounts.get(chain) || 0) + 1) + } + }) + + return Array.from(chainCounts.entries()) + .sort(([, a], [, b]) => b - a) + .slice(0, 5) + .map(([chain, count], index) => ({ + rank: index + 1, + chain, + items: count, + })) +} + +const fetchKlerosSubgraphData = async ( + graphqlBatcher: any, +): Promise => { + const statsRequestId = `stats-${Date.now()}-${Math.random()}` + const curatorRequestId = `curator-${Date.now()}-${Math.random()}` + + const [statsResult, curatorResult] = await Promise.all([ + graphqlBatcher.request( + statsRequestId, + getCurateStatsQuery(), + ) as Promise, + graphqlBatcher.request(curatorRequestId, CURATOR_STATS_QUERY) as Promise<{ + litems: ItemData[] + }>, + ]) + + const registries = statsResult.lregistries || [] + const items = statsResult.litems || [] + const curatorItems = curatorResult.litems || [] + + // Calculate totals + const totalVerified = registries.reduce((total, reg) => { + return total + (parseInt(reg.numberOfRegistered, 10) || 0) + }, 0) + + const totalSubmissions = registries.reduce((total, reg) => { + return ( + total + + (parseInt(reg.numberOfRegistered, 10) || 0) + + (parseInt(reg.numberOfRegistrationRequested, 10) || 0) + + (parseInt(reg.numberOfClearingRequested, 10) || 0) + ) + }, 0) + + // Calculate unique curators + const uniqueCurators = new Set() + curatorItems.forEach((item) => { + item.requests?.forEach((req) => { + if (req.requester) uniqueCurators.add(req.requester.toLowerCase()) + if (req.challenger) uniqueCurators.add(req.challenger.toLowerCase()) + }) + }) + + // Generate time series data + const last7Days = generateDateRange(30) + const submissionsData = last7Days.map((date) => + filterItemsByDate(items, date), + ) + const disputesData = last7Days.map((date) => + filterItemsByDate(items, date, true), + ) + const chainRanking = calculateChainRanking(items) + + const tokensStats = calculateRegistryStats( + registries, + registryMap.Tokens, + items, + ) + const cdnStats = calculateRegistryStats(registries, registryMap.CDN, items) + const singleTagsStats = calculateRegistryStats( + registries, + registryMap.Single_Tags, + items, + ) + const tagQueriesStats = calculateRegistryStats( + registries, + registryMap.Tags_Queries, + items, + ) + + return { + totalAssetsVerified: totalVerified, + totalSubmissions: totalSubmissions, + totalCurators: uniqueCurators.size, + tokens: tokensStats, + cdn: cdnStats, + singleTags: singleTagsStats, + tagQueries: tagQueriesStats, + submissionsVsDisputes: { + submissions: submissionsData, + disputes: disputesData, + dates: last7Days.map((d) => + d.toLocaleDateString('en', { month: 'short', day: 'numeric' }), + ), + }, + chainRanking, + } +} + +export const useDapplookerStats = () => { + const graphqlBatcher = useGraphqlBatcher() + + return useQuery({ + queryKey: ['dapplooker-stats'], + queryFn: async (): Promise => { + try { + if (DAPPLOOKER_API_KEY) { + const enhancedData = await fetchDapplookerData(graphqlBatcher) + if (enhancedData) { + return enhancedData + } + } + + return await fetchKlerosSubgraphData(graphqlBatcher) + } catch (error) { + return EMPTY_STATS + } + }, + ...CACHE_CONFIG, + }) +} diff --git a/websites/app/src/hooks/useKlerosDisputes.ts b/websites/app/src/hooks/useKlerosDisputes.ts new file mode 100644 index 0000000..b40c60e --- /dev/null +++ b/websites/app/src/hooks/useKlerosDisputes.ts @@ -0,0 +1,143 @@ +import { useQuery } from '@tanstack/react-query'; +import { gql } from 'graphql-request'; +import { SUBGRAPH_KLEROS_DISPLAY_GNOSIS_ENDPOINT } from 'consts'; + +interface KlerosDispute { + id: string; + disputeIDNumber: string; + court: { + id: string; + }; + period: string; + lastPeriodChangeTs: string; + ruled: boolean; + ruling: string; + arbitrated: string; +} + +interface KlerosDisputesResponse { + disputes: KlerosDispute[]; +} + +const KLEROS_DISPUTES_QUERY = gql` + query LatestDisputes($first: Int!, $orderBy: String!, $orderDirection: String!, $court: String!) { + disputes(first: $first, orderBy: $orderBy, orderDirection: $orderDirection, where: { court: $court }) { + id + disputeIDNumber + court { + id + } + period + lastPeriodChangeTs + ruled + ruling + arbitrated + } + } +`; + +// xDAI Curation Court ID +const XDAI_CURATION_COURT_ID = '1'; + +const CACHE_CONFIG = { + staleTime: 2 * 60 * 1000, // 2 minutes + refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes + retry: 2, +} as const; + +const fetchKlerosDisputes = async (first = 10): Promise => { + try { + + const response = await fetch(SUBGRAPH_KLEROS_DISPLAY_GNOSIS_ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify({ + query: KLEROS_DISPUTES_QUERY, + variables: { + first, + orderBy: 'disputeIDNumber', + orderDirection: 'desc', + court: XDAI_CURATION_COURT_ID, + }, + }), + }); + + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP error! status: ${response.status} - ${errorText}`); + } + + const result: { data: KlerosDisputesResponse } = await response.json(); + + if (!result.data?.disputes) { + return []; + } + + + return result.data.disputes; + } catch (error) { + throw error; + } +}; + +export const useKlerosDisputes = (first = 10) => { + return useQuery({ + queryKey: ['kleros-disputes', first], + queryFn: () => fetchKlerosDisputes(first), + ...CACHE_CONFIG, + }); +}; + +// Helper functions for dispute data +export const getDisputePeriodName = (period: string): string => { + const periods: Record = { + 'EVIDENCE': 'Evidence', + 'COMMIT': 'Commit', + 'VOTE': 'Vote', + 'APPEAL': 'Appeal', + 'EXECUTED': 'Executed', + }; + return periods[period] || period; +}; + +export const formatDisputeDeadline = (lastPeriodChangeTs: string): string => { + const lastChange = new Date(parseInt(lastPeriodChangeTs) * 1000); + const now = new Date(); + + // Check if the timestamp is in the future (possibly incorrect data) + if (lastChange.getTime() > now.getTime()) { + return 'Recently updated'; + } + + const diffMs = now.getTime() - lastChange.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const diffHours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + + if (diffDays > 0) { + return `${diffDays} days, ${diffHours}h ago`; + } else if (diffHours > 0) { + return `${diffHours}h ago`; + } else { + const diffMinutes = Math.floor(diffMs / (1000 * 60)); + return `${Math.max(1, diffMinutes)}m ago`; + } +}; + +export const getCourtName = (courtID: string): string => { + const courts: Record = { + '0': 'General Court', + '1': 'Curation Court', // xDAI Curation Court + '2': 'Technical Court', + '3': 'Marketing Services Court', + '4': 'English Language Court', + '5': 'Video Production Court', + '6': 'Onboarding Court', + '7': 'Translation Court', + '8': 'Data Analysis Court', + }; + return courts[courtID] || 'General Court'; +}; \ No newline at end of file diff --git a/websites/app/src/hooks/useLockOverlayScroll.ts b/websites/app/src/hooks/useLockOverlayScroll.ts new file mode 100644 index 0000000..831a0ed --- /dev/null +++ b/websites/app/src/hooks/useLockOverlayScroll.ts @@ -0,0 +1,29 @@ +import { useContext, useEffect, useCallback } from "react"; + +import { OverlayScrollContext } from "context/OverlayScrollContext"; + +export const useLockOverlayScroll = (shouldLock: boolean) => { + const osInstanceRef = useContext(OverlayScrollContext); + + const lockScroll = useCallback(() => { + const osInstance = osInstanceRef?.current?.osInstance(); + if (osInstance) { + osInstance.options({ overflow: { x: "hidden", y: "hidden" } }); + } + }, [osInstanceRef]); + + const unlockScroll = useCallback(() => { + const osInstance = osInstanceRef?.current?.osInstance(); + if (osInstance) { + osInstance.options({ overflow: { x: "scroll", y: "scroll" } }); + } + }, [osInstanceRef]); + + useEffect(() => { + if (shouldLock) { + lockScroll(); + } else { + unlockScroll(); + } + }, [shouldLock, lockScroll, unlockScroll]); +}; diff --git a/websites/app/src/hooks/useScrollTop.ts b/websites/app/src/hooks/useScrollTop.ts index 65c539b..485d081 100644 --- a/websites/app/src/hooks/useScrollTop.ts +++ b/websites/app/src/hooks/useScrollTop.ts @@ -4,8 +4,11 @@ import { OverlayScrollContext } from "context/OverlayScrollContext"; export const useScrollTop = () => { const osInstanceRef = useContext(OverlayScrollContext); - const scrollTop = () => { - osInstanceRef?.current?.osInstance().elements().viewport.scroll({ top: 0 }); + const scrollTop = (smooth = false) => { + osInstanceRef?.current + ?.osInstance() + ?.elements() + .viewport.scroll({ top: 0, behavior: smooth ? "smooth" : "auto" }); }; return scrollTop; diff --git a/websites/app/src/hooks/useSubmitterStats.ts b/websites/app/src/hooks/useSubmitterStats.ts new file mode 100644 index 0000000..f5762fa --- /dev/null +++ b/websites/app/src/hooks/useSubmitterStats.ts @@ -0,0 +1,55 @@ +import { useQuery } from "@tanstack/react-query"; +import { SUBGRAPH_GNOSIS_ENDPOINT } from "consts"; + +const SUBMITTER_STATS_QUERY = ` + query SubmitterStats($userAddress: String!) { + ongoing: LItem( + where: { + status: {_in: [RegistrationRequested, ClearingRequested]} + requests: {requester: {_eq: $userAddress}} + } + limit: 10000 + ) { + id + } + past: LItem( + where: { + status: {_in: [Registered, Absent]} + requests: {requester: {_eq: $userAddress}} + } + limit: 10000 + ) { + id + } + } +`; + +export const useSubmitterStats = (address?: string) => { + const userAddress = address?.toLowerCase(); + return useQuery({ + queryKey: ["refetchOnBlock", "useSubmitterStats", userAddress], + enabled: !!userAddress, + queryFn: async () => { + const response = await fetch(SUBGRAPH_GNOSIS_ENDPOINT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + query: SUBMITTER_STATS_QUERY, + variables: { userAddress }, + }), + }); + const json = await response.json(); + + const ongoingCount = json.data?.ongoing?.length || 0; + const pastCount = json.data?.past?.length || 0; + + return { + submitter: { + totalSubmissions: ongoingCount + pastCount, + ongoingSubmissions: ongoingCount, + pastSubmissions: pastCount + } + }; + }, + }); +}; diff --git a/websites/app/src/index.html b/websites/app/src/index.html index c246ad9..373d07f 100644 --- a/websites/app/src/index.html +++ b/websites/app/src/index.html @@ -20,7 +20,7 @@ content="dispute resolution, decentralized arbitration, Kleros, blockchain court" /> - Kleros Scout + Scout · Kleros
diff --git a/websites/app/src/layout/Footer/index.tsx b/websites/app/src/layout/Footer/index.tsx new file mode 100644 index 0000000..5017c9d --- /dev/null +++ b/websites/app/src/layout/Footer/index.tsx @@ -0,0 +1,80 @@ +import React from "react"; +import styled, { css } from "styled-components"; + +import { landscapeStyle } from "styles/landscapeStyle"; +import { hoverShortTransitionTiming } from "styles/commonStyles"; + +import SecuredByKlerosLogo from "svgs/footer/secured-by-kleros.svg"; + +import { socialmedia } from "consts/socialmedia"; + +import LightButton from "components/LightButton"; +import { ExternalLink } from "components/ExternalLink"; + +const Container = styled.div` + display: flex; + min-height: 114px; + width: 100%; + background-color: ${({ theme }) => (theme.name === "dark" ? theme.lightGrey : theme.primaryPurple)}; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 8px; + gap: 16px; + z-index: 1; + + ${landscapeStyle( + () => css` + min-height: 64px; + flex-direction: row; + justify-content: space-between; + padding: 0 32px; + ` + )} +`; + +const StyledSecuredByKlerosLogo = styled(SecuredByKlerosLogo)` + ${hoverShortTransitionTiming} + min-height: 24px; + + path { + fill: ${({ theme }) => theme.white}BF; + } + + :hover path { + fill: ${({ theme }) => theme.white}; + } +`; + +const StyledSocialMedia = styled.div` + display: flex; + + .button-svg { + margin-right: 0; + } +`; + +const SecuredByKleros: React.FC = () => ( + + + +); + +const SocialMedia = () => ( + + {Object.values(socialmedia).map((site, i) => ( + + + + ))} + +); + +const Footer: React.FC = () => ( + + + + +); + +export default Footer; diff --git a/websites/app/src/layout/Header/DesktopHeader.tsx b/websites/app/src/layout/Header/DesktopHeader.tsx new file mode 100644 index 0000000..2dba932 --- /dev/null +++ b/websites/app/src/layout/Header/DesktopHeader.tsx @@ -0,0 +1,126 @@ +import React, { useCallback, useEffect, useState } from "react"; +import styled, { css } from "styled-components"; + +import { useLocation } from "react-router-dom"; +import { useToggle } from "react-use"; +import { useAccount } from "wagmi"; + +import { DEFAULT_CHAIN } from "consts/chains"; +import { useLockOverlayScroll } from "hooks/useLockOverlayScroll"; + +import { landscapeStyle } from "styles/landscapeStyle"; +import { responsiveSize } from "styles/responsiveSize"; + +import ConnectWallet from "components/ConnectWallet"; +import OverlayPortal from "components/OverlayPortal"; +import { Overlay } from "components/Overlay"; + +import Logo from "./Logo"; +import DappList from "./navbar/DappList"; +import Explore from "./navbar/Explore"; +import Menu from "./navbar/Menu"; +import Help from "./navbar/Menu/Help"; +import Settings from "./navbar/Menu/Settings"; + +const Container = styled.div` + display: none; + position: absolute; + height: 64px; + + ${landscapeStyle( + () => css` + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + position: relative; + ` + )}; +`; + +const LeftSide = styled.div` + display: flex; + gap: 8px; +`; + +const MiddleSide = styled.div` + display: flex; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); +`; + +const RightSide = styled.div` + display: flex; + gap: ${responsiveSize(4, 8)}; + + margin-left: 8px; + canvas { + width: 20px; + } +`; + +const ConnectWalletContainer = styled.div<{ isConnected: boolean; isDefaultChain: boolean }>` + label { + color: ${({ theme }) => theme.white}; + cursor: pointer; + } +`; + +const DesktopHeader: React.FC = () => { + const [isDappListOpen, toggleIsDappListOpen] = useToggle(false); + const [isHelpOpen, toggleIsHelpOpen] = useToggle(false); + const [isSettingsOpen, toggleIsSettingsOpen] = useToggle(false); + const [initialTab, setInitialTab] = useState(0); + const location = useLocation(); + const { isConnected, chainId } = useAccount(); + const isDefaultChain = chainId === DEFAULT_CHAIN; + const initializeFragmentURL = useCallback(() => { + const hashIncludes = (hash: "#notifications") => location.hash.includes(hash); + const hasNotificationsPath = hashIncludes("#notifications"); + toggleIsSettingsOpen(hasNotificationsPath); + setInitialTab(hasNotificationsPath ? 1 : 0); + }, [ + toggleIsSettingsOpen, + location.hash, + ]); + + useEffect(initializeFragmentURL, [initializeFragmentURL]); + + useLockOverlayScroll(isDappListOpen || isHelpOpen || isSettingsOpen); + + return ( + <> + + + + + + + + + + + + + + + + + {(isDappListOpen || isHelpOpen || isSettingsOpen) && ( + + + {isDappListOpen && } + {isHelpOpen && } + {isSettingsOpen && } + + + )} + + ); +}; +export default DesktopHeader; diff --git a/websites/app/src/layout/Header/Logo.tsx b/websites/app/src/layout/Header/Logo.tsx new file mode 100644 index 0000000..3051a74 --- /dev/null +++ b/websites/app/src/layout/Header/Logo.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import styled from "styled-components"; + +import { hoverShortTransitionTiming } from "styles/commonStyles"; + +import { Link } from "react-router-dom"; + +import KlerosScoutLogo from "svgs/header/kleros-scout.svg"; + +const Container = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 16px; +`; + +const StyledKlerosScoutLogo = styled(KlerosScoutLogo)` + ${hoverShortTransitionTiming} + max-height: 40px; + width: auto; + + &:hover { + path { + fill: ${({ theme }) => theme.white}BF; + } + } +`; + +const Logo: React.FC = () => ( + + {" "} + + + + +); + +export default Logo; diff --git a/websites/app/src/layout/Header/MobileHeader.tsx b/websites/app/src/layout/Header/MobileHeader.tsx new file mode 100644 index 0000000..280881c --- /dev/null +++ b/websites/app/src/layout/Header/MobileHeader.tsx @@ -0,0 +1,63 @@ +import React, { useContext, useMemo, useRef } from "react"; +import styled, { css } from "styled-components"; + +import { useClickAway, useToggle } from "react-use"; + +import HamburgerIcon from "svgs/header/hamburger.svg"; + +import { landscapeStyle } from "styles/landscapeStyle"; + +import LightButton from "components/LightButton"; + +import Logo from "./Logo"; +import NavBar from "./navbar"; + +const Container = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + height: 64px; + + ${landscapeStyle( + () => css` + display: none; + ` + )} +`; + +const StyledLightButton = styled(LightButton)` + padding: 0 !important; + + .button-svg { + margin-right: 0px; + } +`; + +const OpenContext = React.createContext({ + isOpen: false, + toggleIsOpen: () => { + // Placeholder + }, +}); + +export function useOpenContext() { + return useContext(OpenContext); +} + +const MobileHeader = () => { + const [isOpen, toggleIsOpen] = useToggle(false); + const containerRef = useRef(null); + useClickAway(containerRef, () => toggleIsOpen(false)); + const memoizedContext = useMemo(() => ({ isOpen, toggleIsOpen }), [isOpen, toggleIsOpen]); + return ( + + + + + + + + ); +}; +export default MobileHeader; diff --git a/websites/app/src/layout/Header/index.tsx b/websites/app/src/layout/Header/index.tsx new file mode 100644 index 0000000..858a1bb --- /dev/null +++ b/websites/app/src/layout/Header/index.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import styled from "styled-components"; + +import DesktopHeader from "./DesktopHeader"; +import MobileHeader from "./MobileHeader"; + +const Container = styled.div` + display: flex; + flex-wrap: wrap; + position: sticky; + z-index: 10; + top: 0; + width: 100%; + background-color: ${({ theme }) => (theme.name === "dark" ? `${theme.lightGrey}` : theme.primaryPurple)}; + backdrop-filter: ${({ theme }) => (theme.name === "dark" ? "blur(12px)" : "none")}; + -webkit-backdrop-filter: ${({ theme }) => (theme.name === "dark" ? "blur(12px)" : "none")}; // Safari support + border-bottom: 1px solid ${({ theme }) => theme.white}29; +`; + +const HeaderContainer = styled.div` + width: 100%; + padding: 0px 24px; +`; + +const Header: React.FC = () => { + return ( + + + + + + + ); +}; + +export default Header; diff --git a/websites/app/src/layout/Header/navbar/DappList.tsx b/websites/app/src/layout/Header/navbar/DappList.tsx new file mode 100644 index 0000000..c6fa55e --- /dev/null +++ b/websites/app/src/layout/Header/navbar/DappList.tsx @@ -0,0 +1,156 @@ +import React, { useRef } from "react"; +import styled, { css } from "styled-components"; + +import { useClickAway } from "react-use"; + +import Curate from "svgs/icons/curate-image.png"; +import Resolver from "svgs/icons/dispute-resolver.svg"; +import Escrow from "svgs/icons/escrow.svg"; +import Governor from "svgs/icons/governor.svg"; +import Court from "svgs/icons/kleros.svg"; +import POH from "svgs/icons/poh-image.png"; +import Vea from "svgs/icons/vea.svg"; + +import { landscapeStyle } from "styles/landscapeStyle"; +import { responsiveSize } from "styles/responsiveSize"; + +import Product from "./Product"; + +const Container = styled.div` + display: flex; + position: absolute; + max-height: 340px; + top: 5%; + left: 50%; + transform: translate(-50%); + z-index: 1; + flex-direction: column; + align-items: center; + + width: 86vw; + max-width: 480px; + border-radius: 3px; + border: 1px solid ${({ theme }) => theme.stroke}; + background-color: ${({ theme }) => theme.whiteBackground}; + box-shadow: 0px 2px 3px rgba(0, 0, 0, 0.06); + + svg { + visibility: visible; + } + + ${landscapeStyle( + () => css` + margin-top: 64px; + top: 0; + left: 0; + right: auto; + transform: none; + width: ${responsiveSize(300, 480)}; + max-height: 80vh; + ` + )} +`; + +const Header = styled.h1` + padding-top: 24px; + font-size: 24px; + font-weight: 600; + line-height: 32.68px; +`; + +const ItemsDiv = styled.div` + display: grid; + overflow-y: auto; + padding: 4px ${responsiveSize(8, 24)} 16px ${responsiveSize(8, 24)}; + row-gap: 8px; + column-gap: 2px; + justify-items: center; + max-width: 480px; + min-width: 300px; + width: ${responsiveSize(300, 480)}; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); +`; + +const ITEMS = [ + { + text: "Court V2", + Icon: Court, + url: "https://v2.kleros.builders/", + }, + { + text: "Curate V2", + Icon: Curate, + url: "https://curate-v2.netlify.app/", + }, + { + text: "Resolver V2", + Icon: Resolver, + url: "https://v2.kleros.builders/#/resolver", + }, + { + text: "Escrow V2", + Icon: Escrow, + url: "https://escrow-v2.kleros.builders/", + }, + { + text: "Court V1", + Icon: Court, + url: "https://court.kleros.io/", + }, + { + text: "Curate V1", + Icon: Curate, + url: "https://curate.kleros.io", + }, + { + text: "Resolver V1", + Icon: Resolver, + url: "https://resolve.kleros.io", + }, + { + text: "Escrow V1", + Icon: Escrow, + url: "https://escrow.kleros.io", + }, + { + text: "Vea", + Icon: Vea, + url: "https://veascan.io", + }, + { + text: "Kleros Scout", + Icon: Curate, + url: "https://klerosscout.eth.limo", + }, + { + text: "POH V2", + Icon: POH, + url: "https://v2.proofofhumanity.id", + }, + { + text: "Governor", + Icon: Governor, + url: "https://governor.kleros.io", + }, +]; + +interface IDappList { + toggleIsDappListOpen: () => void; +} + +const DappList: React.FC = ({ toggleIsDappListOpen }) => { + const containerRef = useRef(null); + useClickAway(containerRef, () => toggleIsDappListOpen()); + + return ( + +
Kleros Solutions
+ + {ITEMS.map((item) => { + return ; + })} + +
+ ); +}; +export default DappList; diff --git a/websites/app/src/layout/Header/navbar/Debug.tsx b/websites/app/src/layout/Header/navbar/Debug.tsx new file mode 100644 index 0000000..94520d2 --- /dev/null +++ b/websites/app/src/layout/Header/navbar/Debug.tsx @@ -0,0 +1,69 @@ +import React, { useMemo } from "react"; +import styled from "styled-components"; + +import { GIT_BRANCH, GIT_DIRTY, GIT_HASH, GIT_TAGS, GIT_URL, RELEASE_VERSION } from "consts/index"; +import { useToggleTheme } from "hooks/useToggleThemeContext"; +import { isUndefined } from "utils/index"; + +import Phase from "components/Phase"; + +const Container = styled.div` + display: flex; + flex-direction: column; + gap: 12px; + padding: 0px 3px; + + label, + a { + font-family: "Roboto Mono", monospace; + line-height: 10px; + font-size: 10px; + color: ${({ theme }) => theme.stroke}; + } +`; + +const StyledIframe = styled.iframe` + border: none; + width: 100%; + height: 30px; + border-radius: 3px; +`; + +const StyledLabel = styled.label` + padding-left: 8px; +`; + +const StyledPhase = styled(Phase)` + padding-left: 8px; +`; + +const Version = () => ( + + v{RELEASE_VERSION}{" "} + + #{GIT_HASH} + + {GIT_BRANCH && GIT_BRANCH !== "HEAD" && ` ${GIT_BRANCH}`} + {GIT_TAGS && ` ${GIT_TAGS}`} + {GIT_DIRTY && ` dirty`} + +); + +const ServicesStatus = () => { + const [theme] = useToggleTheme(); + const statusUrlParameters = useMemo(() => (theme === "light" ? "?theme=light" : "?theme=dark"), [theme]); + const statusUrl = import.meta.env.REACT_APP_STATUS_URL; + return ; +}; + +const Debug: React.FC = () => { + return ( + + + + + + ); +}; + +export default Debug; diff --git a/websites/app/src/layout/Header/navbar/Explore.tsx b/websites/app/src/layout/Header/navbar/Explore.tsx new file mode 100644 index 0000000..87e6cb3 --- /dev/null +++ b/websites/app/src/layout/Header/navbar/Explore.tsx @@ -0,0 +1,196 @@ +import React, { useMemo, useState, useEffect, useRef } from "react"; +import styled, { css } from "styled-components"; +import { landscapeStyle } from "styles/landscapeStyle"; + +import { Link, useLocation, useSearchParams } from "react-router-dom"; + +import ArrowDown from "svgs/icons/arrow-down.svg"; + +import { useOpenContext } from "../MobileHeader"; + +const Container = styled.div` + display: flex; + flex-direction: column; + + ${landscapeStyle( + () => css` + flex-direction: row; + ` + )}; +`; + +const Title = styled.h1` + display: block; + margin-bottom: 8px; + + ${landscapeStyle( + () => css` + display: none; + ` + )}; +`; + +const flexLinkStyle = css<{ isActive: boolean; isMobileNavbar?: boolean }>` + display: flex; + align-items: center; + text-decoration: none; + font-size: 16px; + color: ${({ isActive, theme }) => (isActive ? theme.primaryText : `${theme.primaryText}BA`)}; + font-weight: ${({ isActive, isMobileNavbar }) => (isMobileNavbar && isActive ? "600" : "normal")}; + + border-radius: 7px; + &:hover { + color: ${({ theme, isMobileNavbar }) => (isMobileNavbar ? theme.primaryText : theme.white)} !important; + } + + ${landscapeStyle( + () => css` + color: ${({ isActive, theme }) => (isActive ? theme.white : `${theme.white}BA`)}; + ` + )}; +`; + +const StyledLink = styled(Link)<{ isActive: boolean; isMobileNavbar?: boolean }>` + ${flexLinkStyle}; + padding: 8px 16px 8px 0; +`; + +const StyledToggle = styled.div<{ isActive: boolean; isMobileNavbar?: boolean }>` + cursor: pointer; + ${flexLinkStyle}; + padding: 8px 8px 8px 0; +`; + +const StyledArrowDown = styled(ArrowDown)<{ open: boolean }>` + margin-left: 4px; + width: 20px; + height: 12px; + transition: transform 0.2s; + transform: rotate(${({ open }) => (open ? "180deg" : "0deg")}); +`; + +const DropdownContainer = styled.div` + position: relative; +`; + +const DropdownMenu = styled.div<{ isMobileNavbar?: boolean }>` + display: flex; + flex-direction: column; + position: absolute; + top: calc(100% + 4px); + left: 0; + background: ${({ theme }) => theme.lightBackground}; + border-radius: 12px; + min-width: 260px; + padding: 8px 0; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + z-index: 1000; + ${landscapeStyle( + () => css` + left: 50%; + transform: translateX(-50%); + ` + )}; +`; + +const DropdownItem = styled(Link)<{ isActive: boolean; isMobileNavbar?: boolean }>` + ${flexLinkStyle}; + width: 100%; + padding: 16px 24px; + white-space: nowrap; +`; + +interface IExplore { + isMobileNavbar?: boolean; +} + +const Explore: React.FC = ({ isMobileNavbar }) => { + const location = useLocation(); + const [searchParams] = useSearchParams(); + const { toggleIsOpen } = useOpenContext(); + const [open, setOpen] = useState(false); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setOpen(false); + } + }; + if (open) document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [open]); + + const navLinks = useMemo(() => [{ to: "/dashboard", text: "Dashboard" }], []); + + const currentSeg = useMemo(() => location.pathname.split("/")[1] || "", [location.pathname]); + const ownsProfile = !searchParams.get("address"); + + const getIsActive = (to: string) => { + const path = to.split("?")[0]; + if (path === "/") return location.pathname === "/"; + const targetSeg = path.split("/")[1] || ""; + if (targetSeg !== currentSeg) return false; + return targetSeg !== "profile" || ownsProfile; + }; + + const registryOptions = useMemo( + () => [ + { label: "Tokens", value: "Tokens" }, + { label: "Contract Domain Name", value: "CDN" }, + { label: "Address Tags - Single Tags", value: "Single_Tags" }, + { label: "Address Tags - Query Tags", value: "Tags_Queries" }, + ], + [] + ); + + const handleOptionClick = () => { + toggleIsOpen(); + setOpen(false); + }; + + return ( + + Explore + {navLinks.map(({ to, text }) => ( + + {text} + + ))} + + setOpen(!open)} + > + Explore Registries + + + {open && ( + + {registryOptions.map(({ label, value }) => ( + + {label} + + ))} + + )} + + + ); +}; + +export default Explore; diff --git a/websites/app/src/layout/Header/navbar/Menu/Help.tsx b/websites/app/src/layout/Header/navbar/Menu/Help.tsx new file mode 100644 index 0000000..3e013e9 --- /dev/null +++ b/websites/app/src/layout/Header/navbar/Menu/Help.tsx @@ -0,0 +1,137 @@ +import React, { useRef } from "react"; +import styled, { css } from "styled-components"; +import { landscapeStyle } from "styles/landscapeStyle"; + +import { useClickAway } from "react-use"; + +import Guide from "svgs/icons/book.svg"; +import Bug from "svgs/icons/bug.svg"; +import Chat from "svgs/icons/chat.svg" +import ETH from "svgs/icons/eth.svg"; +import Faq from "svgs/menu-icons/help.svg"; +import Telegram from "svgs/socialmedia/telegram.svg"; + +import { IHelp } from "../index"; + +const Container = styled.div` + display: flex; + flex-direction: column; + position: absolute; + max-height: 80vh; + overflow-y: auto; + width: 86vw; + max-width: 444px; + top: 5%; + left: 50%; + transform: translateX(-50%); + z-index: 1; + padding: 12px 12px 24px 12px; + border: 0.1px solid ${({ theme }) => theme.stroke}; + background-color: ${({ theme }) => theme.lightBackground}; + border-radius: 12px; + box-shadow: 0px 2px 3px rgba(0, 0, 0, 0.06); + + ${landscapeStyle( + () => css` + margin-top: 64px; + width: 260px; + top: 0; + right: 0; + left: auto; + transform: none; + ` + )} +`; + +const ListItem = styled.a` + display: flex; + gap: 8px; + padding: 12px 8px; + cursor: pointer; + transition: transform 0.2s; + + small { + font-size: 16px; + font-weight: 400; + color: ${({ theme }) => theme.secondaryText}; + } + + :hover { + transform: scale(1.02); + } + + :hover small { + transition: color 0.1s; + color: ${({ theme }) => theme.white}; + } + + :hover svg { + transition: color 0.1s; + fill: ${({ theme }) => theme.white}; + } +`; + +const Icon = styled.svg` + display: inline-block; + width: 16px; + height: 16px; + fill: ${({ theme }) => theme.secondaryText}; +`; + +const ITEMS = [ + { + text: "Get Help", + Icon: Telegram, + url: "https://t.me/KlerosCurate", + }, + { + text: "Report a Bug", + Icon: Bug, + url: "https://github.com/kleros/scout/issues", + }, + { + text: "Give Feedback", + Icon: Chat, + url: "https://forms.gle/Qnr9QwqRjaYMyB3t6", + }, + { + text: "App Guide", + Icon: Guide, + url: "https://docs.kleros.io/products/curate/kleros-scout", + }, + { + text: "Crypto Beginner's Guide", + Icon: ETH, + url: "https://ethereum.org/en/wallets/", + }, + { + text: "FAQ", + Icon: Faq, + url: "https://docs.kleros.io/products/curate/kleros-scout/faqs", + }, +]; + +const Help: React.FC = ({ toggleIsHelpOpen }) => { + const containerRef = useRef(null); + useClickAway(containerRef, () => { + toggleIsHelpOpen(); + }); + + return ( + <> + + {ITEMS.map((item) => ( + + + {item.text} + + ))} + + + ); +}; +export default Help; diff --git a/websites/app/src/layout/Header/navbar/Menu/Settings/General/WalletAndProfile.tsx b/websites/app/src/layout/Header/navbar/Menu/Settings/General/WalletAndProfile.tsx new file mode 100644 index 0000000..009ca46 --- /dev/null +++ b/websites/app/src/layout/Header/navbar/Menu/Settings/General/WalletAndProfile.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import styled from "styled-components"; + +import { hoverLongTransitionTiming } from "styles/commonStyles"; + +import ArrowIcon from "svgs/icons/arrow.svg"; + +import { AddressOrName, IdenticonOrAvatar } from "components/ConnectWallet/AccountDisplay"; +import { StyledArrowLink } from "components/StyledArrowLink"; +import { ISettings } from "../../../index"; + +const Container = styled.div` + ${hoverLongTransitionTiming} + display: flex; + justify-content: center; + align-items: center; + padding: 16px 32px; + gap: 24px; + border: 1px solid ${({ theme }) => theme.stroke}; + border-radius: 30px; + + > label { + color: ${({ theme }) => theme.primaryText}; + font-size: 16px; + font-weight: 600; + } + + :hover { + background-color: ${({ theme }) => theme.lightGrey}; + } +`; + +const AvatarAndAddressContainer = styled.div` + display: flex; + flex-direction: row; + gap: 8px; +`; + +const ReStyledArrowLink = styled(StyledArrowLink)` + font-size: 14px; + + > svg { + height: 14px; + width: 14px; + } +`; + +const WalletAndProfile: React.FC = ({ toggleIsSettingsOpen }) => { + return ( + + + + + + + My Activity + + + ); +}; +export default WalletAndProfile; diff --git a/websites/app/src/layout/Header/navbar/Menu/Settings/General/index.tsx b/websites/app/src/layout/Header/navbar/Menu/Settings/General/index.tsx new file mode 100644 index 0000000..b1dccab --- /dev/null +++ b/websites/app/src/layout/Header/navbar/Menu/Settings/General/index.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import styled from "styled-components"; + +import { useAccount, useDisconnect } from "wagmi"; + +import { Button } from "@kleros/ui-components-library"; + +import { ChainDisplay } from "components/ConnectWallet/AccountDisplay"; +import { EnsureChain } from "components/EnsureChain"; +import WalletAndProfile from "./WalletAndProfile"; +import { ISettings } from "../../../index"; + +const Container = styled.div` + display: flex; + flex-direction: column; + justify-content: center; +`; + +const StyledChainContainer = styled.div` + display: flex; + gap: 0.5rem; + justify-content: center; + align-items: center; + :before { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; + background-color: ${({ theme }) => theme.success}; + } + > label { + color: ${({ theme }) => theme.success}; + } +`; + +const StyledButton = styled.div` + display: flex; + justify-content: center; + margin-top: 8px; +`; + +const EnsureChainContainer = styled.div` + display: flex; + justify-content: center; + padding: 24px 32px 20px 32px; +`; + +const UserContainer = styled.div` + display: flex; + flex-direction: column; + gap: 16px; +`; + +export const DisconnectWalletButton: React.FC = () => { + const { disconnect } = useDisconnect(); + return